Reservations support

Add the concept of resource reservation in neutron.
Usage tracking logic is also updated to support reservations.
Reservations are not however available with the now deprecated
configuration-based quota driver.

The base API controller will now use reservations to perform
quota checks rather than counting resource usage and then
invoking the limit_check routine.

The limit_check routine however has not been removed and
depreacated as a part of this patch. In order to ensure all
quota drivers expose a consistent interface, a
make_reservation method has been added to the configuration
based driver as well. This method simply performs "old-style"
limit checks by counting resource usage and then invoking
limit_check.

DocImpact

Implements blueprint better-quotas.

Change-Id: Ifea07f461def564884af5b291c8a56655a4d818b
This commit is contained in:
Salvatore Orlando 2015-03-11 17:28:43 -07:00
parent bf800f9c6c
commit 574b25b857
13 changed files with 697 additions and 49 deletions

View File

@ -416,13 +416,15 @@ class Controller(object):
if self._collection in body: if self._collection in body:
# Have to account for bulk create # Have to account for bulk create
items = body[self._collection] items = body[self._collection]
deltas = {}
bulk = True
else: else:
items = [body] items = [body]
bulk = False
# Ensure policy engine is initialized # Ensure policy engine is initialized
policy.init() policy.init()
# Store requested resource amounts grouping them by tenant
# This won't work with multiple resources. However because of the
# current structure of this controller there will hardly be more than
# one resource for which reservations are being made
request_deltas = {}
for item in items: for item in items:
self._validate_network_tenant_ownership(request, self._validate_network_tenant_ownership(request,
item[self._resource]) item[self._resource])
@ -433,30 +435,34 @@ class Controller(object):
if 'tenant_id' not in item[self._resource]: if 'tenant_id' not in item[self._resource]:
# no tenant_id - no quota check # no tenant_id - no quota check
continue continue
try: tenant_id = item[self._resource]['tenant_id']
tenant_id = item[self._resource]['tenant_id'] delta = request_deltas.get(tenant_id, 0)
count = quota.QUOTAS.count(request.context, self._resource, delta = delta + 1
self._plugin, tenant_id) request_deltas[tenant_id] = delta
if bulk: # Quota enforcement
delta = deltas.get(tenant_id, 0) + 1 reservations = []
deltas[tenant_id] = delta try:
else: for tenant in request_deltas:
delta = 1 reservation = quota.QUOTAS.make_reservation(
kwargs = {self._resource: count + delta} request.context,
except exceptions.QuotaResourceUnknown as e: tenant,
{self._resource:
request_deltas[tenant]},
self._plugin)
reservations.append(reservation)
except exceptions.QuotaResourceUnknown as e:
# We don't want to quota this resource # We don't want to quota this resource
LOG.debug(e) LOG.debug(e)
else:
quota.QUOTAS.limit_check(request.context,
item[self._resource]['tenant_id'],
**kwargs)
def notify(create_result): def notify(create_result):
# Ensure usage trackers for all resources affected by this API # Ensure usage trackers for all resources affected by this API
# operation are marked as dirty # operation are marked as dirty
# TODO(salv-orlando): This operation will happen in a single with request.context.session.begin():
# transaction with reservation commit once that is implemented # Commit the reservation(s)
resource_registry.set_resources_dirty(request.context) for reservation in reservations:
quota.QUOTAS.commit_reservation(
request.context, reservation.reservation_id)
resource_registry.set_resources_dirty(request.context)
notifier_method = self._resource + '.create.end' notifier_method = self._resource + '.create.end'
self._notifier.info(request.context, self._notifier.info(request.context,
@ -467,11 +473,35 @@ class Controller(object):
notifier_method) notifier_method)
return create_result return create_result
kwargs = {self._parent_id_name: parent_id} if parent_id else {} def do_create(body, bulk=False, emulated=False):
kwargs = {self._parent_id_name: parent_id} if parent_id else {}
if bulk and not emulated:
obj_creator = getattr(self._plugin, "%s_bulk" % action)
else:
obj_creator = getattr(self._plugin, action)
try:
if emulated:
return self._emulate_bulk_create(obj_creator, request,
body, parent_id)
else:
if self._collection in body:
# This is weird but fixing it requires changes to the
# plugin interface
kwargs.update({self._collection: body})
else:
kwargs.update({self._resource: body})
return obj_creator(request.context, **kwargs)
except Exception:
# In case of failure the plugin will always raise an
# exception. Cancel the reservation
with excutils.save_and_reraise_exception():
for reservation in reservations:
quota.QUOTAS.cancel_reservation(
request.context, reservation.reservation_id)
if self._collection in body and self._native_bulk: if self._collection in body and self._native_bulk:
# plugin does atomic bulk create operations # plugin does atomic bulk create operations
obj_creator = getattr(self._plugin, "%s_bulk" % action) objs = do_create(body, bulk=True)
objs = obj_creator(request.context, body, **kwargs)
# Use first element of list to discriminate attributes which # Use first element of list to discriminate attributes which
# should be removed because of authZ policies # should be removed because of authZ policies
fields_to_strip = self._exclude_attributes_by_policy( fields_to_strip = self._exclude_attributes_by_policy(
@ -480,15 +510,12 @@ class Controller(object):
request.context, obj, fields_to_strip=fields_to_strip) request.context, obj, fields_to_strip=fields_to_strip)
for obj in objs]}) for obj in objs]})
else: else:
obj_creator = getattr(self._plugin, action)
if self._collection in body: if self._collection in body:
# Emulate atomic bulk behavior # Emulate atomic bulk behavior
objs = self._emulate_bulk_create(obj_creator, request, objs = do_create(body, bulk=True, emulated=True)
body, parent_id)
return notify({self._collection: objs}) return notify({self._collection: objs})
else: else:
kwargs.update({self._resource: body}) obj = do_create(body)
obj = obj_creator(request.context, **kwargs)
self._send_nova_notification(action, {}, self._send_nova_notification(action, {},
{self._resource: obj}) {self._resource: obj})
return notify({self._resource: self._view(request.context, return notify({self._resource: self._view(request.context,

View File

@ -1,3 +1,3 @@
2a16083502f3 2a16083502f3
48153cb5f051 9859ac9c136
kilo kilo

View File

@ -0,0 +1,47 @@
# Copyright 2015 OpenStack Foundation
#
# 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.
#
"""quota_reservations
Revision ID: 9859ac9c136
Revises: 48153cb5f051
Create Date: 2015-03-11 06:40:56.775075
"""
# revision identifiers, used by Alembic.
revision = '9859ac9c136'
down_revision = '48153cb5f051'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
'reservations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=255), nullable=True),
sa.Column('expiration', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'))
op.create_table(
'resourcedeltas',
sa.Column('resource', sa.String(length=255), nullable=False),
sa.Column('reservation_id', sa.String(length=36), nullable=False),
sa.Column('amount', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['reservation_id'], ['reservations.id'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('resource', 'reservation_id'))

View File

@ -13,11 +13,21 @@
# under the License. # under the License.
import collections import collections
import datetime
import sqlalchemy as sa
from sqlalchemy.orm import exc as orm_exc
from sqlalchemy import sql
from neutron.db import common_db_mixin as common_db_api from neutron.db import common_db_mixin as common_db_api
from neutron.db.quota import models as quota_models from neutron.db.quota import models as quota_models
# Wrapper for utcnow - needed for mocking it in unit tests
def utcnow():
return datetime.datetime.utcnow()
class QuotaUsageInfo(collections.namedtuple( class QuotaUsageInfo(collections.namedtuple(
'QuotaUsageInfo', ['resource', 'tenant_id', 'used', 'reserved', 'dirty'])): 'QuotaUsageInfo', ['resource', 'tenant_id', 'used', 'reserved', 'dirty'])):
@ -27,6 +37,32 @@ class QuotaUsageInfo(collections.namedtuple(
return self.reserved + self.used return self.reserved + self.used
class ReservationInfo(object):
"""Information about a resource reservation."""
def __init__(self, reservation_id, tenant_id, expiration, deltas):
self._reservation_id = reservation_id
self._tenant_id = tenant_id
self._expiration = expiration
self._deltas = deltas
@property
def reservation_id(self):
return self._reservation_id
@property
def tenant_id(self):
return self._tenant_id
@property
def expiration(self):
return self._expiration
@property
def deltas(self):
return self._deltas
def get_quota_usage_by_resource_and_tenant(context, resource, tenant_id, def get_quota_usage_by_resource_and_tenant(context, resource, tenant_id,
lock_for_update=False): lock_for_update=False):
"""Return usage info for a given resource and tenant. """Return usage info for a given resource and tenant.
@ -157,3 +193,105 @@ def set_all_quota_usage_dirty(context, resource, dirty=True):
query = common_db_api.model_query(context, quota_models.QuotaUsage) query = common_db_api.model_query(context, quota_models.QuotaUsage)
query = query.filter_by(resource=resource) query = query.filter_by(resource=resource)
return query.update({'dirty': dirty}) return query.update({'dirty': dirty})
def create_reservation(context, tenant_id, deltas, expiration=None):
# This method is usually called from within another transaction.
# Consider using begin_nested
with context.session.begin(subtransactions=True):
expiration = expiration or (utcnow() + datetime.timedelta(0, 120))
resv = quota_models.Reservation(tenant_id=tenant_id,
expiration=expiration)
context.session.add(resv)
for (resource, delta) in deltas.items():
context.session.add(
quota_models.ResourceDelta(resource=resource,
amount=delta,
reservation=resv))
# quota_usage for all resources involved in this reservation must
# be marked as dirty
set_resources_quota_usage_dirty(
context, deltas.keys(), tenant_id)
return ReservationInfo(resv['id'],
resv['tenant_id'],
resv['expiration'],
dict((delta.resource, delta.amount)
for delta in resv.resource_deltas))
def get_reservation(context, reservation_id):
query = context.session.query(quota_models.Reservation).filter_by(
id=reservation_id)
resv = query.first()
if not resv:
return
return ReservationInfo(resv['id'],
resv['tenant_id'],
resv['expiration'],
dict((delta.resource, delta.amount)
for delta in resv.resource_deltas))
def remove_reservation(context, reservation_id, set_dirty=False):
delete_query = context.session.query(quota_models.Reservation).filter_by(
id=reservation_id)
# Not handling MultipleResultsFound as the query is filtering by primary
# key
try:
reservation = delete_query.one()
except orm_exc.NoResultFound:
# TODO(salv-orlando): Raise here and then handle the exception?
return
tenant_id = reservation.tenant_id
resources = [delta.resource for delta in reservation.resource_deltas]
num_deleted = delete_query.delete()
if set_dirty:
# quota_usage for all resource involved in this reservation must
# be marked as dirty
set_resources_quota_usage_dirty(context, resources, tenant_id)
return num_deleted
def get_reservations_for_resources(context, tenant_id, resources,
expired=False):
"""Retrieve total amount of reservations for specified resources.
:param context: Neutron context with db session
:param tenant_id: Tenant identifier
:param resources: Resources for which reserved amounts should be fetched
:param expired: False to fetch active reservations, True to fetch expired
reservations (defaults to False)
:returns: a dictionary mapping resources with corresponding deltas
"""
if not resources:
# Do not waste time
return
now = utcnow()
resv_query = context.session.query(
quota_models.ResourceDelta.resource,
quota_models.Reservation.expiration,
sql.func.sum(quota_models.ResourceDelta.amount)).join(
quota_models.Reservation)
if expired:
exp_expr = (quota_models.Reservation.expiration < now)
else:
exp_expr = (quota_models.Reservation.expiration >= now)
resv_query = resv_query.filter(sa.and_(
quota_models.Reservation.tenant_id == tenant_id,
quota_models.ResourceDelta.resource.in_(resources),
exp_expr)).group_by(
quota_models.ResourceDelta.resource)
return dict((resource, total_reserved)
for (resource, exp, total_reserved) in resv_query)
def remove_expired_reservations(context, tenant_id=None):
now = utcnow()
resv_query = context.session.query(quota_models.Reservation)
if tenant_id:
tenant_expr = (quota_models.Reservation.tenant_id == tenant_id)
else:
tenant_expr = sql.true()
resv_query = resv_query.filter(sa.and_(
tenant_expr, quota_models.Reservation.expiration < now))
return resv_query.delete()

View File

@ -13,9 +13,16 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from oslo_db import api as oslo_db_api
from oslo_log import log
from neutron.common import exceptions from neutron.common import exceptions
from neutron.db import api as db_api
from neutron.db.quota import api as quota_api
from neutron.db.quota import models as quota_models from neutron.db.quota import models as quota_models
LOG = log.getLogger(__name__)
class DbQuotaDriver(object): class DbQuotaDriver(object):
"""Driver to perform necessary checks to enforce quotas and obtain quota """Driver to perform necessary checks to enforce quotas and obtain quota
@ -42,7 +49,8 @@ class DbQuotaDriver(object):
# update with tenant specific limits # update with tenant specific limits
q_qry = context.session.query(quota_models.Quota).filter_by( q_qry = context.session.query(quota_models.Quota).filter_by(
tenant_id=tenant_id) tenant_id=tenant_id)
tenant_quota.update((q['resource'], q['limit']) for q in q_qry) for item in q_qry:
tenant_quota[item['resource']] = item['limit']
return tenant_quota return tenant_quota
@ -116,6 +124,112 @@ class DbQuotaDriver(object):
return dict((k, v) for k, v in quotas.items()) return dict((k, v) for k, v in quotas.items())
def _handle_expired_reservations(self, context, tenant_id,
resource, expired_amount):
LOG.debug(("Adjusting usage for resource %(resource)s: "
"removing %(expired)d reserved items"),
{'resource': resource,
'expired': expired_amount})
# TODO(salv-orlando): It should be possible to do this
# operation for all resources with a single query.
# Update reservation usage
quota_api.set_quota_usage(
context,
resource,
tenant_id,
reserved=-expired_amount,
delta=True)
# Delete expired reservations (we don't want them to accrue
# in the database)
quota_api.remove_expired_reservations(
context, tenant_id=tenant_id)
@oslo_db_api.wrap_db_retry(max_retries=db_api.MAX_RETRIES,
retry_on_request=True,
retry_on_deadlock=True)
def make_reservation(self, context, tenant_id, resources, deltas, plugin):
# Lock current reservation table
# NOTE(salv-orlando): This routine uses DB write locks.
# These locks are acquired by the count() method invoked on resources.
# Please put your shotguns aside.
# A non locking algorithm for handling reservation is feasible, however
# it will require two database writes even in cases when there are not
# concurrent reservations.
# For this reason it might be advisable to handle contention using
# this kind of locks and paying the cost of a write set certification
# failure when a mysql galera cluster is employed. Also, this class of
# locks should be ok to use when support for sending "hotspot" writes
# to a single node will be avaialable.
requested_resources = deltas.keys()
with context.session.begin():
# Gather current usage information
# TODO(salv-orlando): calling count() for every resource triggers
# multiple queries on quota usage. This should be improved, however
# this is not an urgent matter as the REST API currently only
# allows allocation of a resource at a time
# NOTE: pass plugin too for compatibility with CountableResource
# instances
current_usages = dict(
(resource, resources[resource].count(
context, plugin, tenant_id)) for
resource in requested_resources)
# get_tenant_quotes needs in inout a dictionary mapping resource
# name to BaseResosurce instances so that the default quota can be
# retrieved
current_limits = self.get_tenant_quotas(
context, resources, tenant_id)
# Adjust for expired reservations. Apparently it is cheaper than
# querying everytime for active reservations and counting overall
# quantity of resources reserved
expired_deltas = quota_api.get_reservations_for_resources(
context, tenant_id, requested_resources, expired=True)
# Verify that the request can be accepted with current limits
resources_over_limit = []
for resource in requested_resources:
expired_reservations = expired_deltas.get(resource, 0)
total_usage = current_usages[resource] - expired_reservations
# A negative quota limit means infinite
if current_limits[resource] < 0:
LOG.debug(("Resource %(resource)s has unlimited quota "
"limit. It is possible to allocate %(delta)s "
"items."), {'resource': resource,
'delta': deltas[resource]})
continue
res_headroom = current_limits[resource] - total_usage
LOG.debug(("Attempting to reserve %(delta)d items for "
"resource %(resource)s. Total usage: %(total)d; "
"quota limit: %(limit)d; headroom:%(headroom)d"),
{'resource': resource,
'delta': deltas[resource],
'total': total_usage,
'limit': current_limits[resource],
'headroom': res_headroom})
if res_headroom < deltas[resource]:
resources_over_limit.append(resource)
if expired_reservations:
self._handle_expired_reservations(
context, tenant_id, resource, expired_reservations)
if resources_over_limit:
raise exceptions.OverQuota(overs=sorted(resources_over_limit))
# Success, store the reservation
# TODO(salv-orlando): Make expiration time configurable
return quota_api.create_reservation(
context, tenant_id, deltas)
def commit_reservation(self, context, reservation_id):
# Do not mark resource usage as dirty. If a reservation is committed,
# then the releveant resources have been created. Usage data for these
# resources has therefore already been marked dirty.
quota_api.remove_reservation(context, reservation_id,
set_dirty=False)
def cancel_reservation(self, context, reservation_id):
# Mark resource usage as dirty so the next time both actual resources
# used and reserved will be recalculated
quota_api.remove_reservation(context, reservation_id,
set_dirty=True)
def limit_check(self, context, tenant_id, resources, values): def limit_check(self, context, tenant_id, resources, values):
"""Check simple quota limits. """Check simple quota limits.

View File

@ -13,12 +13,33 @@
# under the License. # under the License.
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy import sql from sqlalchemy import sql
from neutron.db import model_base from neutron.db import model_base
from neutron.db import models_v2 from neutron.db import models_v2
class ResourceDelta(model_base.BASEV2):
resource = sa.Column(sa.String(255), primary_key=True)
reservation_id = sa.Column(sa.String(36),
sa.ForeignKey('reservations.id',
ondelete='CASCADE'),
primary_key=True,
nullable=False)
# Requested amount of resource
amount = sa.Column(sa.Integer)
class Reservation(model_base.BASEV2, models_v2.HasId):
tenant_id = sa.Column(sa.String(255))
expiration = sa.Column(sa.DateTime())
resource_deltas = orm.relationship(ResourceDelta,
backref='reservation',
lazy="joined",
cascade='all, delete-orphan')
class Quota(model_base.BASEV2, models_v2.HasId, models_v2.HasTenant): class Quota(model_base.BASEV2, models_v2.HasId, models_v2.HasTenant):
"""Represent a single quota override for a tenant. """Represent a single quota override for a tenant.

View File

@ -24,6 +24,7 @@ import six
import webob import webob
from neutron.common import exceptions from neutron.common import exceptions
from neutron.db.quota import api as quota_api
from neutron.i18n import _LI, _LW from neutron.i18n import _LI, _LW
from neutron.quota import resource_registry from neutron.quota import resource_registry
@ -152,6 +153,33 @@ class ConfDriver(object):
msg = _('Access to this resource was denied.') msg = _('Access to this resource was denied.')
raise webob.exc.HTTPForbidden(msg) raise webob.exc.HTTPForbidden(msg)
def make_reservation(self, context, tenant_id, resources, deltas, plugin):
"""This driver does not support reservations.
This routine is provided for backward compatibility purposes with
the API controllers which have now been adapted to make reservations
rather than counting resources and checking limits - as this
routine ultimately does.
"""
for resource in deltas.keys():
count = QUOTAS.count(context, resource, plugin, tenant_id)
total_use = deltas.get(resource, 0) + count
deltas[resource] = total_use
self.limit_check(
context,
tenant_id,
resource_registry.get_all_resources(),
deltas)
# return a fake reservation - the REST controller expects it
return quota_api.ReservationInfo('fake', None, None, None)
def commit_reservation(self, context, reservation_id):
"""Tnis is a noop as this driver does not support reservations."""
def cancel_reservation(self, context, reservation_id):
"""Tnis is a noop as this driver does not support reservations."""
class QuotaEngine(object): class QuotaEngine(object):
"""Represent the set of recognized quotas.""" """Represent the set of recognized quotas."""
@ -210,6 +238,39 @@ class QuotaEngine(object):
return res.count(context, *args, **kwargs) return res.count(context, *args, **kwargs)
def make_reservation(self, context, tenant_id, deltas, plugin):
# Verify that resources are managed by the quota engine
# Ensure no value is less than zero
unders = [key for key, val in deltas.items() if val < 0]
if unders:
raise exceptions.InvalidQuotaValue(unders=sorted(unders))
requested_resources = set(deltas.keys())
all_resources = resource_registry.get_all_resources()
managed_resources = set([res for res in all_resources.keys()
if res in requested_resources])
# Make sure we accounted for all of them...
unknown_resources = requested_resources - managed_resources
if unknown_resources:
raise exceptions.QuotaResourceUnknown(
unknown=sorted(unknown_resources))
# FIXME(salv-orlando): There should be no reason for sending all the
# resource in the registry to the quota driver, but as other driver
# APIs request them, this will be sorted out with a different patch.
return self.get_driver().make_reservation(
context,
tenant_id,
all_resources,
deltas,
plugin)
def commit_reservation(self, context, reservation_id):
self.get_driver().commit_reservation(context, reservation_id)
def cancel_reservation(self, context, reservation_id):
self.get_driver().cancel_reservation(context, reservation_id)
def limit_check(self, context, tenant_id, **values): def limit_check(self, context, tenant_id, **values):
"""Check simple quota limits. """Check simple quota limits.
@ -232,6 +293,7 @@ class QuotaEngine(object):
:param tenant_id: Tenant for which the quota limit is being checked :param tenant_id: Tenant for which the quota limit is being checked
:param values: Dict specifying requested deltas for each resource :param values: Dict specifying requested deltas for each resource
""" """
# TODO(salv-orlando): Deprecate calls to this API
# Verify that resources are managed by the quota engine # Verify that resources are managed by the quota engine
requested_resources = set(values.keys()) requested_resources = set(values.keys())
managed_resources = set([res for res in managed_resources = set([res for res in

View File

@ -208,14 +208,15 @@ class TrackedResource(BaseResource):
max_retries=db_api.MAX_RETRIES, max_retries=db_api.MAX_RETRIES,
exception_checker=lambda exc: exception_checker=lambda exc:
isinstance(exc, oslo_db_exception.DBDuplicateEntry)) isinstance(exc, oslo_db_exception.DBDuplicateEntry))
def _set_quota_usage(self, context, tenant_id, in_use): def _set_quota_usage(self, context, tenant_id, in_use, reserved):
return quota_api.set_quota_usage(context, self.name, return quota_api.set_quota_usage(context, self.name, tenant_id,
tenant_id, in_use=in_use) in_use=in_use, reserved=reserved)
def _resync(self, context, tenant_id, in_use): def _resync(self, context, tenant_id, in_use, reserved):
# Update quota usage # Update quota usage
usage_info = self._set_quota_usage( usage_info = self._set_quota_usage(
context, tenant_id, in_use=in_use) context, tenant_id, in_use, reserved)
self._dirty_tenants.discard(tenant_id) self._dirty_tenants.discard(tenant_id)
self._out_of_sync_tenants.discard(tenant_id) self._out_of_sync_tenants.discard(tenant_id)
LOG.debug(("Unset dirty status for tenant:%(tenant_id)s on " LOG.debug(("Unset dirty status for tenant:%(tenant_id)s on "
@ -231,40 +232,62 @@ class TrackedResource(BaseResource):
{'tenant_id': tenant_id, 'resource': self.name}) {'tenant_id': tenant_id, 'resource': self.name})
in_use = context.session.query(self._model_class).filter_by( in_use = context.session.query(self._model_class).filter_by(
tenant_id=tenant_id).count() tenant_id=tenant_id).count()
reservations = quota_api.get_reservations_for_resources(
context, tenant_id, [self.name])
reserved = reservations.get(self.name, 0)
# Update quota usage # Update quota usage
return self._resync(context, tenant_id, in_use) return self._resync(context, tenant_id, in_use, reserved)
def count(self, context, _plugin, tenant_id, resync_usage=False): def count(self, context, _plugin, tenant_id, resync_usage=False):
"""Return the current usage count for the resource.""" """Return the current usage count for the resource.
# Load current usage data
This method will fetch the information from resource usage data,
unless usage data are marked as "dirty", in which case both used and
reserved resource are explicitly counted.
The _plugin and _resource parameters are unused but kept for
compatibility with the signature of the count method for
CountableResource instances.
"""
# Load current usage data, setting a row-level lock on the DB
usage_info = quota_api.get_quota_usage_by_resource_and_tenant( usage_info = quota_api.get_quota_usage_by_resource_and_tenant(
context, self.name, tenant_id) context, self.name, tenant_id, lock_for_update=True)
# If dirty or missing, calculate actual resource usage querying # If dirty or missing, calculate actual resource usage querying
# the database and set/create usage info data # the database and set/create usage info data
# NOTE: this routine "trusts" usage counters at service startup. This # NOTE: this routine "trusts" usage counters at service startup. This
# assumption is generally valid, but if the database is tampered with, # assumption is generally valid, but if the database is tampered with,
# or if data migrations do not take care of usage counters, the # or if data migrations do not take care of usage counters, the
# assumption will not hold anymore # assumption will not hold anymore
if (tenant_id in self._dirty_tenants or not usage_info if (tenant_id in self._dirty_tenants or
or usage_info.dirty): not usage_info or usage_info.dirty):
LOG.debug(("Usage tracker for resource:%(resource)s and tenant:" LOG.debug(("Usage tracker for resource:%(resource)s and tenant:"
"%(tenant_id)s is out of sync, need to count used " "%(tenant_id)s is out of sync, need to count used "
"quota"), {'resource': self.name, "quota"), {'resource': self.name,
'tenant_id': tenant_id}) 'tenant_id': tenant_id})
in_use = context.session.query(self._model_class).filter_by( in_use = context.session.query(self._model_class).filter_by(
tenant_id=tenant_id).count() tenant_id=tenant_id).count()
reservations = quota_api.get_reservations_for_resources(
context, tenant_id, [self.name])
reserved = reservations.get(self.name, 0)
# Update quota usage, if requested (by default do not do that, as # Update quota usage, if requested (by default do not do that, as
# typically one counts before adding a record, and that would mark # typically one counts before adding a record, and that would mark
# the usage counter as dirty again) # the usage counter as dirty again)
if resync_usage or not usage_info: if resync_usage or not usage_info:
usage_info = self._resync(context, tenant_id, in_use) usage_info = self._resync(context, tenant_id,
in_use, reserved)
else: else:
usage_info = quota_api.QuotaUsageInfo(usage_info.resource, usage_info = quota_api.QuotaUsageInfo(usage_info.resource,
usage_info.tenant_id, usage_info.tenant_id,
in_use, in_use,
usage_info.reserved, reserved,
usage_info.dirty) usage_info.dirty)
LOG.debug(("Quota usage for %(resource)s was recalculated. "
"Used quota:%(used)d; Reserved quota:%(reserved)d"),
{'resource': self.name,
'used': usage_info.used,
'reserved': usage_info.reserved})
return usage_info.total return usage_info.total
def register_events(self): def register_events(self):

View File

@ -65,7 +65,7 @@ def set_resources_dirty(context):
return return
for res in get_all_resources().values(): for res in get_all_resources().values():
with context.session.begin(): with context.session.begin(subtransactions=True):
if is_tracked(res.name) and res.dirty: if is_tracked(res.name) and res.dirty:
res.mark_dirty(context, nested=True) res.mark_dirty(context, nested=True)

View File

@ -12,6 +12,10 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import datetime
import mock
from neutron import context from neutron import context
from neutron.db.quota import api as quota_api from neutron.db.quota import api as quota_api
from neutron.tests.unit import testlib_api from neutron.tests.unit import testlib_api
@ -24,6 +28,12 @@ class TestQuotaDbApi(testlib_api.SqlTestCaseLight):
self.context = context.Context('Gonzalo', self.tenant_id, self.context = context.Context('Gonzalo', self.tenant_id,
is_admin=False, is_advsvc=False) is_admin=False, is_advsvc=False)
def _create_reservation(self, resource_deltas,
tenant_id=None, expiration=None):
tenant_id = tenant_id or self.tenant_id
return quota_api.create_reservation(
self.context, tenant_id, resource_deltas, expiration)
def _create_quota_usage(self, resource, used, reserved, tenant_id=None): def _create_quota_usage(self, resource, used, reserved, tenant_id=None):
tenant_id = tenant_id or self.tenant_id tenant_id = tenant_id or self.tenant_id
return quota_api.set_quota_usage( return quota_api.set_quota_usage(
@ -203,6 +213,125 @@ class TestQuotaDbApi(testlib_api.SqlTestCaseLight):
self.assertIsNone(quota_api.get_quota_usage_by_resource_and_tenant( self.assertIsNone(quota_api.get_quota_usage_by_resource_and_tenant(
self.context, 'goals', self.tenant_id)) self.context, 'goals', self.tenant_id))
def _verify_reserved_resources(self, expected, actual):
for (resource, delta) in actual.items():
self.assertIn(resource, expected)
self.assertEqual(delta, expected[resource])
del expected[resource]
self.assertFalse(expected)
def test_create_reservation(self):
resources = {'goals': 2, 'assists': 1}
resv = self._create_reservation(resources)
self.assertEqual(self.tenant_id, resv.tenant_id)
self._verify_reserved_resources(resources, resv.deltas)
def test_create_reservation_with_expirtion(self):
resources = {'goals': 2, 'assists': 1}
exp_date = datetime.datetime(2016, 3, 31, 14, 30)
resv = self._create_reservation(resources, expiration=exp_date)
self.assertEqual(self.tenant_id, resv.tenant_id)
self.assertEqual(exp_date, resv.expiration)
self._verify_reserved_resources(resources, resv.deltas)
def _test_remove_reservation(self, set_dirty):
resources = {'goals': 2, 'assists': 1}
resv = self._create_reservation(resources)
self.assertEqual(1, quota_api.remove_reservation(
self.context, resv.reservation_id, set_dirty=set_dirty))
def test_remove_reservation(self):
self._test_remove_reservation(False)
def test_remove_reservation_and_set_dirty(self):
routine = 'neutron.db.quota.api.set_resources_quota_usage_dirty'
with mock.patch(routine) as mock_routine:
self._test_remove_reservation(False)
mock_routine.assert_called_once_with(
self.context, mock.ANY, self.tenant_id)
def test_remove_non_existent_reservation(self):
self.assertIsNone(quota_api.remove_reservation(self.context, 'meh'))
def _get_reservations_for_resource_helper(self):
# create three reservation, 1 expired
resources_1 = {'goals': 2, 'assists': 1}
resources_2 = {'goals': 3, 'bookings': 1}
resources_3 = {'bookings': 2, 'assists': 2}
exp_date_1 = datetime.datetime(2016, 3, 31, 14, 30)
exp_date_2 = datetime.datetime(2015, 3, 31, 14, 30)
self._create_reservation(resources_1, expiration=exp_date_1)
self._create_reservation(resources_2, expiration=exp_date_1)
self._create_reservation(resources_3, expiration=exp_date_2)
def test_get_reservations_for_resources(self):
with mock.patch('neutron.db.quota.api.utcnow') as mock_utcnow:
self._get_reservations_for_resource_helper()
mock_utcnow.return_value = datetime.datetime(
2015, 5, 20, 0, 0)
deltas = quota_api.get_reservations_for_resources(
self.context, self.tenant_id, ['goals', 'assists', 'bookings'])
self.assertIn('goals', deltas)
self.assertEqual(5, deltas['goals'])
self.assertIn('assists', deltas)
self.assertEqual(1, deltas['assists'])
self.assertIn('bookings', deltas)
self.assertEqual(1, deltas['bookings'])
self.assertEqual(3, len(deltas))
def test_get_expired_reservations_for_resources(self):
with mock.patch('neutron.db.quota.api.utcnow') as mock_utcnow:
mock_utcnow.return_value = datetime.datetime(
2015, 5, 20, 0, 0)
self._get_reservations_for_resource_helper()
deltas = quota_api.get_reservations_for_resources(
self.context, self.tenant_id,
['goals', 'assists', 'bookings'],
expired=True)
self.assertIn('assists', deltas)
self.assertEqual(2, deltas['assists'])
self.assertIn('bookings', deltas)
self.assertEqual(2, deltas['bookings'])
self.assertEqual(2, len(deltas))
def test_get_reservation_for_resources_with_empty_list(self):
self.assertIsNone(quota_api.get_reservations_for_resources(
self.context, self.tenant_id, []))
def test_remove_expired_reservations(self):
with mock.patch('neutron.db.quota.api.utcnow') as mock_utcnow:
mock_utcnow.return_value = datetime.datetime(
2015, 5, 20, 0, 0)
resources = {'goals': 2, 'assists': 1}
exp_date_1 = datetime.datetime(2016, 3, 31, 14, 30)
resv_1 = self._create_reservation(resources, expiration=exp_date_1)
exp_date_2 = datetime.datetime(2015, 3, 31, 14, 30)
resv_2 = self._create_reservation(resources, expiration=exp_date_2)
self.assertEqual(1, quota_api.remove_expired_reservations(
self.context, self.tenant_id))
self.assertIsNone(quota_api.get_reservation(
self.context, resv_2.reservation_id))
self.assertIsNotNone(quota_api.get_reservation(
self.context, resv_1.reservation_id))
def test_remove_expired_reservations_no_tenant(self):
with mock.patch('neutron.db.quota.api.utcnow') as mock_utcnow:
mock_utcnow.return_value = datetime.datetime(
2015, 5, 20, 0, 0)
resources = {'goals': 2, 'assists': 1}
exp_date_1 = datetime.datetime(2014, 3, 31, 14, 30)
resv_1 = self._create_reservation(resources, expiration=exp_date_1)
exp_date_2 = datetime.datetime(2015, 3, 31, 14, 30)
resv_2 = self._create_reservation(resources,
expiration=exp_date_2,
tenant_id='Callejon')
self.assertEqual(2, quota_api.remove_expired_reservations(
self.context))
self.assertIsNone(quota_api.get_reservation(
self.context, resv_2.reservation_id))
self.assertIsNone(quota_api.get_reservation(
self.context, resv_1.reservation_id))
class TestQuotaDbApiAdminContext(TestQuotaDbApi): class TestQuotaDbApiAdminContext(TestQuotaDbApi):

View File

@ -27,16 +27,22 @@ class FakePlugin(base_plugin.NeutronDbPluginV2, driver.DbQuotaDriver):
class TestResource(object): class TestResource(object):
"""Describe a test resource for quota checking.""" """Describe a test resource for quota checking."""
def __init__(self, name, default): def __init__(self, name, default, fake_count=0):
self.name = name self.name = name
self.quota = default self.quota = default
self.fake_count = fake_count
@property @property
def default(self): def default(self):
return self.quota return self.quota
def count(self, *args, **kwargs):
return self.fake_count
PROJECT = 'prj_test' PROJECT = 'prj_test'
RESOURCE = 'res_test' RESOURCE = 'res_test'
ALT_RESOURCE = 'res_test_meh'
class TestDbQuotaDriver(testlib_api.SqlTestCase): class TestDbQuotaDriver(testlib_api.SqlTestCase):
@ -132,3 +138,63 @@ class TestDbQuotaDriver(testlib_api.SqlTestCase):
self.assertRaises(exceptions.InvalidQuotaValue, self.assertRaises(exceptions.InvalidQuotaValue,
self.plugin.limit_check, context.get_admin_context(), self.plugin.limit_check, context.get_admin_context(),
PROJECT, resources, values) PROJECT, resources, values)
def _test_make_reservation_success(self, quota_driver,
resource_name, deltas):
resources = {resource_name: TestResource(resource_name, 2)}
self.plugin.update_quota_limit(self.context, PROJECT, resource_name, 2)
reservation = quota_driver.make_reservation(
self.context,
self.context.tenant_id,
resources,
deltas,
self.plugin)
self.assertIn(resource_name, reservation.deltas)
self.assertEqual(deltas[resource_name],
reservation.deltas[resource_name])
self.assertEqual(self.context.tenant_id,
reservation.tenant_id)
def test_make_reservation_single_resource(self):
quota_driver = driver.DbQuotaDriver()
self._test_make_reservation_success(
quota_driver, RESOURCE, {RESOURCE: 1})
def test_make_reservation_fill_quota(self):
quota_driver = driver.DbQuotaDriver()
self._test_make_reservation_success(
quota_driver, RESOURCE, {RESOURCE: 2})
def test_make_reservation_multiple_resources(self):
quota_driver = driver.DbQuotaDriver()
resources = {RESOURCE: TestResource(RESOURCE, 2),
ALT_RESOURCE: TestResource(ALT_RESOURCE, 2)}
deltas = {RESOURCE: 1, ALT_RESOURCE: 2}
self.plugin.update_quota_limit(self.context, PROJECT, RESOURCE, 2)
self.plugin.update_quota_limit(self.context, PROJECT, ALT_RESOURCE, 2)
reservation = quota_driver.make_reservation(
self.context,
self.context.tenant_id,
resources,
deltas,
self.plugin)
self.assertIn(RESOURCE, reservation.deltas)
self.assertIn(ALT_RESOURCE, reservation.deltas)
self.assertEqual(1, reservation.deltas[RESOURCE])
self.assertEqual(2, reservation.deltas[ALT_RESOURCE])
self.assertEqual(self.context.tenant_id,
reservation.tenant_id)
def test_make_reservation_over_quota_fails(self):
quota_driver = driver.DbQuotaDriver()
resources = {RESOURCE: TestResource(RESOURCE, 2,
fake_count=2)}
deltas = {RESOURCE: 1}
self.plugin.update_quota_limit(self.context, PROJECT, RESOURCE, 2)
self.assertRaises(exceptions.OverQuota,
quota_driver.make_reservation,
self.context,
self.context.tenant_id,
resources,
deltas,
self.plugin)

View File

@ -344,6 +344,24 @@ class QuotaExtensionDbTestCase(QuotaExtensionTestCase):
extra_environ=env, expect_errors=True) extra_environ=env, expect_errors=True)
self.assertEqual(400, res.status_int) self.assertEqual(400, res.status_int)
def test_make_reservation_resource_unknown_raises(self):
tenant_id = 'tenant_id1'
self.assertRaises(exceptions.QuotaResourceUnknown,
quota.QUOTAS.make_reservation,
context.get_admin_context(load_admin_roles=False),
tenant_id,
{'foobar': 1},
plugin=None)
def test_make_reservation_negative_delta_raises(self):
tenant_id = 'tenant_id1'
self.assertRaises(exceptions.InvalidQuotaValue,
quota.QUOTAS.make_reservation,
context.get_admin_context(load_admin_roles=False),
tenant_id,
{'network': -1},
plugin=None)
class QuotaExtensionCfgTestCase(QuotaExtensionTestCase): class QuotaExtensionCfgTestCase(QuotaExtensionTestCase):
fmt = 'json' fmt = 'json'

View File

@ -165,7 +165,8 @@ class TestTrackedResource(testlib_api.SqlTestCaseLight):
res.count(self.context, None, self.tenant_id, res.count(self.context, None, self.tenant_id,
resync_usage=True) resync_usage=True)
mock_set_quota_usage.assert_called_once_with( mock_set_quota_usage.assert_called_once_with(
self.context, self.resource, self.tenant_id, in_use=2) self.context, self.resource, self.tenant_id,
reserved=0, in_use=2)
def test_count_with_dirty_true_no_usage_info(self): def test_count_with_dirty_true_no_usage_info(self):
res = self._create_resource() res = self._create_resource()
@ -184,7 +185,8 @@ class TestTrackedResource(testlib_api.SqlTestCaseLight):
self.tenant_id) self.tenant_id)
res.count(self.context, None, self.tenant_id, resync_usage=True) res.count(self.context, None, self.tenant_id, resync_usage=True)
mock_set_quota_usage.assert_called_once_with( mock_set_quota_usage.assert_called_once_with(
self.context, self.resource, self.tenant_id, in_use=2) self.context, self.resource, self.tenant_id,
reserved=0, in_use=2)
def test_add_delete_data_triggers_event(self): def test_add_delete_data_triggers_event(self):
res = self._create_resource() res = self._create_resource()
@ -251,4 +253,5 @@ class TestTrackedResource(testlib_api.SqlTestCaseLight):
# and now it should be in sync # and now it should be in sync
self.assertNotIn(self.tenant_id, res._out_of_sync_tenants) self.assertNotIn(self.tenant_id, res._out_of_sync_tenants)
mock_set_quota_usage.assert_called_once_with( mock_set_quota_usage.assert_called_once_with(
self.context, self.resource, self.tenant_id, in_use=2) self.context, self.resource, self.tenant_id,
reserved=0, in_use=2)