Skip to content

Commit e111d4e

Browse files
committed
add quota
1 parent 6e96eba commit e111d4e

19 files changed

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

0 commit comments

Comments
 (0)