Skip to content

Commit 5760480

Browse files
committed
Fix system limits
The recent changes on the resource limit implementation exposed an issue with the implementation of system limits. They shouldnt't be a third, global scope of resources, but rather a default for organization limits on creation. This change addresses this, by introducing a separate, appropriately named table for handling these defaults. The system-level endpoints are removed as they are not needed anymore, and a migration ensures existing system limits are converted to the correct values.
1 parent 5f8f777 commit 5760480

8 files changed

Lines changed: 128 additions & 134 deletions

File tree

src/api/_util/resourcelimit.py

Lines changed: 20 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
BranchAllocationPublic,
2121
BranchProvisioning,
2222
EntityType,
23+
OrganizationLimitDefault,
2324
ProvisioningLog,
2425
ResourceLimit,
2526
ResourceLimitsPublic,
@@ -134,26 +135,6 @@ async def clone_branch_provisioning(session: SessionDep, source: Branch, target:
134135
await session.refresh(target)
135136

136137

137-
async def initialize_organization_resource_limits(session: SessionDep, organization: Organization):
138-
result = await session.execute(select(ResourceLimit).where(ResourceLimit.entity_type == EntityType.system))
139-
system_limits = result.scalars().all()
140-
141-
with session.no_autoflush:
142-
for system_limit in system_limits:
143-
await session.merge(
144-
ResourceLimit(
145-
entity_type=EntityType.org,
146-
org_id=organization.id,
147-
project_id=None,
148-
resource=system_limit.resource,
149-
max_total=system_limit.max_total,
150-
max_per_branch=system_limit.max_per_branch,
151-
)
152-
)
153-
await session.commit()
154-
await session.refresh(organization)
155-
156-
157138
def dict_to_resource_limits(value: Mapping[ResourceType, int | None]) -> ResourceLimitsPublic:
158139
return ResourceLimitsPublic(
159140
milli_vcpu=value.get(ResourceType.milli_vcpu),
@@ -320,7 +301,6 @@ async def get_remaining_project_resources(
320301
*,
321302
exclude_branch_ids: Sequence[Identifier] | None = None,
322303
) -> ResourceLimitsPublic:
323-
system_limits = await get_system_resource_limits(session)
324304
organization_limits = await get_organization_resource_limits(session, organization_id)
325305
project_limits = await get_project_resource_limits(session, project_id)
326306

@@ -337,16 +317,13 @@ async def get_remaining_project_resources(
337317

338318
effective_limits: dict[ResourceType, int] = {}
339319
for resource_type in ResourceType:
340-
system_limit = system_limits.get(resource_type)
341320
organization_limit = organization_limits.get(resource_type)
342321
project_limit = project_limits.get(resource_type)
343322
per_branch_limit = (
344323
project_limit.max_per_branch
345-
if project_limit and project_limit.max_per_branch is not None
324+
if project_limit
346325
else organization_limit.max_per_branch
347-
if organization_limit and organization_limit.max_per_branch is not None
348-
else system_limit.max_per_branch
349-
if system_limit and system_limit.max_per_branch is not None
326+
if organization_limit
350327
else None
351328
)
352329

@@ -374,17 +351,6 @@ async def get_remaining_project_resources(
374351
return dict_to_resource_limits(effective_limits)
375352

376353

377-
async def get_system_resource_limits(session: SessionDep) -> dict[ResourceType, ResourceLimit]:
378-
result = await session.execute(
379-
select(ResourceLimit).where(
380-
ResourceLimit.entity_type == EntityType.system,
381-
ResourceLimit.org_id.is_(None), # type: ignore[union-attr]
382-
ResourceLimit.project_id.is_(None), # type: ignore[union-attr]
383-
)
384-
)
385-
return _map_resource_limits(list(result.scalars().all()))
386-
387-
388354
async def get_organization_resource_limits(
389355
session: SessionDep, organization_id: Identifier
390356
) -> dict[ResourceType, ResourceLimit]:
@@ -638,6 +604,18 @@ def from_database(cls, limits: Sequence[ResourceLimit]) -> Self:
638604
per_branch=Resources.from_database(limits, "max_per_branch"),
639605
)
640606

607+
@classmethod
608+
def from_defaults(cls, defaults: Sequence["OrganizationLimitDefault"]) -> Self:
609+
return cls(
610+
total=Resources.from_database(defaults, "max_total"),
611+
per_branch=Resources.from_database(defaults, "max_per_branch"),
612+
)
613+
614+
@classmethod
615+
async def organization_defaults(cls, session: AsyncSession) -> Self:
616+
result = await session.execute(select(OrganizationLimitDefault))
617+
return cls.from_defaults(list(result.scalars().all()))
618+
641619
def to_database(self, entity_type: EntityType) -> list[ResourceLimit]:
642620
return [
643621
ResourceLimit(
@@ -651,14 +629,6 @@ def to_database(self, entity_type: EntityType) -> list[ResourceLimit]:
651629
]
652630

653631

654-
async def system_limits(session: AsyncSession) -> Limits:
655-
statement = select(ResourceLimit).where(
656-
col(ResourceLimit.org_id).is_(None), col(ResourceLimit.project_id).is_(None)
657-
)
658-
entries = (await session.exec(statement)).all()
659-
return Limits.from_database(entries)
660-
661-
662632
async def organization_limits(organization: Organization) -> Limits:
663633
return Limits.from_database(await organization.awaitable_attrs.limits)
664634

@@ -683,11 +653,6 @@ def _allocations():
683653
)
684654

685655

686-
async def system_allocations(session: AsyncSession) -> Resources:
687-
allocations = (await session.exec(_allocations())).one()
688-
return Resources(**(allocations._asdict()))
689-
690-
691656
async def organization_allocations(session: AsyncSession, organization: Organization) -> Resources:
692657
statement = _allocations().join(Project).where(Project.organization_id == organization.id)
693658
allocations = (await session.exec(statement)).one()
@@ -700,15 +665,8 @@ async def project_allocations(session: AsyncSession, project: Project) -> Resour
700665
return Resources(**(allocations._asdict()))
701666

702667

703-
async def system_available(session: AsyncSession) -> Resources:
704-
return (await system_limits(session)).total - await system_allocations(session)
705-
706-
707668
async def organization_available(session: AsyncSession, organization: Organization) -> Resources:
708-
return Resources.min(
709-
(await organization_limits(organization)).total - await organization_allocations(session, organization),
710-
await system_available(session),
711-
)
669+
return (await organization_limits(organization)).total - await organization_allocations(session, organization)
712670

713671

714672
async def project_available(session: AsyncSession, project: Project) -> Resources:
@@ -718,16 +676,13 @@ async def project_available(session: AsyncSession, project: Project) -> Resource
718676
)
719677

720678

721-
async def project_branch_maxima(session: AsyncSession, project: Project) -> Resources:
722-
"""Minimum per-branch limit across the hierarchy (project > organization > system).
679+
async def project_branch_maxima(project: Project) -> Resources:
680+
"""Minimum per-branch limit across the hierarchy (project > organization).
723681
724682
Returns None for any field where no per-branch limit has been configured at any level.
725683
"""
726684
organization = await project.awaitable_attrs.organization
727685
return Resources.min(
728-
Resources.min(
729-
(await project_limits(project)).per_branch,
730-
(await organization_limits(organization)).per_branch,
731-
),
732-
(await system_limits(session)).per_branch,
686+
(await project_limits(project)).per_branch,
687+
(await organization_limits(organization)).per_branch,
733688
)

src/api/organization/__init__.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from ...deployment import delete_deployment
1414
from ...models.audit import OrganizationAuditLog
1515
from ...models.organization import Organization, OrganizationCreate, OrganizationUpdate
16-
from ...models.resources import ResourceTypePublic, ResourceUsageMinute
16+
from ...models.resources import EntityType, ResourceTypePublic, ResourceUsageMinute
1717
from .._util import Conflict, Forbidden, NotFound, Unauthenticated, url_path_for
18-
from .._util.resourcelimit import initialize_organization_resource_limits
18+
from .._util.resourcelimit import Limits
1919
from .._util.role import create_organization_admin_role
2020
from ..auth import authenticated_user
2121
from ..dependencies import AuthUserDep, OrganizationDep, SessionDep
@@ -87,7 +87,12 @@ async def create(
8787
user: AuthUserDep,
8888
response: Literal["empty", "full"] = "empty",
8989
) -> JSONResponse:
90-
entity = Organization(**parameters.model_dump(), users=[user])
90+
default_limits = await Limits.organization_defaults(session)
91+
entity = Organization(
92+
**parameters.model_dump(),
93+
users=[user],
94+
limits=default_limits.to_database(EntityType.org),
95+
)
9196
session.add(entity)
9297
try:
9398
await session.commit()
@@ -108,9 +113,6 @@ async def create(
108113
await session.commit()
109114
await session.refresh(entity)
110115

111-
# Set up initial organization resource limits
112-
await initialize_organization_resource_limits(session, entity)
113-
114116
entity_url = url_path_for(request, "organizations:detail", organization_id=entity.id)
115117
return JSONResponse(
116118
content=entity.model_dump() if response == "full" else None,

src/api/organization/project/resources.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,5 @@ async def available(session: SessionDep, project: ProjectDep) -> Resources:
6363
name="organizations:projects:resources:branch-maxima",
6464
responses={401: Unauthenticated, 403: Forbidden, 404: NotFound},
6565
)
66-
async def branch_maxima(session: SessionDep, project: ProjectDep) -> Resources:
67-
return await project_branch_maxima(session, project)
66+
async def branch_maxima(project: ProjectDep) -> Resources:
67+
return await project_branch_maxima(project)

src/api/resources.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@
4040
ResourceLimitsPublic,
4141
ResourceUsageMinute,
4242
)
43-
from ._util import Unauthenticated
4443
from ._util.resourcelimit import (
45-
Limits,
46-
Resources,
4744
check_resource_limits,
4845
create_or_update_branch_provisioning,
4946
dict_to_resource_limits,
@@ -55,9 +52,6 @@
5552
get_organization_resource_usage,
5653
get_project_resource_usage,
5754
make_usage_cycle,
58-
system_allocations,
59-
system_available,
60-
system_limits,
6155
)
6256
from .auth import authenticated_user
6357
from .db import SessionDep
@@ -494,30 +488,3 @@ async def monitor_resources():
494488
await asyncio.sleep((interval - elapsed).total_seconds())
495489
else:
496490
logger.warning("Resource monitor execution exeeded desired interval")
497-
498-
499-
@api.get(
500-
"/limits/",
501-
name="resources:limits",
502-
responses={401: Unauthenticated},
503-
)
504-
async def limits(session: SessionDep) -> Limits:
505-
return await system_limits(session)
506-
507-
508-
@api.get(
509-
"/allocations/",
510-
name="resources:allocations",
511-
responses={401: Unauthenticated},
512-
)
513-
async def allocated(session: SessionDep) -> Resources:
514-
return await system_allocations(session)
515-
516-
517-
@api.get(
518-
"/available/",
519-
name="resources:available",
520-
responses={401: Unauthenticated},
521-
)
522-
async def available(session: SessionDep) -> Resources:
523-
return await system_available(session)

src/api/system.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@
2121
VCPU_MILLIS_MIN,
2222
VCPU_MILLIS_STEP,
2323
)
24-
from ..models.resources import ResourceLimitDefinitionPublic, ResourceType
24+
from ..models.resources import OrganizationLimitDefault, ResourceLimitDefinitionPublic, ResourceType
2525
from ..models.role import AccessRight
26-
from ._util.resourcelimit import get_system_resource_limits
2726
from .auth import authenticated_user
2827
from .db import SessionDep
2928

@@ -68,10 +67,12 @@ async def list_available_permissions(
6867
async def list_resource_limit_definitions(
6968
session: SessionDep,
7069
) -> list[ResourceLimitDefinitionPublic]:
71-
system_limits = await get_system_resource_limits(session)
70+
defaults_result = await session.execute(select(OrganizationLimitDefault))
71+
defaults = {d.resource: d for d in defaults_result.scalars().all()}
7272

7373
def _get_limit(resource_type: ResourceType, default: int) -> int:
74-
return system_limits[resource_type].max_total if system_limits[resource_type] else default
74+
d = defaults.get(resource_type)
75+
return d.max_total if d else default
7576

7677
max_vcpu_millis = _get_limit(ResourceType.milli_vcpu, VCPU_MILLIS_MAX)
7778
max_ram_bytes = _get_limit(ResourceType.ram, MEMORY_MAX)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""fix-system-limits
2+
3+
Revision ID: e8f3a2d51c9b
4+
Revises: 9bebcc605033
5+
Create Date: 2026-03-20 00:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
import sqlmodel
12+
import sqlmodel.sql
13+
from alembic import op
14+
from ulid import ULID
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "e8f3a2d51c9b"
18+
down_revision: Union[str, Sequence[str], None] = "9bebcc605033"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
"""Upgrade schema."""
25+
resource_type_enum = sa.Enum(
26+
"milli_vcpu", "ram", "iops", "database_size", "storage_size",
27+
name="resourcetype",
28+
create_type=False,
29+
)
30+
31+
# Create the organizationlimitdefault table
32+
op.create_table(
33+
"organizationlimitdefault",
34+
sa.Column("id", sa.UUID(), nullable=False),
35+
sa.Column("resource", resource_type_enum, nullable=False),
36+
sa.Column("max_total", sa.BigInteger(), nullable=False),
37+
sa.Column("max_per_branch", sa.BigInteger(), nullable=False),
38+
sa.PrimaryKeyConstraint("id"),
39+
)
40+
op.create_index("uq_org_limit_default_resource", "organizationlimitdefault", ["resource"], unique=True)
41+
42+
# Migrate system limits → OrganizationLimitDefault
43+
conn = op.get_bind()
44+
system_rows = conn.execute(
45+
sa.text("SELECT resource, max_total, max_per_branch FROM resourcelimit WHERE entity_type = 'system'")
46+
).fetchall()
47+
48+
org_limit_default = sa.table(
49+
"organizationlimitdefault",
50+
sa.column("id", sa.UUID),
51+
sa.column("resource", resource_type_enum),
52+
sa.column("max_total", sa.BigInteger),
53+
sa.column("max_per_branch", sa.BigInteger),
54+
)
55+
for resource, max_total, max_per_branch in system_rows:
56+
conn.execute(
57+
sa.insert(org_limit_default).values(
58+
id=ULID().to_uuid(),
59+
resource=resource,
60+
max_total=max_total,
61+
max_per_branch=max_per_branch,
62+
)
63+
)
64+
65+
conn.execute(sa.text("DELETE FROM resourcelimit WHERE entity_type = 'system'"))
66+
67+
68+
def downgrade() -> None:
69+
"""Downgrade schema."""
70+
# Data migration is not trivially reversible; just drop the table.
71+
op.drop_index("uq_org_limit_default_resource", table_name="organizationlimitdefault")
72+
op.drop_table("organizationlimitdefault")

src/models/resources.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ class ResourceType(PyEnum):
2929

3030

3131
class EntityType(PyEnum):
32-
system = "system"
3332
org = "org"
3433
project = "project"
3534

@@ -85,6 +84,13 @@ class ResourceLimit(AsyncAttrs, Model, table=True):
8584
max_per_branch: Annotated[int, Field(sa_type=BigInteger)]
8685

8786

87+
class OrganizationLimitDefault(AsyncAttrs, Model, table=True):
88+
__table_args__ = (Index("uq_org_limit_default_resource", "resource", unique=True),)
89+
resource: ResourceType
90+
max_total: Annotated[int, Field(sa_type=BigInteger)]
91+
max_per_branch: Annotated[int, Field(sa_type=BigInteger)]
92+
93+
8894
class BranchProvisioning(AsyncAttrs, Model, table=True):
8995
branch_id: Identifier | None = Model.foreign_key_field("branch", ondelete="CASCADE")
9096
resource: ResourceType

0 commit comments

Comments
 (0)