Skip to content

Commit 3f49453

Browse files
committed
add quota
1 parent 14a666e commit 3f49453

20 files changed

Lines changed: 1817 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: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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 typing as tp
53+
import uuid as sys_uuid
54+
55+
from restalchemy.common import exceptions as ra_e
56+
from restalchemy.dm import filters as dm_filters
57+
from restalchemy.dm import models
58+
from restalchemy.dm import properties
59+
from restalchemy.dm import types
60+
from restalchemy.storage.sql import orm
61+
62+
63+
class QuotaExceededError(ra_e.ValidationErrorException):
64+
message = "Quota exceeded for resource '%(resource_name)s' in project %(project_id)s: %(current)s > %(limit)s"
65+
66+
def __init__(
67+
self, resource_name: str, limit: int, current: int, project_id: sys_uuid.UUID
68+
):
69+
super().__init__(
70+
resource_name=resource_name,
71+
limit=limit,
72+
current=current,
73+
project_id=project_id,
74+
)
75+
self.resource_name = resource_name
76+
self.limit = limit
77+
self.current = current
78+
self.project_id = project_id
79+
80+
81+
# Built-in default quota limits keyed by resource_name (__tablename__).
82+
# These apply when no explicit QuotaLimit row exists for the project+resource.
83+
# A resource not listed here or in the DB has no quota limit (unlimited).
84+
DEFAULT_QUOTA_LIMIT = 1000
85+
DEFAULT_QUOTA_LIMITS: tp.Dict[str, int] = {
86+
"net_lb": DEFAULT_QUOTA_LIMIT,
87+
"compute_sets": DEFAULT_QUOTA_LIMIT,
88+
"nodes": DEFAULT_QUOTA_LIMIT,
89+
"secret_passwords": DEFAULT_QUOTA_LIMIT,
90+
"secret_certificates": DEFAULT_QUOTA_LIMIT,
91+
"secret_rsa_keys": DEFAULT_QUOTA_LIMIT,
92+
"secret_ssh_keys": DEFAULT_QUOTA_LIMIT,
93+
}
94+
95+
96+
class QuotaModelMixin:
97+
def _quota_limit(self, session) -> tp.Optional[int]:
98+
limit = QuotaLimit.objects.get_one_or_none(
99+
session=session,
100+
filters={
101+
"project_id": dm_filters.EQ(self.project_id),
102+
"resource_name": dm_filters.EQ(self.__tablename__),
103+
},
104+
)
105+
if limit is not None:
106+
return limit.limit
107+
return DEFAULT_QUOTA_LIMITS.get(self.__tablename__)
108+
109+
@staticmethod
110+
def _quota_lock_key(project_id: sys_uuid.UUID, resource_name: str) -> int:
111+
"""Derive a positive bigint lock key for pg_advisory_xact_lock.
112+
113+
The key is a deterministic 63-bit hash of ``{project_id}:{resource_name}``,
114+
so concurrent inserts for the same project+resource contend on the same
115+
lock, while different project+resource pairs do not block each other.
116+
117+
Examples
118+
--------
119+
>>> import uuid
120+
>>> pid = uuid.UUID("00000000-0000-0000-0000-000000000000")
121+
>>> _quota_lock_key(pid, "net_lb")
122+
8255477843906886675
123+
>>> _quota_lock_key(pid, "compute_sets")
124+
2792567584429316197
125+
>>> # Different project → different key
126+
>>> pid2 = uuid.UUID("12345678-1234-5678-1234-567812345678")
127+
>>> _quota_lock_key(pid2, "net_lb")
128+
1636646552482098955
129+
"""
130+
key = f"{project_id}:{resource_name}"
131+
digest = hashlib.sha256(key.encode()).digest()[:8]
132+
return (
133+
int.from_bytes(digest, byteorder="big", signed=False) & 0x7FFFFFFFFFFFFFFF
134+
)
135+
136+
def _quota_acquire_lock(self, session: tp.Any) -> None:
137+
lock_key = self._quota_lock_key(self.project_id, self.__tablename__)
138+
session.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
139+
140+
def _quota_reserve(self, session) -> None:
141+
# Check limit, count existing reservations, create reservation.
142+
# If limit exceeded, raises QuotaExceededError.
143+
limit = self._quota_limit(session)
144+
if limit is None:
145+
return
146+
147+
self._quota_acquire_lock(session)
148+
149+
current = QuotaReservation.objects.count(
150+
session=session,
151+
filters={
152+
"project_id": dm_filters.EQ(self.project_id),
153+
"resource_name": dm_filters.EQ(self.__tablename__),
154+
},
155+
)
156+
157+
if current + 1 > limit:
158+
raise QuotaExceededError(
159+
resource_name=self.__tablename__,
160+
limit=limit,
161+
current=current + 1,
162+
project_id=self.project_id,
163+
)
164+
165+
reservation = QuotaReservation(
166+
uuid=self.uuid,
167+
project_id=self.project_id,
168+
resource_name=self.__tablename__,
169+
)
170+
reservation.insert(session=session)
171+
172+
def _quota_release_all(self, session) -> None:
173+
# Delete all reservations for this entity.
174+
for reservation in QuotaReservation.objects.get_all(
175+
filters={
176+
"uuid": dm_filters.EQ(self.uuid),
177+
"project_id": dm_filters.EQ(self.project_id),
178+
"resource_name": dm_filters.EQ(self.__tablename__),
179+
},
180+
session=session,
181+
):
182+
reservation.delete(session=session)
183+
184+
def insert(self, session=None):
185+
# Reserve quota slot, then insert entity.
186+
# The reservation is in the same transaction as the entity, so if the
187+
# entity insert fails the entire transaction is rolled back and the
188+
# reservation is cleaned up automatically — no explicit release needed.
189+
if session is None:
190+
engine = QuotaReservation._get_engine()
191+
with engine.session_manager() as s:
192+
self._quota_reserve(s)
193+
super().insert(session=s)
194+
else:
195+
self._quota_reserve(session)
196+
super().insert(session=session)
197+
198+
def delete(self, session=None):
199+
# Release reservation, then delete entity.
200+
if session is None:
201+
engine = QuotaReservation._get_engine()
202+
with engine.session_manager() as s:
203+
self._quota_release_all(s)
204+
super().delete(session=s)
205+
else:
206+
self._quota_release_all(session)
207+
super().delete(session=session)
208+
209+
210+
def reconcile_quota_reservations(session):
211+
cursor = session.execute(
212+
"SELECT DISTINCT resource_name FROM quotas_reservations",
213+
None,
214+
)
215+
resource_names = [row["resource_name"] for row in cursor]
216+
for rname in resource_names:
217+
session.execute(
218+
"DELETE FROM quotas_reservations r "
219+
"WHERE r.resource_name = %s "
220+
"AND NOT EXISTS (SELECT 1 FROM " + rname + " e WHERE e.uuid = r.uuid)",
221+
[rname],
222+
)
223+
224+
225+
class QuotaReservation(
226+
models.ModelWithUUID,
227+
models.ModelWithTimestamp,
228+
models.ModelWithProject,
229+
orm.SQLStorableMixin,
230+
):
231+
__tablename__ = "quotas_reservations"
232+
233+
resource_name = properties.property(
234+
types.String(max_length=255),
235+
required=True,
236+
read_only=True,
237+
)
238+
239+
@classmethod
240+
def get_project_quota_summary(
241+
cls,
242+
session: tp.Optional[tp.Any] = None,
243+
project_id: sys_uuid.UUID | str | None = None,
244+
) -> tp.List[tp.Dict[str, tp.Any]]:
245+
"""Get aggregated quota reservation information.
246+
247+
Returns a list of dictionaries with keys:
248+
- project_id: UUID of the project
249+
- resource_name: name of the resource
250+
- reserved_count: number of reservations for this project+resource
251+
252+
If project_id is provided, filters to that specific project.
253+
Otherwise, returns summaries for all projects.
254+
255+
If session is not provided, creates a temporary session using the default engine.
256+
"""
257+
258+
def req():
259+
if project_id is not None:
260+
return session.execute(
261+
"SELECT project_id, resource_name, COUNT(*) as reserved_count "
262+
"FROM quotas_reservations "
263+
"WHERE project_id = %s "
264+
"GROUP BY project_id, resource_name",
265+
[project_id],
266+
)
267+
else:
268+
return session.execute(
269+
"SELECT project_id, resource_name, COUNT(*) as reserved_count "
270+
"FROM quotas_reservations "
271+
"GROUP BY project_id, resource_name",
272+
None,
273+
)
274+
275+
if session is None:
276+
engine = cls._get_engine()
277+
with engine.session_manager() as session:
278+
cursor = req()
279+
else:
280+
cursor = req()
281+
282+
result = [
283+
{
284+
"project_id": str(row["project_id"]),
285+
"resource_name": row["resource_name"],
286+
"reserved_count": row["reserved_count"],
287+
}
288+
for row in cursor
289+
]
290+
291+
return result
292+
293+
294+
class QuotaLimit(
295+
models.ModelWithUUID,
296+
models.ModelWithTimestamp,
297+
models.ModelWithProject,
298+
orm.SQLStorableMixin,
299+
):
300+
__tablename__ = "quota_limits"
301+
302+
resource_name = properties.property(
303+
types.String(max_length=255),
304+
required=True,
305+
)
306+
limit = properties.property(
307+
types.Integer(min_value=0),
308+
required=True,
309+
)

0 commit comments

Comments
 (0)