Skip to content

Commit a210e76

Browse files
committed
feat(BA-5827): AppConfigFragment foundation (DB + repository)
Lands the `app_config_fragments` storage layer per BEP-1052 §1·§2: table + Alembic migration, ORM row, data types, the repository slice (reads + writes via Creator/Updater/Purger helpers), domain errors, and the repository unit tests. The shared `Resilience` policy + DB metric layer are wired here too. The service / adapter / DTO / GQL+REST / SDK+CLI verticals layer on top in subsequent PRs.
1 parent 7a44d32 commit a210e76

23 files changed

Lines changed: 1365 additions & 0 deletions

File tree

changes/11282.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `app_config_fragments` table and repository foundation — per-scope raw config rows keyed by `(scope_type, scope_id, name)` with a NO-ACTION FK to `app_config_policies.config_name` (BEP-1052 §1).

src/ai/backend/common/metrics/metric.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ class LayerType(enum.StrEnum):
455455
RESOURCE_SLOT_REPOSITORY = "resource_slot_repository"
456456

457457
# DB Source layers
458+
APP_CONFIG_FRAGMENT_DB_SOURCE = "app_config_fragment_db_source"
458459
APP_CONFIG_POLICY_DB_SOURCE = "app_config_policy_db_source"
459460
AUDIT_LOG_DB_SOURCE = "audit_log_db_source"
460461
AUTH_DB_SOURCE = "auth_db_source"

src/ai/backend/manager/data/app_config/__init__.py

Whitespace-only changes.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from collections.abc import Mapping, Sequence
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData
9+
10+
11+
@dataclass(frozen=True)
12+
class AppConfigData:
13+
"""Service-layer return type for the merged AppConfig view (BEP-1052 §5).
14+
15+
`fragments` are ordered low → high merge priority (matching the
16+
policy's `scope_sources`). `config` is the deep-merged result,
17+
projected to `None` when every contributing fragment is empty.
18+
"""
19+
20+
user_id: uuid.UUID
21+
name: str
22+
fragments: Sequence[AppConfigFragmentData]
23+
config: Mapping[str, Any] | None
24+
25+
26+
@dataclass(frozen=True)
27+
class AppConfigSearchResult:
28+
"""Result from searching merged `AppConfig` views."""
29+
30+
items: list[AppConfigData]
31+
total_count: int
32+
has_next_page: bool
33+
has_previous_page: bool

src/ai/backend/manager/data/app_config_fragment/__init__.py

Whitespace-only changes.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
import enum
4+
import uuid
5+
from collections.abc import Mapping
6+
from dataclasses import dataclass
7+
from datetime import datetime
8+
from typing import Any
9+
10+
11+
class AppConfigScopeType(enum.StrEnum):
12+
PUBLIC = "public"
13+
DOMAIN = "domain"
14+
DOMAIN_USER_DEFAULTS = "domain_user_defaults"
15+
USER = "user"
16+
17+
18+
@dataclass(frozen=True, slots=True)
19+
class AppConfigFragmentKey:
20+
"""Natural-key identifier for a single `app_config_fragments` row."""
21+
22+
scope_type: AppConfigScopeType
23+
scope_id: str
24+
name: str
25+
26+
27+
@dataclass(frozen=True)
28+
class AppConfigFragmentData:
29+
id: uuid.UUID
30+
scope_type: AppConfigScopeType
31+
scope_id: str
32+
name: str
33+
extra_config: Mapping[str, Any] | None
34+
created_at: datetime
35+
updated_at: datetime | None
36+
37+
@property
38+
def key(self) -> AppConfigFragmentKey:
39+
return AppConfigFragmentKey(
40+
scope_type=self.scope_type,
41+
scope_id=self.scope_id,
42+
name=self.name,
43+
)
44+
45+
46+
@dataclass(frozen=True)
47+
class AppConfigFragmentSearchResult:
48+
"""Result from searching raw `app_config_fragments` rows."""
49+
50+
items: list[AppConfigFragmentData]
51+
total_count: int
52+
has_next_page: bool
53+
has_previous_page: bool

src/ai/backend/manager/errors/app_config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,47 @@ def error_code(self) -> ErrorCode:
3131
operation=ErrorOperation.READ,
3232
error_detail=ErrorDetail.NOT_FOUND,
3333
)
34+
35+
36+
class AppConfigFragmentConflict(BackendAIError, web.HTTPConflict):
37+
error_type = "https://api.backend.ai/probs/app-config-fragment-conflict"
38+
error_title = (
39+
"An app-config fragment with the same (scope_type, scope_id, name) already exists."
40+
)
41+
42+
def error_code(self) -> ErrorCode:
43+
return ErrorCode(
44+
domain=ErrorDomain.BACKENDAI,
45+
operation=ErrorOperation.CREATE,
46+
error_detail=ErrorDetail.CONFLICT,
47+
)
48+
49+
50+
class AppConfigFragmentNotFound(ObjectNotFound):
51+
object_name = "app-config fragment"
52+
53+
def error_code(self) -> ErrorCode:
54+
return ErrorCode(
55+
domain=ErrorDomain.BACKENDAI,
56+
operation=ErrorOperation.READ,
57+
error_detail=ErrorDetail.NOT_FOUND,
58+
)
59+
60+
61+
class AppConfigFragmentPolicyMissing(BackendAIError, web.HTTPConflict):
62+
"""Raised when a fragment references a `name` without a matching policy row.
63+
64+
Defense-in-depth against the required-policy invariant from
65+
BEP-1052 §1 — normally the service layer rejects earlier, but the
66+
FK violation surfaces here if the service check is bypassed.
67+
"""
68+
69+
error_type = "https://api.backend.ai/probs/app-config-fragment-policy-missing"
70+
error_title = "Referenced app-config policy does not exist for this fragment."
71+
72+
def error_code(self) -> ErrorCode:
73+
return ErrorCode(
74+
domain=ErrorDomain.BACKENDAI,
75+
operation=ErrorOperation.CREATE,
76+
error_detail=ErrorDetail.CONFLICT,
77+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""add app_config_fragments table
2+
3+
Lands the AppConfigFragment slice of the BEP-1052 (Scoped App Config
4+
Redesign) data-layer foundation: per-scope raw rows keyed by
5+
`(scope_type, scope_id, name)`. `name` is a FK to
6+
`app_config_policies.config_name` with default NO ACTION (the
7+
required-policy invariant — see BEP-1052 §1).
8+
9+
Stacks on top of `5df264862995_add_app_config_policies.py`; the
10+
policy table must exist before the FK can be created.
11+
12+
Revision ID: a662131d5603
13+
Revises: 5df264862995
14+
Create Date: 2026-04-24
15+
16+
"""
17+
18+
import sqlalchemy as sa
19+
from alembic import op
20+
from sqlalchemy.dialects import postgresql as pgsql
21+
22+
from ai.backend.manager.models.base import IDColumn
23+
24+
# revision identifiers, used by Alembic.
25+
revision = "a662131d5603"
26+
down_revision = "5df264862995"
27+
# Part of: 26.5.0
28+
branch_labels = None
29+
depends_on = None
30+
31+
32+
def upgrade() -> None:
33+
op.create_table(
34+
"app_config_fragments",
35+
IDColumn(),
36+
sa.Column(
37+
"scope_type",
38+
sa.String(length=32),
39+
nullable=False,
40+
index=True,
41+
),
42+
sa.Column("scope_id", sa.String(length=255), nullable=False),
43+
sa.Column(
44+
"name",
45+
sa.String(length=128),
46+
sa.ForeignKey(
47+
# No ON DELETE / ON UPDATE — Postgres default NO ACTION
48+
# enforces the required-policy invariant: a policy
49+
# cannot be dropped while fragments reference it, and
50+
# config_name is immutable so ON UPDATE never fires.
51+
"app_config_policies.config_name",
52+
name="fk_app_config_fragments_name_app_config_policies_config_name",
53+
),
54+
nullable=False,
55+
),
56+
sa.Column(
57+
"extra_config",
58+
pgsql.JSONB(),
59+
nullable=True,
60+
),
61+
sa.Column(
62+
"created_at",
63+
sa.DateTime(timezone=True),
64+
nullable=False,
65+
server_default=sa.func.now(),
66+
),
67+
sa.Column(
68+
"updated_at",
69+
sa.DateTime(timezone=True),
70+
nullable=True,
71+
),
72+
sa.UniqueConstraint(
73+
"scope_type",
74+
"scope_id",
75+
"name",
76+
name="uq_app_config_fragments_scope_name",
77+
),
78+
)
79+
80+
81+
def downgrade() -> None:
82+
op.drop_table("app_config_fragments")
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from ai.backend.manager.data.app_config_fragment.types import AppConfigScopeType
2+
3+
from .row import AppConfigFragmentRow
4+
5+
__all__ = (
6+
"AppConfigFragmentRow",
7+
"AppConfigScopeType",
8+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from collections.abc import Mapping
5+
from datetime import datetime
6+
from typing import Any
7+
8+
import sqlalchemy as sa
9+
from sqlalchemy.dialects import postgresql as pgsql
10+
from sqlalchemy.orm import Mapped, mapped_column
11+
12+
from ai.backend.manager.data.app_config_fragment.types import (
13+
AppConfigFragmentData,
14+
AppConfigScopeType,
15+
)
16+
from ai.backend.manager.models.base import GUID, Base, StrEnumType
17+
18+
19+
class AppConfigFragmentRow(Base): # type: ignore[misc]
20+
__tablename__ = "app_config_fragments"
21+
__table_args__ = (
22+
sa.UniqueConstraint(
23+
"scope_type",
24+
"scope_id",
25+
"name",
26+
name="uq_app_config_fragments_scope_name",
27+
),
28+
)
29+
30+
id: Mapped[uuid.UUID] = mapped_column(
31+
GUID, primary_key=True, server_default=sa.text("uuid_generate_v4()")
32+
)
33+
scope_type: Mapped[AppConfigScopeType] = mapped_column(
34+
"scope_type",
35+
StrEnumType(AppConfigScopeType, length=32),
36+
nullable=False,
37+
index=True,
38+
)
39+
scope_id: Mapped[str] = mapped_column(
40+
"scope_id",
41+
sa.String(length=255),
42+
nullable=False,
43+
)
44+
name: Mapped[str] = mapped_column(
45+
"name",
46+
sa.String(length=128),
47+
# FK to `app_config_policies.config_name` (default NO ACTION) —
48+
# enforces the required-policy invariant from BEP-1052 §1.
49+
sa.ForeignKey(
50+
"app_config_policies.config_name",
51+
name="fk_app_config_fragments_name_app_config_policies_config_name",
52+
),
53+
nullable=False,
54+
)
55+
extra_config: Mapped[Mapping[str, Any] | None] = mapped_column(
56+
"extra_config",
57+
pgsql.JSONB,
58+
nullable=True,
59+
)
60+
created_at: Mapped[datetime] = mapped_column(
61+
"created_at",
62+
sa.DateTime(timezone=True),
63+
nullable=False,
64+
server_default=sa.func.now(),
65+
)
66+
updated_at: Mapped[datetime | None] = mapped_column(
67+
"updated_at",
68+
sa.DateTime(timezone=True),
69+
nullable=True,
70+
onupdate=sa.func.current_timestamp(),
71+
)
72+
73+
def to_data(self) -> AppConfigFragmentData:
74+
return AppConfigFragmentData(
75+
id=self.id,
76+
scope_type=self.scope_type,
77+
scope_id=self.scope_id,
78+
name=self.name,
79+
extra_config=dict(self.extra_config) if self.extra_config is not None else None,
80+
created_at=self.created_at,
81+
updated_at=self.updated_at,
82+
)

0 commit comments

Comments
 (0)