Merge "New Quota driver ``DbQuotaNoLockDriver``"

This commit is contained in:
Zuul 2021-06-04 23:19:37 +00:00 committed by Gerrit Code Review
commit afcaf6805d
11 changed files with 261 additions and 60 deletions

View File

@ -43,7 +43,7 @@ limits are currently not enforced on RPC interfaces listening on the AMQP
bus.
Plugin and ML2 drivers are not supposed to enforce quotas for resources they
manage. However, the subnet_allocation [#]_ extension is an exception and will
manage. However, the subnet_allocation [1]_ extension is an exception and will
be discussed below.
The quota management and enforcement mechanisms discussed here apply to every
@ -59,12 +59,14 @@ There are two main components in the Neutron quota system:
* The Quota Engine.
Both components rely on a quota driver. The neutron codebase currently defines
two quota drivers:
three quota drivers:
* neutron.db.quota.driver.DbQuotaDriver
* neutron.db.quota.driver_nolock.DbQuotaNoLockDriver (default)
* neutron.quota.ConfDriver
The latter driver is however deprecated.
The latter driver is however deprecated. The ``DbQuotaNoLockDriver`` is the
default quota driver, defined in the configuration option ``quota_driver``.
The Quota API extension handles quota management, whereas the Quota Engine
component handles quota enforcement. This API extension is loaded like any
@ -91,7 +93,7 @@ For a reservation to be successful, the total amount of resources requested,
plus the total amount of resources reserved, plus the total amount of resources
already stored in the database should not exceed the project's quota limit.
Finally, both quota management and enforcement rely on a "quota driver" [#]_,
Finally, both quota management and enforcement rely on a "quota driver" [2]_,
whose task is basically to perform database operations.
Quota Management
@ -100,14 +102,14 @@ Quota Management
The quota management component is fairly straightforward.
However, unlike the vast majority of Neutron extensions, it uses it own
controller class [#]_.
controller class [3]_.
This class does not implement the POST operation. List, get, update, and
delete operations are implemented by the usual index, show, update and
delete methods. These method simply call into the quota driver for either
fetching project quotas or updating them.
The _update_attributes method is called only once in the controller lifetime.
This method dynamically updates Neutron's resource attribute map [#]_ so that
This method dynamically updates Neutron's resource attribute map [4]_ so that
an attribute is added for every resource managed by the quota engine.
Request authorisation is performed in this controller, and only 'admin' users
are allowed to modify quotas for projects. As the neutron policy engine is not
@ -131,11 +133,18 @@ Resource Usage Info
Neutron has two ways of tracking resource usage info:
* CountableResource, where resource usage is calculated every time quotas
limits are enforced by counting rows in the resource table and reservations
for that resource.
* TrackedResource, which instead relies on a specific table tracking usage
data, and performs explicitly counting only when the data in this table are
not in sync with actual used and reserved resources.
limits are enforced by counting rows in the resource table or resources
tables and reservations for that resource.
* TrackedResource, depends on the selected driver:
* DbQuotaDriver: the resource usage relies on a specific table tracking
usage data, and performs explicitly counting only when the data in this
table are not in sync with actual used and reserved resources.
* DbQuotaNoLockDriver: the resource usage is counted directly from the
database table associated to the resource. In this new driver,
CountableResource and TrackedResource could look similar but
TrackedResource depends on one single database model (table) and the
resource count is done directly on this table only.
Another difference between CountableResource and TrackedResource is that the
former invokes a plugin method to count resources. CountableResource should be
@ -147,6 +156,9 @@ use CountableResource instances.
Resource creation is performed by the create_resource_instance factory method
in the neutron.quota.resource module.
DbQuotaDriver description
-------------------------
From a performance perspective, having a table tracking resource usage
has some advantages, albeit not fundamental. Indeed the time required for
executing queries to explicitly count objects will increase with the number of
@ -183,15 +195,48 @@ caveats' section, it is more reliable than solutions such as:
* Having a periodic task synchronising quota usage data with actual data in
the Neutron DB.
Finally, regardless of whether CountableResource or TrackedResource is used,
the quota engine always invokes its count() method to retrieve resource usage.
DbQuotaNoLockDriver description
-------------------------------
The strategy of this quota driver is the opposite to ``DbQuotaDriver``.
Instead of tracking the usage quota of each resource in a specific table,
this driver retrieves the used resources directly form the database.
Each TrackedResource is linked to a database table that stores the tracked
resources. This driver claims that a trivial query on the resource table,
filtering by project ID, is faster than attending to the DB events and tracking
the quota usage in an independent table.
This driver relays on the database engine transactionality isolation. Each
time a new resource is requested, the quota driver opens a database transaction
to:
* Clean up the expired reservations. The amount of expired reservations is
always limited because of the short timeout set (2 minutes).
* Retrieve the used resources for a specific project. This query retrieves
only the "project_id" column of the resource to avoid backref requests; that
limits the scope of the query and speeds up it.
* Retrieve the reserved resources, created by other concurrent operations.
* If there is enough quota, create a new reservation register.
Those operations, executed in the same transaction, are fast enough to avoid
another concurrent resource reservation, exceeding the available quota. At the
same time, this driver does not create a lock per resource and project ID,
allowing concurrent requests that won't be blocked by the resource lock.
Because the quota reservation process, described before, is a fast operation,
the chances of overcommiting resources over the quota limits are low. Neutron
does not enforce quota in such way that a quota limit violation could never
occur [5]_.
Regardless of whether CountableResource or TrackedResource is used, the quota
engine always invokes its count() method to retrieve resource usage.
Therefore, from the perspective of the Quota engine there is absolutely no
difference between CountableResource and TrackedResource.
Quota Enforcement
-----------------
Quota Enforcement in DbQuotaDriver
----------------------------------
Before dispatching a request to the plugin, the Neutron 'base' controller [#]_
Before dispatching a request to the plugin, the Neutron 'base' controller [6]_
attempts to make a reservation for requested resource(s).
Reservations are made by calling the make_reservation method in
neutron.quota.QuotaEngine.
@ -228,7 +273,7 @@ While non-locking approaches are possible, it has been found out that, since
a non-locking algorithms increases the chances of collision, the cost of
handling a DBDeadlock is still lower than the cost of retrying the operation
when a collision is detected. A study in this direction was conducted for
IP allocation operations, but the same principles apply here as well [#]_.
IP allocation operations, but the same principles apply here as well [7]_.
Nevertheless, moving away for DB-level locks is something that must happen
for quota enforcement in the future.
@ -345,9 +390,10 @@ Please be aware of the following limitations of the quota enforcement engine:
References
----------
.. [#] Subnet allocation extension: http://opendev.org/openstack/neutron/tree/neutron/extensions/subnetallocation.py
.. [#] DB Quota driver class: http://opendev.org/openstack/neutron/tree/neutron/db/quota/driver.py#n30
.. [#] Quota API extension controller: http://opendev.org/openstack/neutron/tree/neutron/extensions/quotasv2.py#n40
.. [#] Neutron resource attribute map: http://opendev.org/openstack/neutron/tree/neutron/api/v2/attributes.py#n639
.. [#] Base controller class: http://opendev.org/openstack/neutron/tree/neutron/api/v2/base.py#n50
.. [#] http://lists.openstack.org/pipermail/openstack-dev/2015-February/057534.html
.. [1] Subnet allocation extension: http://opendev.org/openstack/neutron/tree/neutron/extensions/subnetallocation.py
.. [2] DB Quota driver class: http://opendev.org/openstack/neutron/tree/neutron/db/quota/driver.py#n30
.. [3] Quota API extension controller: http://opendev.org/openstack/neutron/tree/neutron/extensions/quotasv2.py#n40
.. [4] Neutron resource attribute map: http://opendev.org/openstack/neutron/tree/neutron/api/v2/attributes.py#n639
.. [5] Quota limit exceeded: https://bugs.launchpad.net/neutron/+bug/1862050/
.. [6] Base controller class: http://opendev.org/openstack/neutron/tree/neutron/api/v2/base.py#n50
.. [7] http://lists.openstack.org/pipermail/openstack-dev/2015-February/057534.html

View File

@ -18,8 +18,9 @@ from oslo_config import cfg
from neutron._i18n import _
QUOTA_DB_MODULE = 'neutron.db.quota.driver'
QUOTA_DB_DRIVER = '%s.DbQuotaDriver' % QUOTA_DB_MODULE
QUOTA_DB_MODULE = 'neutron.db.quota.driver_nolock'
QUOTA_DB_DRIVER = QUOTA_DB_MODULE + '.DbQuotaNoLockDriver'
QUOTA_CONF_DRIVER = 'neutron.quota.ConfDriver'
QUOTAS_CFG_GROUP = 'QUOTAS'

View File

@ -0,0 +1,78 @@
# Copyright (c) 2021 Red Hat Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from neutron_lib.db import api as db_api
from neutron_lib import exceptions
from oslo_log import log
from neutron.db.quota import api as quota_api
from neutron.db.quota import driver as quota_driver
LOG = log.getLogger(__name__)
class DbQuotaNoLockDriver(quota_driver.DbQuotaDriver):
"""Driver to enforce quotas and retrieve quota information
This driver does not use a (resource, project_id) lock but relays on the
simplicity of the calculation method, that is executed in a single database
transaction. The method (1) counts the number of created resources and (2)
the number of resource reservations. If the requested number of resources
do not exceeds the quota, a new reservation register is created.
This calculation method does not guarantee the quota enforcement if, for
example, the database isolation level is read committed; two transactions
can count the same number of resources and reservations, committing both
a new reservation exceeding the quota. But the goal of this reservation
method is to be fast enough to avoid the concurrency when counting the
resources while not blocking concurrent API operations.
"""
@db_api.retry_if_session_inactive()
def make_reservation(self, context, project_id, resources, deltas, plugin):
resources_over_limit = []
with db_api.CONTEXT_WRITER.using(context):
# Filter out unlimited resources.
limits = self.get_tenant_quotas(context, resources, project_id)
unlimited_resources = set([resource for (resource, limit) in
limits.items() if limit < 0])
requested_resources = (set(deltas.keys()) - unlimited_resources)
# Delete expired reservations before counting valid ones. This
# operation is fast and by calling it before making any
# reservation, we ensure the freshness of the reservations.
quota_api.remove_expired_reservations(context,
tenant_id=project_id)
# Count the number of (1) used and (2) reserved resources for this
# project_id. If any resource limit is exceeded, raise exception.
for resource_name in requested_resources:
tracked_resource = resources.get(resource_name)
if not tracked_resource:
continue
used_and_reserved = tracked_resource.count(
context, None, project_id, count_db_registers=True)
resource_num = deltas[resource_name]
if limits[resource_name] < (used_and_reserved + resource_num):
resources_over_limit.append(resource_name)
if resources_over_limit:
raise exceptions.OverQuota(overs=sorted(resources_over_limit))
return quota_api.create_reservation(context, project_id, deltas)
def cancel_reservation(self, context, reservation_id):
quota_api.remove_reservation(context, reservation_id, set_dirty=False)

View File

@ -38,7 +38,7 @@ DEFAULT_QUOTAS_ACTION = 'default'
RESOURCE_NAME = 'quota'
RESOURCE_COLLECTION = RESOURCE_NAME + "s"
QUOTAS = quota.QUOTAS
DB_QUOTA_DRIVER = 'neutron.db.quota.driver.DbQuotaDriver'
DB_QUOTA_DRIVER = cfg.CONF.QUOTAS.quota_driver
EXTENDED_ATTRIBUTES_2_0 = {
RESOURCE_COLLECTION: {}
}

View File

@ -23,6 +23,8 @@ from oslo_config import cfg
from neutron._i18n import _
from neutron.api import extensions
from neutron.api.v2 import resource
from neutron.db.quota import driver
from neutron.db.quota import driver_nolock
from neutron.extensions import quotasv2
from neutron.quota import resource_registry
@ -32,7 +34,11 @@ RESOURCE_NAME = 'quota'
ALIAS = RESOURCE_NAME + '_' + DETAIL_QUOTAS_ACTION
QUOTA_DRIVER = cfg.CONF.QUOTAS.quota_driver
RESOURCE_COLLECTION = RESOURCE_NAME + "s"
DB_QUOTA_DRIVER = 'neutron.db.quota.driver.DbQuotaDriver'
DB_QUOTA_DRIVERS = tuple('.'.join([klass.__module__, klass.__name__])
for klass in (driver.DbQuotaDriver,
driver_nolock.DbQuotaNoLockDriver,
)
)
EXTENDED_ATTRIBUTES_2_0 = {
RESOURCE_COLLECTION: {}
}
@ -61,8 +67,7 @@ class Quotasv2_detail(api_extensions.ExtensionDescriptor):
# Ensure new extension is not loaded with old conf driver.
extensions.register_custom_supported_check(
ALIAS, lambda: QUOTA_DRIVER == DB_QUOTA_DRIVER,
plugin_agnostic=True)
ALIAS, lambda: QUOTA_DRIVER in DB_QUOTA_DRIVERS, plugin_agnostic=True)
@classmethod
def get_name(cls):

View File

@ -12,6 +12,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import abc
from neutron_lib.db import api as db_api
from neutron_lib.plugins import constants
from neutron_lib.plugins import directory
@ -22,6 +24,8 @@ from sqlalchemy import exc as sql_exc
from sqlalchemy.orm import session as se
from neutron._i18n import _
from neutron.common import utils as n_utils
from neutron.conf import quota as quota_conf
from neutron.db.quota import api as quota_api
LOG = log.getLogger(__name__)
@ -55,7 +59,7 @@ def _count_resource(context, collection_name, tenant_id):
_('No plugins that support counting %s found.') % collection_name)
class BaseResource(object):
class BaseResource(object, metaclass=abc.ABCMeta):
"""Describe a single resource for quota checking."""
def __init__(self, name, flag, plural_name=None):
@ -98,6 +102,7 @@ class BaseResource(object):
return max(value, -1)
@property
@abc.abstractmethod
def dirty(self):
"""Return the current state of the Resource instance.
@ -106,6 +111,10 @@ class BaseResource(object):
does not track usage.
"""
@abc.abstractmethod
def count(self, context, plugin, project_id, **kwargs):
"""Return the total count of this resource"""
class CountableResource(BaseResource):
"""Describe a resource where the counts are determined by a function."""
@ -143,9 +152,13 @@ class CountableResource(BaseResource):
name, flag=flag, plural_name=plural_name)
self._count_func = count
def count(self, context, plugin, tenant_id, **kwargs):
@property
def dirty(self):
return
def count(self, context, plugin, project_id, **kwargs):
# NOTE(ihrachys) _count_resource doesn't receive plugin
return self._count_func(context, self.plural_name, tenant_id)
return self._count_func(context, self.plural_name, project_id)
class TrackedResource(BaseResource):
@ -184,13 +197,21 @@ class TrackedResource(BaseResource):
self._model_class = model_class
self._dirty_tenants = set()
self._out_of_sync_tenants = set()
# NOTE(ralonsoh): "DbQuotaNoLockDriver" driver does not need to track
# the DB events or resync the resource quota usage.
if cfg.CONF.QUOTAS.quota_driver == quota_conf.QUOTA_DB_DRIVER:
self._track_resource_events = False
else:
self._track_resource_events = True
@property
def dirty(self):
if not self._track_resource_events:
return
return self._dirty_tenants
def mark_dirty(self, context):
if not self._dirty_tenants:
if not self._dirty_tenants or not self._track_resource_events:
return
with db_api.CONTEXT_WRITER.using(context):
# It is not necessary to protect this operation with a lock.
@ -236,7 +257,8 @@ class TrackedResource(BaseResource):
return usage_info
def resync(self, context, tenant_id):
if tenant_id not in self._out_of_sync_tenants:
if (tenant_id not in self._out_of_sync_tenants or
not self._track_resource_events):
return
LOG.debug(("Synchronizing usage tracker for tenant:%(tenant_id)s on "
"resource:%(resource)s"),
@ -302,15 +324,37 @@ class TrackedResource(BaseResource):
reserved = reservations.get(self.name, 0)
return reserved
def count(self, context, _plugin, tenant_id, resync_usage=True):
def count(self, context, _plugin, tenant_id, resync_usage=True,
count_db_registers=False):
"""Return the count of the resource.
The _plugin parameter is unused but kept for
compatibility with the signature of the count method for
CountableResource instances.
"""
return (self.count_used(context, tenant_id, resync_usage) +
self.count_reserved(context, tenant_id))
if count_db_registers:
count = self._count_db_registers(context, tenant_id)
else:
count = self.count_used(context, tenant_id, resync_usage)
return count + self.count_reserved(context, tenant_id)
def _count_db_registers(self, context, project_id):
"""Return the existing resources (self._model_class) in a project.
The query executed must be as fast as possible. To avoid retrieving all
model backref relationship columns, only "project_id" is requested
(this column always exists in the DB model because is used in the
filter).
"""
# TODO(ralonsoh): declare the OVO class instead the DB model and use
# ``NeutronDbObject.count`` with the needed filters and fields to
# retrieve ("project_id").
admin_context = n_utils.get_elevated_context(context)
with db_api.CONTEXT_READER.using(admin_context):
query = admin_context.session.query(self._model_class.project_id)
query = query.filter(self._model_class.project_id == project_id)
return query.count()
def _except_bulk_delete(self, delete_context):
if delete_context.mapper.class_ == self._model_class:
@ -321,12 +365,16 @@ class TrackedResource(BaseResource):
self._model_class)
def register_events(self):
if not self._track_resource_events:
return
listen = db_api.sqla_listen
listen(self._model_class, 'after_insert', self._db_event_handler)
listen(self._model_class, 'after_delete', self._db_event_handler)
listen(se.Session, 'after_bulk_delete', self._except_bulk_delete)
def unregister_events(self):
if not self._track_resource_events:
return
try:
db_api.sqla_remove(self._model_class, 'after_insert',
self._db_event_handler)

View File

@ -51,6 +51,7 @@ from neutron.common import ipv6_utils
from neutron.common import test_lib
from neutron.common import utils
from neutron.conf import policies
from neutron.conf import quota as quota_conf
from neutron.db import db_base_plugin_common
from neutron.db import ipam_backend_mixin
from neutron.db.models import l3 as l3_models
@ -110,7 +111,10 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
def setUp(self, plugin=None, service_plugins=None,
ext_mgr=None):
quota_conf.register_quota_opts(quota_conf.core_quota_opts, cfg.CONF)
cfg.CONF.set_override(
'quota_driver', 'neutron.db.quota.driver.DbQuotaDriver',
group=quota_conf.QUOTAS_CFG_GROUP)
super(NeutronDbPluginV2TestCase, self).setUp()
cfg.CONF.set_override('notify_nova_on_port_status_changes', False)
cfg.CONF.set_override('allow_overlapping_ips', True)

View File

@ -30,6 +30,7 @@ from neutron.api.v2 import router
from neutron.common import config
from neutron.conf import quota as qconf
from neutron.db.quota import driver
from neutron.db.quota import driver_nolock
from neutron import quota
from neutron.quota import resource_registry
from neutron.tests import base
@ -504,29 +505,24 @@ class TestDbQuotaDriver(base.BaseTestCase):
class TestQuotaDriverLoad(base.BaseTestCase):
def setUp(self):
super(TestQuotaDriverLoad, self).setUp()
# Make sure QuotaEngine is reinitialized in each test.
quota.QUOTAS._driver = None
def _test_quota_driver(self, cfg_driver, loaded_driver,
with_quota_db_module=True):
quota.QUOTAS._driver = None
cfg.CONF.set_override('quota_driver', cfg_driver, group='QUOTAS')
with mock.patch.dict(sys.modules, {}):
if (not with_quota_db_module and
'neutron.db.quota.driver' in sys.modules):
del sys.modules['neutron.db.quota.driver']
quota.QUOTA_DB_MODULE in sys.modules):
del sys.modules[quota.QUOTA_DB_MODULE]
driver = quota.QUOTAS.get_driver()
self.assertEqual(loaded_driver, driver.__class__.__name__)
def test_quota_db_driver_with_quotas_table(self):
self._test_quota_driver('neutron.db.quota.driver.DbQuotaDriver',
'DbQuotaDriver', True)
def test_quota_driver_load(self):
for klass in (quota.ConfDriver, driver.DbQuotaDriver,
driver_nolock.DbQuotaNoLockDriver):
self._test_quota_driver(
'.'.join([klass.__module__, klass.__name__]),
klass.__name__, True)
def test_quota_db_driver_fallback_conf_driver(self):
self._test_quota_driver('neutron.db.quota.driver.DbQuotaDriver',
'ConfDriver', False)
def test_quota_conf_driver(self):
self._test_quota_driver('neutron.quota.ConfDriver',
'ConfDriver', True)
def test_quota_driver_fallback_conf_driver(self):
self._test_quota_driver(quota.QUOTA_DB_DRIVER, 'ConfDriver', False)

View File

@ -30,34 +30,44 @@ from neutron.tests.unit import testlib_api
DB_PLUGIN_KLASS = 'neutron.db.db_base_plugin_v2.NeutronDbPluginV2'
QUOTA_DRIVER = 'neutron.db.quota.DbQuotaDriver'
meh_quota_flag = 'quota_meh'
meh_quota_opts = [cfg.IntOpt(meh_quota_flag, default=99)]
class _BaseResource(resource.BaseResource):
@property
def dirty(self):
return False
def count(self, context, plugin, project_id, **kwargs):
pass
class TestResource(base.DietTestCase):
"""Unit tests for neutron.quota.resource.BaseResource"""
def test_create_resource_without_plural_name(self):
res = resource.BaseResource('foo', None)
res = _BaseResource('foo', None)
self.assertEqual('foos', res.plural_name)
res = resource.BaseResource('foy', None)
res = _BaseResource('foy', None)
self.assertEqual('foies', res.plural_name)
def test_create_resource_with_plural_name(self):
res = resource.BaseResource('foo', None,
plural_name='foopsies')
res = _BaseResource('foo', None, plural_name='foopsies')
self.assertEqual('foopsies', res.plural_name)
def test_resource_default_value(self):
res = resource.BaseResource('foo', 'foo_quota')
res = _BaseResource('foo', 'foo_quota')
with mock.patch('oslo_config.cfg.CONF') as mock_cfg:
mock_cfg.QUOTAS.foo_quota = 99
self.assertEqual(99, res.default)
def test_resource_negative_default_value(self):
res = resource.BaseResource('foo', 'foo_quota')
res = _BaseResource('foo', 'foo_quota')
with mock.patch('oslo_config.cfg.CONF') as mock_cfg:
mock_cfg.QUOTAS.foo_quota = -99
self.assertEqual(-1, res.default)
@ -94,6 +104,7 @@ class TestTrackedResource(testlib_api.SqlTestCase):
session.add(item)
def setUp(self):
cfg.CONF.set_override('quota_driver', QUOTA_DRIVER, group='QUOTAS')
super(TestTrackedResource, self).setUp()
self.setup_coreplugin(DB_PLUGIN_KLASS)
self.resource = 'meh'

View File

@ -160,6 +160,7 @@ class TestAuxiliaryFunctions(base.DietTestCase):
'TrackedResource.mark_dirty') as mock_mark_dirty:
self.registry.set_tracked_resource('meh', test_quota.MehModel)
self.registry.register_resource_by_name('meh')
self.registry.resources['meh']._track_resource_events = True
res = self.registry.get_resource('meh')
# This ensures dirty is true
res._dirty_tenants.add('tenant_id')

View File

@ -0,0 +1,11 @@
---
features:
- |
A new quota driver is added: ``DbQuotaNoLockDriver``. This driver, unlike
``DbQuotaDriver``, does not create a unique lock per (resource,
project_id). That may lead to a database deadlock state if the number of
server requests exceeds the number of resolved resource creations, as
described in `LP#1926787 <https://bugs.launchpad.net/neutron/+bug/1926787>`_.
This driver relays on the database transactionality isolation and counts
the number of used and reserved resources and, if available, creates the
new resource reservations in one single database transaction.