[openstack-dev] [cross-project][quotas][delimiter]My thoughts on how Delimiter uses generation-id for sequencing
Jay Pipes
jaypipes at gmail.com
Sun May 15 15:06:42 UTC 2016
On 05/15/2016 04:16 AM, Qijing Li wrote:
> Hi Vilobh,
>
> Here is my thoughts on how Delimiter uses generation-id to guarantee
> sequencing. Please correct me if I understand it wrong.
>
> First, the Delimiter need to introduce another model ResourceProvider
> who has two attributes:
>
> * resource_id
> * generation_id
We will need generations for *consumers* of resources as well. The
generation for a provider of a resource is used when updating that
particular provider's view of its inventory. For quotas to work
effectively, we need each service to keep a generation for each consumer
of resources in the system.
> The followings are the steps of how to consume a quota:
>
> Step 1. Check if there is enough available quota
When you refer to quota above, you are using incorrect terminology. The
quota is the *limit* that a consumer of a resource has for a particular
class of resources. There is no such thing as "available quota". What
you are referring to above is whether the requested amount of resources
for a particular consumer would exceed that consumer's quota for a resource.
> If yes, then get the $generation_id //by querying the model
> ResourceProvider with the given resource_id which is the point in time
> view of resource usage.
The generation for a resource provider is not used when *checking* if
quota would be exceeded for a consumer's request of a particular
resource class. The generation for a resource provider is used when
*consuming* resources on a particular resource provider. This
consumption process doesn't have anything to do with Delimiter, though.
It is an internal mechanism of each service whether it uses heavy
locking techniques or whether it uses a generation and retries to ensure
a consistent view.
Please see example code below.
> If no, terminate the process of consuming the quota and return the
> message of “No enough quotas available."
Yes.
> Step 2. Consume the quota.
>
> 2.1 Begin transaction
>
> 2.2 Update the QuotaUsage model: QuotaUsage.in_use =
> QuotaUsage.in_use + amount of quota requested.
No. The above is precisely why there are lots of problems in the
existing system. The QuotaUsage model and database tables need to go
away entirely. They represent a synchronicity problem because they
contain duplicate data (the amount/sum used) from the actual resource
usage tables in the services.
Delimiter should not be responsible for updating any service's view of
resource usage. That is the responsibility of the service itself to do
this. All Delimiter needs to do is supply an interface/object model by
which services should represent usage records and an interface by which
services can determine if a consumer has concurrently changed its
consumption of resources in that service.
> 2.3 Get the $generation_id by querying the ResourceProvider by the
> given resource_id.
>
> If the $generation_id is larger than the $generation_id in Step
> 1, then roll back transaction and GOTO step 1.
>
> this case means there is someone else has changed the
> QuotaUsage during this process.
>
> If the $generation_id is the same as the $generation_id in Step
> 1, then increase the ResourceProvider.generation_id by one and
>
> Commit the transaction. Done!
>
> Note: no case the $generation_id is less than the
> $generation_id in Step 1 because the $generation_id is nondecreasing.
No, sorry, the code in my earlier response to Vilobh and Nikhil was
confusing. The consumer's generation is what needs to be supplied by
Delimiter. The resource provider's generation is used by the service
itself to ensure a consistent view of usages across multiple concurrent
consumers. The resource provider's generation is an internal mechanism
the service could use to prevent multiple consumers from exceeding the
provider's available resources.
Here is what I think needs to be the "interface" that Delimiter facilitates:
```python
import sqlalchemy as sa
from sqlalchemy import sql
import delimiter
from delimiter import objects as d_objects
from nova import objects as n_objects
from nova.db.sqlalchey import tables as n_tables
class NoRowsMatched(Exception):
pass
class ConcurrentConsumption(Exception)
pass
def nova_check(quotas, request_spec):
"""
Do a verification that the resources requested by the supplied user
and tenant involved in the request specification do not cause the
user or tenant's quotas to be exceeded.
:param request_spec: `delimiter.objects.RequestSpec` object
containing requested resource amounts, the
requesting user and project, etc.
:returns `delimiter.objects.QuotaCheckResult` object
containing the boolean result of the check, the
resource classes that violated quotas, and a generation
for the user.
"""
res = d_objects.QuotaCheckResult()
alloc_tbl = n_tables.ALLOCATIONS
cons_tbl = n_tables.CONSUMERS
req_resources = request_spec.resources
conn = get_sql_connection(...)
# Grab our consumer's generation ID
query = sa.select([cons_tbl.c.generation])
query = query.select_from(cons_tbl)
query = query.where(cons_tbl.c.id == request_spec.user_id)
cons_gen = conn.execute(query).fetchone()[0]
res.consumer_generation = cons_gen
# Grab usage amounts
cols = [
alloc_tbl.c.resource_class_id,
sql.func.sum(alloc_tbl.c.used).label('total_used'),
]
where_conds = [
alloc_tbl.c.consumer_id = request_spec.user_id,
alloc_tbl.c.resource_class_id.in_(
req_resources.keys()
),
]
query = sa.select(cols).select_from(alloc_tbl)
query = query.where(where_conds)
query = query.group_by(alloc_tbl.c.resource_class_id)
records = conn.execute(query)
usage_map = {r[0]: r[1] for r in records}
res.success = True
# Determine if any quotas are exceeded
for resource_class, req_amount in req_resources.items():
limit = quotas.get(resource_class)
used = usage_map.get(resource_class)
if limit is None or used is None:
continue
if (req_amount + used) > limit:
res.success &= False
res.exceeded.append(resource_class)
return res
def nova_consume(check_result, request_spec):
"""
Actually writes allocation records to the database and
increments the supplied consumer's generation to ensure
a consistent view of usage.
:param check_result: `delimiter.objects.QuotaCheckResult` object
returned from `delimiter.check()` call
:param request_spec: `delimiter.objects.RequestSpec` object
containing requested resource amounts, the
requesting user and project, etc.
:raises `ConcurrentConsumption` if the final increment of
the consumer's generation failed to affect a single row
:raises `NoValidHost` if no resource provider could be found
with capacity to handle the requested resources.
"""
rp_tbl = n_tables.RESOURCE_PROVIDERS
alloc_tbl = n_tables.ALLOCATIONS
cons_tbl = n_tables.CONSUMERS
conn = get_sql_connection(...)
req_resources = request_spec.resources
max_retries = 3
tries = 1
while True:
# Nova finds a resource provider that has capacity for the
# requested set of resources. The objects returned by the call
# to find_resource_provider() are of type
# `nova.objects.ResourceProvider`. These objects have a
# generation property that allows Nova to have a consistent view
# of inventory on each individual resource provider.
provider = find_resource_provider(request_spec)
if provider is None:
raise NoValidHost
trans = conn.begin()
try:
# Write allocation records for all requested resources
for resource_class, req_amount in req_resources.items():
ins = alloc_tbl.insert().values(
resource_provider_id=provider.id,
resource_class_id=resource_class,
consumer_id=request_spec.user_id,
used=req_amount)
conn.execute(ins)
# Update the generation of the resource provider
generation = provider.generation
where_conds = [
rp_tbl.c.id == provider_id,
rp_tbl.c.generation == generation,
]
upd = rp_tbl.update().where(sql.and_(*where_conds))
upd = upd.values(generation=generation+1)
upd_res = conn.execute(upd)
# Check to see if another thread executed a concurrent
# update on the same target resource provider
rc = upd_res.rowcount
if rc != 1:
raise NoRowsMatched
# Update the generation of the consumer
generation = check_res.consumer_generation
where_conds = [
cons_tbl.c.id == request_spec.user_id,
cons_tbl.c.generation == generation,
]
upd = cons_tbl.update().where(sql.and_(*where_conds))
upd = upd.values(generation=generation+1)
upd_res = conn.execute(upd)
# Check to see if another client of the same consumer
# consumed some resources in between the quota check
# and here...
rc = upd_res.rowcount
if rc != 1:
raise ConcurrentConsumption()
trans.commit()
except ConcurrentConsumption:
# This exception triggers the outer loop to recheck
# quota is not violated and attempt to consume again
trans.rollback()
raise
except NoRowsMatched:
trans.rollback()
if tries >= max_retries:
raise NoValidHost
tries += 1
# Initialize Delimiter library interfaces to use the Nova service's
# callbacks defined above for checking and resource consumption
delimiter.set_check_cb(nova_check)
delimiter.set_consume_cb(nova_consume)
# Assume a user 'uX' in project 'pA' requests 2 VCPU resources
# and 1024 MEMORY_MB resources in their request to Nova:
rq = d_objects.RequestSpec()
rq.user_id = 'uX'
rq.project_id = 'pA'
rq.resources[VCPU] = 2
rq.resources[MEMORY_MB] = 1024
# Assume delimiter.get_quotas_for_user() queries some data store
# for the limits for a user. The returned object is a dict of
# limit values, keyed by resource class ID.
quotas = delimiter.get_quotas_for_user(rq.user_id)
# We first do a check of user X and the requested resource amount
check_res = delimiter.check(quotas, rq)
# Just exit if we would have violated any quotas
if not check_res.success:
raise delimiter.QuotaExceeded(resources=check_res.exceeded)
# Now do the consumption, allowing the service itself to use
# the consumer's generation ID to ensure a consistent view
max_retries = 3
tries = 1
while True:
try:
delimiter.consume(check_res, rq)
except NoValidHost:
# Nothing we can do about this... just raise.
raise
except ConcurrentConsumption:
# Another thread consumed for this user in between our first
# check and our consume() call. Let's recheck and try to
# consume again.
if tries >= max_retries:
raise RetriesExceeded
check_res = delimiter.check(quotas, rq)
if not check_res.success:
raise delimiter.QuotaExceeded(resources=check_res.exceeded)
tries += 1
```
The above code demonstrates how Nova would write the `check` and
`consume` callbacks to ensure that its view of resource provider
inventory and usage, as well as its view of a consumer's consumption of
any resource in the system is kept consistent.
Other services could certainly use the same compare-and-update technique
that the nova_consume() method uses above, but they are not required to.
They could alternatively use a heavier lock-based system to ensure
consistency or they could use a DLM of some sorts, or they could even
forgo locking entirely and build some level of tolerance for exceeding
quotas into their system and rely on post-consume auditing or the like.
The point of the above is to show my idea of where I think Delimiter's
scope should be and where I believe the services themselves need to own
certain pieces of the request-check-consume workflow.
Best,
-jay
More information about the OpenStack-dev
mailing list