db: Make EngineFacade a Facade
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
This commit is contained in:
parent
740b16d661
commit
ec52f4370b
767
specs/kilo/make-enginefacade-a-facade.rst
Normal file
767
specs/kilo/make-enginefacade-a-facade.rst
Normal file
@ -0,0 +1,767 @@
|
|||||||
|
================================
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. A ``reader()`` block that ultimately calls upon methods that then invoke
|
||||||
|
``writer()`` should raise an exception; it means this ``reader()`` is not
|
||||||
|
really a ``reader()`` at all.
|
||||||
|
|
||||||
|
2. A ``writer()`` block that ultimately
|
||||||
|
calls upon methods that invoke ``reader()`` should pass successfully;
|
||||||
|
those ``reader()`` blocks will in fact be made to act as a ``writer()``
|
||||||
|
if they are called within the context of a ``writer()`` 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:
|
||||||
|
|
||||||
|
1. If a ``reader_connection()`` or ``writer_connection()`` manager is invoked
|
||||||
|
first, a ``sqlalchemy.engine.Connection``
|
||||||
|
is associated with the context, and not a ``Session``.
|
||||||
|
|
||||||
|
2. If a ``reader()`` or ``writer()`` manager is invoked first, a ``Session``
|
||||||
|
is associated with the context, which will contain within it a
|
||||||
|
``sqlalchemy.engine.Connection``.
|
||||||
|
|
||||||
|
3. If a ``reader_connection()`` or ``writer_connection()`` manager is invoked
|
||||||
|
and there is already a ``Session`` present, the ``Session.connection()``
|
||||||
|
method of that ``Session`` is used to get at the ``Connection``.
|
||||||
|
|
||||||
|
4. If a ``reader()`` or ``writer()`` manager is invoked and there is already
|
||||||
|
a ``Connection`` present, the new ``Session`` is created, and it is
|
||||||
|
bound directly to this existing ``Connection``.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. 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
|
||||||
|
|
||||||
|
|
||||||
|
2. 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
|
||||||
|
|
||||||
|
3. 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
|
||||||
|
|
||||||
|
4. 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
|
||||||
|
|
||||||
|
5. 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:
|
||||||
|
|
||||||
|
1. The need for thread locals or any issues with eventlet is removed.
|
||||||
|
|
||||||
|
2. 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.
|
||||||
|
|
||||||
|
3. The decorator reduces nesting depth compared to context managers, and
|
||||||
|
is ultimately less verbose, save for the need to have a "context"
|
||||||
|
argument.
|
||||||
|
|
||||||
|
4. 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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user