This blueprint proposes a new declarative system of connection management and session and transaction scoping for all projects that currently make use of oslo.db.session.EngineFacade. Change-Id: I8c0efa3b859c2fe5f06f4c0416c5323362d7ef72
30 KiB
Make EngineFacade into a Facade
https://blueprints.launchpad.net/oslo.db/+spec/make-enginefacade-a-facade
Problem description
The oslo.db.sqlalchemy.session.EngineFacade class serves as the
gateway to the SQLAlchemy Engine and Session objects within many
OpenStack projects, including Ceilometer, Glance, Heat, Ironic,
Keystone, Neutron, Nova, and Sahara. However, the object is severely
under-functional; while it provides a function call that ultimately
calls create_engine()
and sessionmaker()
,
consuming projects receive no other utility from this object, and in
order to solve closely related problems that all of them share, each
invent their own systems, all of which are different, verbose, and error
prone, with various performance, stability, and scalability issues.
Registry Functionality
In the first case, EngineFacade as used by projects needs to act as a thread-safe registry, a feature which it does not provide and for which each consuming project has had to invent directly. These inventions are verbose and inconsistent.
For example, in Keystone, the EngineFacade is created thusly in keystone/common/sql/core.py:
_engine_facade = None
def _get_engine_facade():
global _engine_facade
if not _engine_facade:
_engine_facade = db_session.EngineFacade.from_config(CONF)
return _engine_facade
In Ironic we have this; Sahara contains something similar:
_FACADE = None
def _create_facade_lazily():
global _FACADE
if _FACADE is None:
_FACADE = db_session.EngineFacade(
CONF.database.connection,
**dict(CONF.database.iteritems())
)
return _FACADE
However in Nova, we get a similar pattern but with one very critical twist:
_ENGINE_FACADE = None
_LOCK = threading.Lock()
def _create_facade_lazily():
global _LOCK, _ENGINE_FACADE
if _ENGINE_FACADE is None:
with _LOCK:
if _ENGINE_FACADE is None:
_ENGINE_FACADE = db_session.EngineFacade.from_config(CONF)
return _ENGINE_FACADE
Each library invents their own system of establishing EngineFacade as a singleton, and providing CONF into it; none of which are alike. Nova happened to discover that this singleton pattern isn't threadsafe, and added a mutex, however the lack of this critical improvement remains a bug in all the other systems.
Transactional Resource Functionality
Adding a fully functional creational pattern is an easy win, but the
problem goes beyond that. EngineFacade ends its work at
get_engine()
or get_session()
; the former
returns a SQLAlchemy Engine object, which itself is only a factory for
connections, and the latter returns a SQLAlchemy Session object, ready
for use but otherwise unassociated with any specific connection or
transactional context.
The definition of "facade" is a layer that conceals the use of fine-grained APIs behind a layer that is coarse-grained and tailored to the use case at hand. By this definition, the EngineFacade currently is only a factory, and not a facade.
The harm caused by this lack of guidance on EngineFacade's part is widespread. While the failure to provide an adequate creational pattern leads each Openstack project to invent its own workaround, the failure to provide any guidance on connectivity or transactional scope gives rise to a much more significant pattern of poor implementations on the part of all Openstack projects. Each project observed illustrates mis-use of engines, sessions and transactions to a greater or lesser degree, more often than not having direct consequences for performance, stability, and maintainability.
The general theme among all projects is that while they are all presented as web services, there is no structure in place which establishes connectivity and transactional scope for a service method as a whole. Individual methods include explicit boilerplate which establishes some kind of connectivity, either within a transaction or not. The format of this boilerplate alone is not only inconsistent between projects, it's inconsistent within a single project and even in a single module, sometimes intentionally and sometimes not. Equally if not more seriously, individual API methods very frequently proceed across a span of multiple connections and non-connected transactions within the scope of a single operation, and in some cases the multiple transactions are even nested. The use of multiple connections and transactions is in the first place a major performance impediment, and in the second place dilutes the usefulness of transactional logic in the first place as an API method is not actually atomic. When transactions are actually nested, the risk surface for deadlocks increases significantly.
Transactional Scoping Examples
This section will detail some specific examples of the issues just described, for those who are curious.
We first show Neutron's system, which is the most organized and probably has the fewest issues of this nature. Neutron has a system where all database operations proceed where a neutron.context.Context object is passed; the Context object serves as home base to a SQLAlchemy Session that was ultimately retrieved from EngineFacade. A method excerpt looks like this:
def add_resource_association(self, context, service_type, provider_name,
resource_id):
# ...
with context.session.begin(subtransactions=True):
assoc = ProviderResourceAssociation(provider_name=provider_name,
resource_id=resource_id)
context.session.add(assoc)
We see that while the Context object at least allows that all
operations are given access to the same Session, the method still has to
state that it wishes to begin a transaction, and that it needs to
support the fact that the Session may already be within a transaction.
Neutron's system is a little verbose, and suffers from the issue that
individual methods called in series may invoke their work within
distinct transactions on new connections each time, but at least ensures
that just one Session is in play for a given API method from start to
finish; this prevents the issue of inadvertent multiple transaction
nesting, as the Session's begin()
method will disallow a
nested call from opening a new connection.
Next we look at Keystone. Keystone has some database-related helper functions but they don't serve any functional purpose other than some naming abstraction. Keystone has a lot of short "lookup" methods, so many of them look like this:
@sql.handle_conflicts(conflict_type='trust')
def list_trusts(self):
session = sql.get_session()
trusts = session.query(TrustModel).filter_by(deleted_at=None)
return [trust_ref.to_dict() for trust_ref in trusts]
Above, the sql.get_session()
call is just another call
to EngineFacade.get_session(), and that's where the connectivity is set
up. The sql.handle_conflicts()
call doesn't have any role
in establishing this session.
The above call uses the SQLAlchemy Session in "autocommit" mode; in this mode, SQLAlchemy essentially creates connection/transaction context on a per-query basis, and discards it when the query is complete; using the Python Database API (DBAPI), there is no cross-platform option to prevent a transaction from ultimately being present; hence "autocommit" doesn't mean, "no transaction".
In all but the most minimal cases, using the Session in "autocommit" mode is not a good approach to take, and is discouraged in SQLAlchemy's own documentation (see http://docs.sqlalchemy.org/en/rel_0_9/orm/session.html#autocommit-mode), as it means a series of queries will each proceed upon a brand new connection and transaction per query, wasting database resources with expensive rollbacks and even creating a new database connection per query under slight load, where the connection pool is in overflow mode. oslo.db itself also emits a "pessimistic ping" on each connection, where a "SELECT 1" is emitted in order to ensure the connection is alive, so emitting three queries in "autocommit" mode means you're actually emitting six queries.
It's true that for a method like the above where exactly one SELECT is emitted and definitely nothing else, there is a little less Python overhead in that the Session does not build up an internal state object for the transaction, but this is only a tiny optimization; if optimization at that scale is needed, there are other ways to make the above system vastly more performant (e.g. use baked queries, column-based queries, or Core queries).
While both Keystone and Neutron have the issue of implicit use of "autocommit" mode, Nova has more significant issues, both because it is more complex at the database level and is also more performance critical regarding persistence.
Within Nova, the connectivity system is more or less equivalent to that of Keystone; many explicit calls to get_session() and heavy use of the session in "autocommit" mode, most commonly through the model_query() function. But more critical is that the complexity of Nova's API without a foolproof system of maintaining transaction scope leads to a widespread use of multiple transactions per API call, in some cases concurrently, which has definite stability and performance implications.
A typical Nova method looks like:
@require_admin_context
def cell_update(context, cell_name, values):
session = get_session()
with session.begin():
cell_query = _cell_get_by_name_query(context, cell_name,
session=session)
if not cell_query.update(values):
raise exception.CellNotFound(cell_name=cell_name)
cell = cell_query.first()
return cell
In the above call, the get_session()
call returns a
brand new session upon which a transaction is begun; the method then
calls into _cell_get_by_name_query
, passing in the Session
in an effort to ensure this sub-method uses the same transaction. The
intent here is good, that the cell_update()
method knows it
should share its transactional context with a sub-method.
However, this is a burdensome and verbose coding pattern which is inconsistently applied. In those areas where it fails to be applied, the end result is that a single operation invokes several new connections and transactions, sometimes within a nested set of calls; this is wasteful and slow and is a key risk factor for deadlocks. Examples of non-nested, multiple connection/session use within a single call are easy to find. Truly nested transactions are less frequent; one is nova/db/api.py -> floating_ip_bulk_destroy. In this method, we see:
@require_context
def floating_ip_bulk_destroy(context, ips):
session = get_session()
with session.begin():
project_id_to_quota_count = collections.defaultdict(int)
for ip_block in _ip_range_splitter(ips):
query = model_query(context, models.FloatingIp).\
filter(models.FloatingIp.address.in_(ip_block)).\
filter_by(auto_assigned=False)
rows = query.all()
for row in rows:
project_id_to_quota_count[row['project_id']] -= 1
model_query(context, models.FloatingIp).\
filter(models.FloatingIp.address.in_(ip_block)).\
soft_delete(synchronize_session='fetch')
for project_id, count in project_id_to_quota_count.iteritems():
try:
reservations = quota.QUOTAS.reserve(context,
project_id=project_id,
floating_ips=count)
quota.QUOTAS.commit(context,
reservations,
project_id=project_id)
except Exception:
with excutils.save_and_reraise_exception():
LOG.exception(_("Failed to update usages bulk "
"deallocating floating IP"))
The entire method is within session.begin()
. But within
that, we first see a two calls to model_query()
, each of
which forget to pass the session along, so model_query()
makes it's own session and transaction for each. But more seriously,
get_session()
is called many times again, without any way
of passing a session through, down below when
quota.QUOTAS.commit
is called within a loop. The interface
for this starts outside of the database API, in nova/quota.py, where no
session
argument is available:
def commit(self, context, reservations, project_id=None, user_id=None):
"""Commit reservations."""
if project_id is None:
project_id = context.project_id
# If user_id is None, then we use the user_id in context
if user_id is None:
user_id = context.user_id
db.reservation_commit(context, reservations, project_id=project_id,
user_id=user_id)
db.reservation_commit
is back in nova/db/api.py, where
we see a whole new call to get_session()
,
begin()
, calling a second time through the
@_retry_on_deadlock
decorator which also would best know
how to manage its scope at the topmost-level:
@require_context
@_retry_on_deadlock
def reservation_commit(context, reservations, project_id=None, user_id=None):
session = get_session()
with session.begin():
_project_usages, user_usages = _get_project_user_quota_usages(
context, session, project_id, user_id)
reservation_query = _quota_reservations_query(session, context,
reservations)
for reservation in reservation_query.all():
usage = user_usages[reservation.resource]
if reservation.delta >= 0:
usage.reserved -= reservation.delta
usage.in_use += reservation.delta
reservation_query.soft_delete(synchronize_session=False)
In the above example, we first see that the "ad-hoc-session" system
and "more than one way to do it" approach of model_query()
leads to coding errors that are silently masked, but in the case of
reservation_commit()
, the architecture itself disallows
this coding error to even be corrected.
Examples of non-nested multiple sessions and transactions in one API call can be found by using an assertion within the test suite. The two main areas this occurs in the current code are:
- instance_create() calls get_session(), then ec2_instance_create() -> models.save() -> get_session()
- aggregate_create() calls get_session(), then aggregate_get() -> model_query() -> get_session()
The above examples can be fixed manually, but rather than adding more boilerplate, decorators, and imperative arguments to solve the problem as individual cases are identified, the solution should instead be to replace all imperative database code involving transaction scope with a purely declarative facade that handles connectivity, transaction scoping and related features like method retrying in a consistent and context-aware fashion across all projects.
Proposed change
The change is to replace the use of get_session(), get_engine(), and special context managers with a new set of decorators and context managers, which themselves are invoked from a simple import that replaces the usual EngineFacade logic.
The import will essentially allow a single symbol that handles the
work of EngineFacade
and CONF
behind the
scenes:
from oslo.db import enginefacade as sql
This symbol will provide two key decorators, reader()
and writer()
, as well as context managers which mirror
their behavior, using_reader()
and
using_writer()
. The decorators deliver a SQLAlchemy Session
object to the existing context
argument of API methods:
@sql.reader
def some_api_method(context):
# work with context.session
@sql.writer
def some_other_api_method(context):
# work with context.session
Whereas the context managers receive this context
argument locally:
def some_api_method(context):
with sql.using_reader(context) as session:
# work with session
def some_other_api_method(context):
with sql.using_writer(context) as session:
# work with session
Transaction Scope
These decorators and context managers will acquire a new Session
using methods similar to that of the current get_session()
function if one is not already scoped, or if one is already scoped, will
return that existing Session. The Session will then unconditionally be
within a transaction using begin()
, or we may better yet
switch to the default mode of Session
which is that of
"autocommit=False". The state of this transaction will be to remain open
until the method ends, either by raising an exception (unconditional
rollback) or by completing (either a commit() or a close(), depending on
reader/writer semantics).
The goal is that any level of nested calls can all call upon
reader()
or writer()
and participate in an
already ongoing transaction. Only the outermost call within the scope
actually ends the transaction except in the case of an exception; the
writer()
method will emit a commit()
and the
reader()
method will close()
the session,
ensuring that the underlying connection is rolled back in a lightweight
way.
Context and Thread Locals
The proposal at the moment expects a "context" object, which can be any Python object, to be present in order to provide some object that bridges all elements of a call stack together. Most APIs with the notable exception of Keystone appear to already include a context argument.
To support a pattern that does not include a "context" argument, the only alternative is to use thread locals. In discussions with the community, the use of thread locals has the two concerns of: 1. it requires early patching at the eventlet level and 2. thread locals are seen as "action at a distance", more "implicit" than "explicit".
The proposal as stated here can be made to work with thread locals using this recipe:
# at the top of the api module
GLOBAL_CONTEXT = threading.local()
def some_api_method():
with sql.using_writer(GLOBAL_CONTEXT) as session:
# work with session
Whether or not we build in the above pattern, or we get Keystone to use an explicit context object, is not yet decided. See "Alternatives" for a listing of various options.
Reader vs. Writer
At the outset, reader()
vs. writer()
only
intend to allow a block of functionality to mark itself as only
requiring read-only access, or involving write access. At the very
least, it can indicate if the outermost block need to be concerned about
committing a transaction. Beyond that, this declaration can be used to
determine if a particular method or block is suitable for "retry on
deadlock", and also allows systems that attempt to split logic between
"reader" and "writer" database links to know upfront which blocks should
be routed where.
While a fully specified description for open-ended support of
multiple databases is out of scope for this spec, as part of the
implementation here we will necessarily implement at least what is
already present. The existing EngineFacade features a "slave_engine"
attribute as well as a "use_slave" flag on get_session()
and get_engine()
; at least the Nova project and possibly
others currently make use of this flag. So we will carry over an
equivalent level of functionality into reader()
and
writer()
to start.
Beyond maintaining existing functionality, more comprehensive and potentially elaborate systems of multiple database support will be made easier to specify and implement subsequent to the rollout of this specification. This is because consuming projects will greatly reduce their verbosity down to a simple declarative level, leaving oslo.db free to expand upon the underlying machinery without incurring additional across-the-board changes in projects (hence one of the main reasons "facades" are used).
The behavior for nesting of readers and writers is as follows:
- A
reader()
block that ultimately calls upon methods that then invokewriter()
should raise an exception; it means thisreader()
is not really areader()
at all. - A
writer()
block that ultimately calls upon methods that invokereader()
should pass successfully; thosereader()
blocks will in fact be made to act as awriter()
if they are called within the context of awriter()
block.
Core Connection Methods
For those methods that use Core only, corresponding methods
reader_connection()
and writer_connection()
are supplied, which instead of returning a
sqlalchemy.orm.Session
, return a
sqlalchemy.engine.Connection
:
@sql.writer_connection
def some_core_api_method(context):
context.connection.execute(<statement>)
def some_core_api_method(context):
with sql.using_writer_connection(context) as conn:
conn.execute(<statement>)
reader_connection()
and writer_connection()
will integrate with reader()
and writer()
,
such that the outermost context will establish the
sqlalchemy.engine.Connection
that is to be used for the
full context, whether or not it is associated with a
Session
. This means the following:
- If a
reader_connection()
orwriter_connection()
manager is invoked first, asqlalchemy.engine.Connection
is associated with the context, and not aSession
. - If a
reader()
orwriter()
manager is invoked first, aSession
is associated with the context, which will contain within it asqlalchemy.engine.Connection
. - If a
reader_connection()
orwriter_connection()
manager is invoked and there is already aSession
present, theSession.connection()
method of thatSession
is used to get at theConnection
. - If a
reader()
orwriter()
manager is invoked and there is already aConnection
present, the newSession
is created, and it is bound directly to this existingConnection
.
Integration with Configuration / Startup
The reader()
, writer()
and other methods
will be calling upon functional equivalents of the current
get_session()
and get_engine()
methods within
oslo.db, as well as handling the logic that currently consists of
invoking an EngineFacade
and combining it with
CONF
. That is, the consuming application does not refer to
EngineFacade
or CONF
at all; the interaction
with CONF
is performed similarly as it is now within
oslo.db only, and is done under a mutex so that it is thread safe, in
the way that Nova performs this task.
For applications that currently have special logic to add keys to
CONF
or EngineFacade
, additional API methods
will be provided. For example, Sahara wants to ensure the
sqlite_fk
flag is set to True
. The pattern
will look like:
from oslo.db import enginefacade as sql
sql.configure(sqlite_fk=True)
def some_api_method():
with sql.reader() as session:
# work with session
Retry on Deadlock / Other failures
Oslo.db provides the @wrap_db_retry()
decorator, which
allows an API method to replay itself on failure. Per https://review.openstack.org/#/c/109549/,
we will be adding specificity to this decorator, which allows it to
explicitly indicate that a method should be retried when a deadlock
condition occurs. We can look into integrating this feature into the
reader()
and writer()
decorators as well.
Alternatives
A key decision here is that of the decorator vs. the context manager, as well as the use of thread locals.
Example forms:
Decorator, using context:
@sql.reader def some_api_method(context): # work with context.session @sql.writer def some_other_api_method(context): # work with context.session
Decorator, using thread local; here, the
session
argument is injected into the argument list of the API method within the scope of the decorator, it is not present in the outer call to the API method:@sql.reader def some_api_method(session): # work with session @sql.writer def some_other_api_method(session): # work with session
Context manager, using context:
def some_api_method(context): with sql.using_reader(context) as session: # work with session def some_other_api_method(context): with sql.using_writer(context) as session: # work with session
Context manager, using implicit thread local:
def some_api_method(): with sql.using_reader() as session: # work with session def some_other_api_method(): with sql.using_writer() as session: # work with session
Context manager, using explicit thread local:
def some_api_method(): with sql.using_reader(GLOBAL_CONTEXT) as session: # work with session def some_other_api_method(): with sql.using_writer(GLOBAL_CONTEXT) as session: # work with session
The author favors approach #1. It should be noted that all the above approaches can be supported at the same time, if projects cannot agree on an approach.
Advantages to using a decorator only with an explicit context are:
The need for thread locals or any issues with eventlet is removed.
The "Retry on deadlock" and other "retry" features could be integrated into the
reader()
/writer()
decorators, such that all API methods automatically gain this feature. As it stands, applications need to constantly push out new changes each time an unavoidable deadlock situation is detected in the wild, adding their@_retry_on_deadlock()
decorators to ever more API methods.The decorator reduces nesting depth compared to context managers, and is ultimately less verbose, save for the need to have a "context" argument.
Decorators eliminate the possibility of this already-present antipattern:
def some_api_method(): with sql.writer() as session: # do something with session # transaction completes here for element in stuff: # new transaction per element some_other_api_method_with_db(element)
Above, we are inadvertently performing any number of distinct
transactions, first with the sql.writer()
, then with each
call to some_other_api_method_with_db(). This antipattern can already be
seen in methods like Nova's instance_create()
method,
paraphrased below:
@require_context
def instance_create(context, values):
# ... about halfway through
session = get_session()
# session / connection / transaction #1
with session.begin():
# does some things with instnace_ref
# session / connection / transaction #2
ec2_instance_create(context, instance_ref['uuid'])
# session / connection / transaction #3
_instance_extra_create(context, {'instance_uuid': instance_ref['uuid']})
return instance_ref
Because the context manager allows unnecessary choices about when a transaction can begin and end within a method, we open ourselves up to make the wrong choice, as is already occurring in current code. Using a decorator, this antipattern is impossible:
@sql.writer()
def some_api_method(context):
# do something with context.session
for element in stuff:
# uses same session / transaction guaranteed
some_other_api_method_with_db(context, element)
# transaction completes here
One advantage to using an implicit "thread local" context is that it is impossible to inadvertently switch contexts in the middle of a call-chain, which would again lead to the nested-transaction issue.
An advantage of using context managers with implicit threadlocals is that it would be easier for Keystone to migrate to this system.
Impact on Existing APIs
Existing projects would need to integrate into some form of the patterns given.
Security impact
none
Performance Impact
Performance will be dramatically improved as the current use of many redundant and disconnected sessions and transactions will be joined together.
Configuration Impact
none.
Developer Impact
new patterns for developers to be aware of.
Testing Impact
As most test suites currently make the simple decision of working with SQLite and allowing API methods to make use of their usual get_session() / get_engine() logic without any change or injection, little to no changes should be needed at first. Within oslo.db, the "opportunistic" fixtures as well as the DbTestCase system will be made to integrate with the new context manager/decorator system.
Implementation
Assignee(s)
Mike Bayer
Milestones
Target Milestone for completion:
Work Items
Incubation
Adoption
Library
Anticipated API Stabilization
Documentation Impact
Dependencies
References
Note
This work is licensed under a Creative Commons Attribution 3.0 Unported License. http://creativecommons.org/licenses/by/3.0/legalcode