Skip to content

Commit 353ace7

Browse files
committed
add quota
1 parent 14a666e commit 353ace7

20 files changed

Lines changed: 1827 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ exordos_core/
8989
tox -e py310,py312,py314
9090

9191
# Run unit tests only
92-
tox -e py310
92+
tox -e py314
9393

9494
# Run functional tests
95-
tox -e py310-functional
95+
tox -e py314-functional
9696

9797
# Run linters
9898
tox -e ruff-check # Check code style

exordos_core/compute/dm/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from exordos_core.common import utils
3737
from exordos_core.common.dm import models as cm
3838
from exordos_core.compute import constants as nc
39+
from exordos_core.quota.dm.models import QuotaModelMixin
3940

4041
if tp.TYPE_CHECKING:
4142
from exordos_core.compute.pool.drivers.base import AbstractPoolDriver
@@ -261,6 +262,7 @@ class UnscheduledVolume(models.ModelWithUUID, orm.SQLStorableMixin):
261262
class NodeSet(
262263
infra_models.NodeSet,
263264
ua_models.InstanceWithDerivativesMixin,
265+
QuotaModelMixin,
264266
orm.SQLStorableMixin,
265267
):
266268
__tablename__ = "compute_sets"
@@ -300,6 +302,7 @@ def set_active(self):
300302

301303
class Node(
302304
infra_models.Node,
305+
QuotaModelMixin,
303306
orm.SQLStorableWithJSONFieldsMixin,
304307
):
305308
__tablename__ = "nodes"

exordos_core/quota/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.

exordos_core/quota/dm/__init__.py

Whitespace-only changes.

exordos_core/quota/dm/models.py

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
#
18+
# Quota flow
19+
# ----------
20+
#
21+
# Two tables control quotas:
22+
# quota_limits — admin-defined cap per resource (e.g. net_lb → 5)
23+
# quotas_reservations — per-entity reservation rows tying a quota slot to an entity UUID
24+
#
25+
# QuotaModelMixin overrides insert() and delete() on tracked entities (LB, NodeSet, Node).
26+
#
27+
# On insert():
28+
# 1. _quota_reserve(s) — in the caller's transaction:
29+
# a. Look up the limit for this resource (self.__tablename__).
30+
# If no limit is set, quota is unlimited — skip.
31+
# b. Acquire a pg_advisory_xact_lock keyed by {project_id}:{resource_name}
32+
# to serialize concurrent reservations for the same project+resource.
33+
# c. Count existing reservations for this project+resource.
34+
# If at or above the limit, raise QuotaExceededError (rolls back the transaction).
35+
# d. Insert a QuotaReservation row with uuid=self.uuid — the entity's own UUID
36+
# links the reservation to the entity.
37+
# 2. super().insert(s) — persist the entity itself in the same transaction.
38+
# 3. If step 2 raises, the transaction rolls back and the reservation is
39+
# automatically cleaned up — no explicit release needed.
40+
#
41+
# On delete():
42+
# 1. _quota_release_all(s) — delete the reservation row (frees the quota slot).
43+
# 2. super().delete(s) — delete the entity itself.
44+
#
45+
# reconcile_quota_reservations(session):
46+
# Find all reservations whose uuid no longer exists in the entity table.
47+
# This handles edge cases where reservation cleanup was missed.
48+
# Designed to be run periodically or on-demand as a repair mechanism.
49+
#
50+
51+
import hashlib
52+
import logging
53+
import typing as tp
54+
import uuid as sys_uuid
55+
56+
from restalchemy.common import exceptions as ra_e
57+
from restalchemy.dm import filters as dm_filters
58+
from restalchemy.dm import models
59+
from restalchemy.dm import properties
60+
from restalchemy.dm import types
61+
from restalchemy.storage import exceptions as rs_e
62+
from restalchemy.storage.sql import orm
63+
64+
LOG = logging.getLogger(__name__)
65+
66+
67+
class QuotaExceededError(ra_e.ValidationErrorException):
68+
message = "Quota exceeded for resource '%(resource_name)s' in project %(project_id)s: %(current)s > %(limit)s"
69+
70+
def __init__(
71+
self, resource_name: str, limit: int, current: int, project_id: sys_uuid.UUID
72+
):
73+
super().__init__(
74+
resource_name=resource_name,
75+
limit=limit,
76+
current=current,
77+
project_id=project_id,
78+
)
79+
self.resource_name = resource_name
80+
self.limit = limit
81+
self.current = current
82+
self.project_id = project_id
83+
84+
85+
# Built-in default quota limits keyed by resource_name (__tablename__).
86+
# These apply when no explicit QuotaLimit row exists for the project+resource.
87+
# A resource not listed here or in the DB has no quota limit (unlimited).
88+
DEFAULT_QUOTA_LIMIT = 1000
89+
DEFAULT_QUOTA_LIMITS: tp.Dict[str, int] = {
90+
"net_lb": DEFAULT_QUOTA_LIMIT,
91+
"compute_sets": DEFAULT_QUOTA_LIMIT,
92+
"nodes": DEFAULT_QUOTA_LIMIT,
93+
"secret_passwords": DEFAULT_QUOTA_LIMIT,
94+
"secret_certificates": DEFAULT_QUOTA_LIMIT,
95+
"secret_rsa_keys": DEFAULT_QUOTA_LIMIT,
96+
"secret_ssh_keys": DEFAULT_QUOTA_LIMIT,
97+
}
98+
99+
100+
class QuotaModelMixin:
101+
def _quota_limit(self, session) -> tp.Optional[int]:
102+
limit = QuotaLimit.objects.get_one_or_none(
103+
session=session,
104+
filters={
105+
"project_id": dm_filters.EQ(self.project_id),
106+
"resource_name": dm_filters.EQ(self.__tablename__),
107+
},
108+
)
109+
if limit is not None:
110+
return limit.limit
111+
return DEFAULT_QUOTA_LIMITS.get(self.__tablename__)
112+
113+
@staticmethod
114+
def _quota_lock_key(project_id: sys_uuid.UUID, resource_name: str) -> int:
115+
"""Derive a positive bigint lock key for pg_advisory_xact_lock.
116+
117+
The key is a deterministic 63-bit hash of ``{project_id}:{resource_name}``,
118+
so concurrent inserts for the same project+resource contend on the same
119+
lock, while different project+resource pairs do not block each other.
120+
121+
Examples
122+
--------
123+
>>> import uuid
124+
>>> pid = uuid.UUID("00000000-0000-0000-0000-000000000000")
125+
>>> _quota_lock_key(pid, "net_lb")
126+
8255477843906886675
127+
>>> _quota_lock_key(pid, "compute_sets")
128+
2792567584429316197
129+
>>> # Different project → different key
130+
>>> pid2 = uuid.UUID("12345678-1234-5678-1234-567812345678")
131+
>>> _quota_lock_key(pid2, "net_lb")
132+
1636646552482098955
133+
"""
134+
key = f"{project_id}:{resource_name}"
135+
digest = hashlib.sha256(key.encode()).digest()[:8]
136+
return (
137+
int.from_bytes(digest, byteorder="big", signed=False) & 0x7FFFFFFFFFFFFFFF
138+
)
139+
140+
def _quota_acquire_lock(self, session: tp.Any) -> None:
141+
lock_key = self._quota_lock_key(self.project_id, self.__tablename__)
142+
session.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
143+
144+
def _quota_reserve(self, session) -> None:
145+
# Check limit, count existing reservations, create reservation.
146+
# If limit exceeded, raises QuotaExceededError.
147+
limit = self._quota_limit(session)
148+
if limit is None:
149+
return
150+
151+
self._quota_acquire_lock(session)
152+
153+
current = QuotaReservation.objects.count(
154+
session=session,
155+
filters={
156+
"project_id": dm_filters.EQ(self.project_id),
157+
"resource_name": dm_filters.EQ(self.__tablename__),
158+
},
159+
)
160+
161+
if current + 1 > limit:
162+
raise QuotaExceededError(
163+
resource_name=self.__tablename__,
164+
limit=limit,
165+
current=current + 1,
166+
project_id=self.project_id,
167+
)
168+
169+
reservation = QuotaReservation(
170+
uuid=self.uuid,
171+
project_id=self.project_id,
172+
resource_name=self.__tablename__,
173+
)
174+
try:
175+
reservation.insert(session=session)
176+
except rs_e.ConflictRecords:
177+
LOG.debug(
178+
"Quota reservation already exists for %s",
179+
self.uuid,
180+
)
181+
182+
def _quota_release_all(self, session) -> None:
183+
# Delete all reservations for this entity.
184+
for reservation in QuotaReservation.objects.get_all(
185+
filters={
186+
"uuid": dm_filters.EQ(self.uuid),
187+
"project_id": dm_filters.EQ(self.project_id),
188+
"resource_name": dm_filters.EQ(self.__tablename__),
189+
},
190+
session=session,
191+
):
192+
reservation.delete(session=session)
193+
194+
def insert(self, session=None):
195+
# Reserve quota slot, then insert entity.
196+
# The reservation is in the same transaction as the entity, so if the
197+
# entity insert fails the entire transaction is rolled back and the
198+
# reservation is cleaned up automatically — no explicit release needed.
199+
if session is None:
200+
engine = QuotaReservation._get_engine()
201+
with engine.session_manager() as s:
202+
self._quota_reserve(s)
203+
super().insert(session=s)
204+
else:
205+
self._quota_reserve(session)
206+
super().insert(session=session)
207+
208+
def delete(self, session=None):
209+
# Release reservation, then delete entity.
210+
if session is None:
211+
engine = QuotaReservation._get_engine()
212+
with engine.session_manager() as s:
213+
self._quota_release_all(s)
214+
super().delete(session=s)
215+
else:
216+
self._quota_release_all(session)
217+
super().delete(session=session)
218+
219+
220+
def reconcile_quota_reservations(session):
221+
cursor = session.execute(
222+
"SELECT DISTINCT resource_name FROM quotas_reservations",
223+
None,
224+
)
225+
resource_names = [row["resource_name"] for row in cursor]
226+
for rname in resource_names:
227+
session.execute(
228+
"DELETE FROM quotas_reservations r "
229+
"WHERE r.resource_name = %s "
230+
"AND NOT EXISTS (SELECT 1 FROM " + rname + " e WHERE e.uuid = r.uuid)",
231+
[rname],
232+
)
233+
234+
235+
class QuotaReservation(
236+
models.ModelWithUUID,
237+
models.ModelWithTimestamp,
238+
models.ModelWithProject,
239+
orm.SQLStorableMixin,
240+
):
241+
__tablename__ = "quotas_reservations"
242+
243+
resource_name = properties.property(
244+
types.String(max_length=255),
245+
required=True,
246+
read_only=True,
247+
)
248+
249+
@classmethod
250+
def get_project_quota_summary(
251+
cls,
252+
session: tp.Optional[tp.Any] = None,
253+
project_id: sys_uuid.UUID | str | None = None,
254+
) -> tp.List[tp.Dict[str, tp.Any]]:
255+
"""Get aggregated quota reservation information.
256+
257+
Returns a list of dictionaries with keys:
258+
- project_id: UUID of the project
259+
- resource_name: name of the resource
260+
- reserved_count: number of reservations for this project+resource
261+
262+
If project_id is provided, filters to that specific project.
263+
Otherwise, returns summaries for all projects.
264+
265+
If session is not provided, creates a temporary session using the default engine.
266+
"""
267+
268+
def req():
269+
if project_id is not None:
270+
return session.execute(
271+
"SELECT project_id, resource_name, COUNT(*) as reserved_count "
272+
"FROM quotas_reservations "
273+
"WHERE project_id = %s "
274+
"GROUP BY project_id, resource_name",
275+
[project_id],
276+
)
277+
else:
278+
return session.execute(
279+
"SELECT project_id, resource_name, COUNT(*) as reserved_count "
280+
"FROM quotas_reservations "
281+
"GROUP BY project_id, resource_name",
282+
None,
283+
)
284+
285+
if session is None:
286+
engine = cls._get_engine()
287+
with engine.session_manager() as session:
288+
cursor = req()
289+
else:
290+
cursor = req()
291+
292+
result = [
293+
{
294+
"project_id": str(row["project_id"]),
295+
"resource_name": row["resource_name"],
296+
"reserved_count": row["reserved_count"],
297+
}
298+
for row in cursor
299+
]
300+
301+
return result
302+
303+
304+
class QuotaLimit(
305+
models.ModelWithUUID,
306+
models.ModelWithTimestamp,
307+
models.ModelWithProject,
308+
orm.SQLStorableMixin,
309+
):
310+
__tablename__ = "quota_limits"
311+
312+
resource_name = properties.property(
313+
types.String(max_length=255),
314+
required=True,
315+
)
316+
limit = properties.property(
317+
types.Integer(min_value=0),
318+
required=True,
319+
)

0 commit comments

Comments
 (0)