diff --git a/changes/11282.feature.md b/changes/11282.feature.md new file mode 100644 index 00000000000..f105a728250 --- /dev/null +++ b/changes/11282.feature.md @@ -0,0 +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`. diff --git a/src/ai/backend/common/metrics/metric.py b/src/ai/backend/common/metrics/metric.py index 83c86b5516d..5a4508697ad 100644 --- a/src/ai/backend/common/metrics/metric.py +++ b/src/ai/backend/common/metrics/metric.py @@ -414,6 +414,8 @@ class DomainType(enum.StrEnum): class LayerType(enum.StrEnum): # Repository layers with _REPOSITORY suffix AGENT_REPOSITORY = "agent_repository" + APP_CONFIG_FRAGMENT_ADMIN_REPOSITORY = "app_config_fragment_admin_repository" + APP_CONFIG_FRAGMENT_REPOSITORY = "app_config_fragment_repository" APP_CONFIG_POLICY_ADMIN_REPOSITORY = "app_config_policy_admin_repository" APP_CONFIG_POLICY_REPOSITORY = "app_config_policy_repository" AUTH_REPOSITORY = "auth_repository" @@ -457,6 +459,7 @@ class LayerType(enum.StrEnum): RESOURCE_SLOT_REPOSITORY = "resource_slot_repository" # DB Source layers + APP_CONFIG_FRAGMENT_DB_SOURCE = "app_config_fragment_db_source" APP_CONFIG_POLICY_DB_SOURCE = "app_config_policy_db_source" AUDIT_LOG_DB_SOURCE = "audit_log_db_source" AUTH_DB_SOURCE = "auth_db_source" diff --git a/src/ai/backend/manager/data/app_config/__init__.py b/src/ai/backend/manager/data/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/data/app_config/types.py b/src/ai/backend/manager/data/app_config/types.py new file mode 100644 index 00000000000..54cd501c4ec --- /dev/null +++ b/src/ai/backend/manager/data/app_config/types.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData + + +@dataclass(frozen=True) +class AppConfigData: + """Service-layer return type for the merged AppConfig view. + + `fragments` are ordered low → high merge priority (matching the + policy's `scope_sources`). `config` is the deep-merged result, + projected to `None` when every contributing fragment is empty. + """ + + user_id: uuid.UUID + name: str + fragments: Sequence[AppConfigFragmentData] + config: Mapping[str, Any] | None + + +@dataclass(frozen=True) +class AppConfigSearchResult: + """Result from searching merged `AppConfig` views.""" + + items: list[AppConfigData] + total_count: int + has_next_page: bool + has_previous_page: bool diff --git a/src/ai/backend/manager/data/app_config_fragment/__init__.py b/src/ai/backend/manager/data/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/data/app_config_fragment/types.py b/src/ai/backend/manager/data/app_config_fragment/types.py new file mode 100644 index 00000000000..41f677527cb --- /dev/null +++ b/src/ai/backend/manager/data/app_config_fragment/types.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import enum +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +class AppConfigScopeType(enum.StrEnum): + PUBLIC = "public" + DOMAIN = "domain" + DOMAIN_USER_DEFAULTS = "domain_user_defaults" + USER = "user" + + +@dataclass(frozen=True, slots=True) +class AppConfigFragmentKey: + """Natural-key identifier for a single `app_config_fragments` row.""" + + scope_type: AppConfigScopeType + scope_id: str + name: str + + +@dataclass(frozen=True) +class AppConfigFragmentData: + id: uuid.UUID + scope_type: AppConfigScopeType + scope_id: str + name: str + config: Mapping[str, Any] | None + created_at: datetime + updated_at: datetime | None + + @property + def key(self) -> AppConfigFragmentKey: + return AppConfigFragmentKey( + scope_type=self.scope_type, + scope_id=self.scope_id, + name=self.name, + ) + + +@dataclass(frozen=True) +class AppConfigFragmentSearchResult: + """Result from searching raw `app_config_fragments` rows.""" + + items: list[AppConfigFragmentData] + total_count: int + has_next_page: bool + has_previous_page: bool diff --git a/src/ai/backend/manager/errors/app_config.py b/src/ai/backend/manager/errors/app_config.py index e7a76002fce..00cb78b0c83 100644 --- a/src/ai/backend/manager/errors/app_config.py +++ b/src/ai/backend/manager/errors/app_config.py @@ -31,3 +31,28 @@ def error_code(self) -> ErrorCode: operation=ErrorOperation.READ, error_detail=ErrorDetail.NOT_FOUND, ) + + +class AppConfigFragmentConflict(BackendAIError, web.HTTPConflict): + error_type = "https://api.backend.ai/probs/app-config-fragment-conflict" + error_title = ( + "An app-config fragment with the same (scope_type, scope_id, name) already exists." + ) + + def error_code(self) -> ErrorCode: + return ErrorCode( + domain=ErrorDomain.BACKENDAI, + operation=ErrorOperation.CREATE, + error_detail=ErrorDetail.CONFLICT, + ) + + +class AppConfigFragmentNotFound(ObjectNotFound): + object_name = "app-config fragment" + + def error_code(self) -> ErrorCode: + return ErrorCode( + domain=ErrorDomain.BACKENDAI, + operation=ErrorOperation.READ, + error_detail=ErrorDetail.NOT_FOUND, + ) diff --git a/src/ai/backend/manager/models/alembic/versions/a662131d5603_add_app_config_fragments.py b/src/ai/backend/manager/models/alembic/versions/a662131d5603_add_app_config_fragments.py new file mode 100644 index 00000000000..196c6706cfc --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/a662131d5603_add_app_config_fragments.py @@ -0,0 +1,81 @@ +"""add app_config_fragments table + +Adds the per-scope raw fragment table keyed by +`(scope_type, scope_id, name)`. `name` is a FK to +`app_config_policies.config_name` with default NO ACTION enforcing the +required-policy invariant. + +Stacks on top of `5df264862995_add_app_config_policies.py`; the +policy table must exist before the FK can be created. + +Revision ID: a662131d5603 +Revises: 5df264862995 +Create Date: 2026-04-24 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql as pgsql + +from ai.backend.manager.models.base import IDColumn + +# revision identifiers, used by Alembic. +revision = "a662131d5603" +down_revision = "5df264862995" +# Part of: 26.5.0 +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "app_config_fragments", + IDColumn(), + sa.Column( + "scope_type", + sa.String(length=32), + nullable=False, + index=True, + ), + sa.Column("scope_id", sa.String(length=255), nullable=False), + sa.Column( + "name", + sa.String(length=128), + sa.ForeignKey( + # No ON DELETE / ON UPDATE — Postgres default NO ACTION + # enforces the required-policy invariant: a policy + # cannot be dropped while fragments reference it, and + # config_name is immutable so ON UPDATE never fires. + "app_config_policies.config_name", + name="fk_app_config_fragments_name_app_config_policies_config_name", + ), + nullable=False, + ), + sa.Column( + "config", + pgsql.JSONB(), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=True, + ), + sa.UniqueConstraint( + "scope_type", + "scope_id", + "name", + name="uq_app_config_fragments_scope_name", + ), + ) + + +def downgrade() -> None: + op.drop_table("app_config_fragments") diff --git a/src/ai/backend/manager/models/app_config_fragment/__init__.py b/src/ai/backend/manager/models/app_config_fragment/__init__.py new file mode 100644 index 00000000000..3d7d66a7efb --- /dev/null +++ b/src/ai/backend/manager/models/app_config_fragment/__init__.py @@ -0,0 +1,8 @@ +from ai.backend.manager.data.app_config_fragment.types import AppConfigScopeType + +from .row import AppConfigFragmentRow + +__all__ = ( + "AppConfigFragmentRow", + "AppConfigScopeType", +) diff --git a/src/ai/backend/manager/models/app_config_fragment/row.py b/src/ai/backend/manager/models/app_config_fragment/row.py new file mode 100644 index 00000000000..c6172cf4443 --- /dev/null +++ b/src/ai/backend/manager/models/app_config_fragment/row.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql as pgsql +from sqlalchemy.orm import Mapped, mapped_column + +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigScopeType, +) +from ai.backend.manager.models.base import GUID, Base, StrEnumType + + +class AppConfigFragmentRow(Base): # type: ignore[misc] + __tablename__ = "app_config_fragments" + __table_args__ = ( + sa.UniqueConstraint( + "scope_type", + "scope_id", + "name", + name="uq_app_config_fragments_scope_name", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + GUID, primary_key=True, server_default=sa.text("uuid_generate_v4()") + ) + scope_type: Mapped[AppConfigScopeType] = mapped_column( + "scope_type", + StrEnumType(AppConfigScopeType, length=32), + nullable=False, + index=True, + ) + scope_id: Mapped[str] = mapped_column( + "scope_id", + sa.String(length=255), + nullable=False, + ) + name: Mapped[str] = mapped_column( + "name", + sa.String(length=128), + # FK to `app_config_policies.config_name` (default NO ACTION) — + # enforces the required-policy invariant. + sa.ForeignKey( + "app_config_policies.config_name", + name="fk_app_config_fragments_name_app_config_policies_config_name", + ), + nullable=False, + ) + config: Mapped[Mapping[str, Any] | None] = mapped_column( + "config", + pgsql.JSONB, + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ) + updated_at: Mapped[datetime | None] = mapped_column( + "updated_at", + sa.DateTime(timezone=True), + nullable=True, + onupdate=sa.func.current_timestamp(), + ) + + def to_data(self) -> AppConfigFragmentData: + return AppConfigFragmentData( + id=self.id, + scope_type=self.scope_type, + scope_id=self.scope_id, + name=self.name, + config=dict(self.config) if self.config is not None else None, + created_at=self.created_at, + updated_at=self.updated_at, + ) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/__init__.py b/src/ai/backend/manager/repositories/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/repositories/app_config_fragment/admin_repository.py b/src/ai/backend/manager/repositories/app_config_fragment/admin_repository.py new file mode 100644 index 00000000000..8352fd7ee56 --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/admin_repository.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ai.backend.common.exception import BackendAIError +from ai.backend.common.metrics.metric import DomainType, LayerType +from ai.backend.common.resilience.policies.metrics import MetricArgs, MetricPolicy +from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy +from ai.backend.common.resilience.resilience import Resilience +from ai.backend.manager.data.app_config.types import AppConfigSearchResult +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigFragmentKey, + AppConfigFragmentSearchResult, +) +from ai.backend.manager.errors.app_config import AppConfigFragmentNotFound +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.repositories.app_config_fragment.creators import ( + AppConfigFragmentCreatorSpec, +) +from ai.backend.manager.repositories.app_config_fragment.db_source import ( + AppConfigFragmentDBSource, +) +from ai.backend.manager.repositories.app_config_fragment.updaters import ( + AppConfigFragmentUpdaterSpec, +) +from ai.backend.manager.repositories.base.creator import Creator +from ai.backend.manager.repositories.base.purger import Purger +from ai.backend.manager.repositories.base.querier import BatchQuerier +from ai.backend.manager.repositories.base.updater import Updater + +app_config_fragment_admin_repository_resilience = Resilience( + policies=[ + MetricPolicy( + MetricArgs( + domain=DomainType.REPOSITORY, + layer=LayerType.APP_CONFIG_FRAGMENT_ADMIN_REPOSITORY, + ) + ), + RetryPolicy( + RetryArgs( + max_retries=5, + retry_delay=0.1, + backoff_strategy=BackoffStrategy.FIXED, + non_retryable_exceptions=(BackendAIError,), + ) + ), + ] +) + + +def _missing(key: AppConfigFragmentKey) -> AppConfigFragmentNotFound: + return AppConfigFragmentNotFound( + extra_msg=( + f"scope_type={key.scope_type.value!r}, scope_id={key.scope_id!r}, name={key.name!r}" + ), + ) + + +class AppConfigFragmentAdminRepository: + """Admin-only operations on AppConfigFragment. + + All mutations (`create` / `update` / `purge`) and cross-scope + reads (`admin_search` raw, `admin_search_app_configs` merged) + live here — read-side scope-bound operations are on + `AppConfigFragmentRepository`. Authorization is enforced at the + service layer before reaching either repository. The required- + policy invariant is enforced upstream by the service layer; the + DB-level FK on ``app_config_fragments.name`` is the defense-in- + depth backstop and surfaces as a generic integrity error. + + Mutations are routed through the shared Creator / Updater / Purger + helpers so the same execution / resilience plumbing applies as in + sister repositories. + """ + + _db_source: AppConfigFragmentDBSource + + def __init__(self, db: ExtendedAsyncSAEngine) -> None: + self._db_source = AppConfigFragmentDBSource(db) + + # ── Mutations ───────────────────────────────────────────────── + + @app_config_fragment_admin_repository_resilience.apply() + async def create( + self, + key: AppConfigFragmentKey, + config: Mapping[str, Any], + ) -> AppConfigFragmentData: + creator: Creator[AppConfigFragmentRow] = Creator( + spec=AppConfigFragmentCreatorSpec( + scope_type=key.scope_type, + scope_id=key.scope_id, + name=key.name, + config=config, + ), + ) + return await self._db_source.create(creator) + + @app_config_fragment_admin_repository_resilience.apply() + async def update( + self, + key: AppConfigFragmentKey, + config: Mapping[str, Any], + ) -> AppConfigFragmentData: + """Update a fragment by natural key. Resolves the natural key + to the row's UUID, builds an ``Updater``, and delegates to the + DB source. Raises :class:`AppConfigFragmentNotFound` when the + row is missing (or vanishes between resolve and write).""" + pk_value = await self._db_source.resolve_pk_by_key(key) + if pk_value is None: + raise _missing(key) + updater: Updater[AppConfigFragmentRow] = Updater( + spec=AppConfigFragmentUpdaterSpec(config=config), + pk_value=pk_value, + ) + result = await self._db_source.update(updater) + if result is None: + raise _missing(key) + return result + + @app_config_fragment_admin_repository_resilience.apply() + async def purge(self, key: AppConfigFragmentKey) -> bool: + """Delete a fragment by natural key. Resolves the natural key, + builds a ``Purger``, and delegates to the DB source. Returns + ``True`` only when a row was actually removed.""" + pk_value = await self._db_source.resolve_pk_by_key(key) + if pk_value is None: + return False + purger: Purger[AppConfigFragmentRow] = Purger( + row_class=AppConfigFragmentRow, + pk_value=pk_value, + ) + return await self._db_source.purge(purger) + + # ── Cross-scope reads ──────────────────────────────────────── + + @app_config_fragment_admin_repository_resilience.apply() + async def admin_search( + self, + querier: BatchQuerier, + ) -> AppConfigFragmentSearchResult: + return await self._db_source.admin_search(querier) + + @app_config_fragment_admin_repository_resilience.apply() + async def admin_search_app_configs( + self, + querier: BatchQuerier, + ) -> AppConfigSearchResult: + return await self._db_source.admin_search_app_configs(querier) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/creators.py b/src/ai/backend/manager/repositories/app_config_fragment/creators.py new file mode 100644 index 00000000000..a7f30ae19cb --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/creators.py @@ -0,0 +1,53 @@ +"""CreatorSpec for AppConfigFragment rows.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, override + +from ai.backend.manager.errors.app_config import AppConfigFragmentConflict +from ai.backend.manager.errors.repository import UniqueConstraintViolationError +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.base.creator import CreatorSpec +from ai.backend.manager.repositories.base.types import IntegrityErrorCheck + + +@dataclass +class AppConfigFragmentCreatorSpec(CreatorSpec[AppConfigFragmentRow]): + """CreatorSpec for `app_config_fragments`. + + Maps the natural-key UNIQUE violation to a typed domain error + (:class:`AppConfigFragmentConflict`). The required-policy + invariant (FK on ``name``) is enforced upstream by the service + layer; the DB-level FK violation surfaces as a generic + integrity error here. + """ + + scope_type: str + scope_id: str + name: str + config: Mapping[str, Any] + + @property + @override + def integrity_error_checks(self) -> Sequence[IntegrityErrorCheck]: + return ( + IntegrityErrorCheck( + violation_type=UniqueConstraintViolationError, + error=AppConfigFragmentConflict( + extra_msg=( + f"Duplicate fragment for ({self.scope_type}, {self.scope_id}, {self.name})" + ), + ), + ), + ) + + @override + def build_row(self) -> AppConfigFragmentRow: + return AppConfigFragmentRow( + scope_type=self.scope_type, + scope_id=self.scope_id, + name=self.name, + config=dict(self.config), + ) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/__init__.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/__init__.py new file mode 100644 index 00000000000..87f5dbe0afd --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/__init__.py @@ -0,0 +1,3 @@ +from .db_source import AppConfigFragmentDBSource + +__all__ = ("AppConfigFragmentDBSource",) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py new file mode 100644 index 00000000000..8c0bcbebc3e --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa + +from ai.backend.common.exception import BackendAIError +from ai.backend.common.metrics.metric import DomainType, LayerType +from ai.backend.common.resilience.policies.metrics import MetricArgs, MetricPolicy +from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy +from ai.backend.common.resilience.resilience import Resilience +from ai.backend.common.utils import deep_merge +from ai.backend.manager.data.app_config.types import AppConfigData, AppConfigSearchResult +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigFragmentKey, + AppConfigFragmentSearchResult, + AppConfigScopeType, +) +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.models.app_config_policy.row import AppConfigPolicyRow +from ai.backend.manager.models.user import UserRow +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigFragmentSearchScope, + UserAppConfigSearchScope, +) +from ai.backend.manager.repositories.base.creator import Creator, execute_creator +from ai.backend.manager.repositories.base.purger import Purger, execute_purger +from ai.backend.manager.repositories.base.querier import BatchQuerier, execute_batch_querier +from ai.backend.manager.repositories.base.updater import Updater, execute_updater + +app_config_fragment_db_source_resilience = Resilience( + policies=[ + MetricPolicy( + MetricArgs( + domain=DomainType.DB_SOURCE, + layer=LayerType.APP_CONFIG_FRAGMENT_DB_SOURCE, + ) + ), + RetryPolicy( + RetryArgs( + max_retries=5, + retry_delay=0.1, + backoff_strategy=BackoffStrategy.FIXED, + non_retryable_exceptions=(BackendAIError,), + ) + ), + ] +) + + +@dataclass(frozen=True, slots=True) +class _MergedChain: + """Internal return type of `_merge_chain` — the ordered fragments + that contributed to the merge plus the deep-merged config (or + `None` when every contributing fragment is empty). + + Re-shaped into `AppConfigData` by callers that also know the + `(user_id, name)` they were resolving for. + """ + + fragments: list[AppConfigFragmentData] + config: Mapping[str, Any] | None + + +class AppConfigFragmentDBSource: + """Database operations for `app_config_fragments`. + + Two roles: + 1. Raw CRUD on `(scope_type, scope_id, name)` rows. + 2. Merge-side reads that resolve a user's `AppConfig` view by joining + `app_config_policies` to derive the chain. + """ + + _db: ExtendedAsyncSAEngine + + def __init__(self, db: ExtendedAsyncSAEngine) -> None: + self._db = db + + # ── Raw fragment CRUD ────────────────────────────────────────── + + @app_config_fragment_db_source_resilience.apply() + async def get(self, key: AppConfigFragmentKey) -> AppConfigFragmentData | None: + async with self._db.begin_readonly_session() as db_sess: + row = await db_sess.scalar( + sa.select(AppConfigFragmentRow).where( + AppConfigFragmentRow.scope_type == key.scope_type, + AppConfigFragmentRow.scope_id == key.scope_id, + AppConfigFragmentRow.name == key.name, + ) + ) + return row.to_data() if row is not None else None + + @app_config_fragment_db_source_resilience.apply() + async def get_by_id(self, id: uuid.UUID) -> AppConfigFragmentData | None: + async with self._db.begin_readonly_session() as db_sess: + row = await db_sess.scalar( + sa.select(AppConfigFragmentRow).where(AppConfigFragmentRow.id == id) + ) + return row.to_data() if row is not None else None + + @app_config_fragment_db_source_resilience.apply() + async def create(self, creator: Creator[AppConfigFragmentRow]) -> AppConfigFragmentData: + """Insert a new fragment via the shared Creator helper. + + The natural-key UNIQUE violation translates to + :class:`AppConfigFragmentConflict` via the spec's + ``integrity_error_checks``. The required-policy invariant + (FK on ``name``) is enforced upstream by the service layer; + a bypass here surfaces as a generic integrity error. + """ + async with self._db.begin_session() as db_sess: + result = await execute_creator(db_sess, creator) + return result.row.to_data() + + @app_config_fragment_db_source_resilience.apply() + async def resolve_pk_by_key( + self, + key: AppConfigFragmentKey, + ) -> uuid.UUID | None: + """Resolve the natural key ``(scope_type, scope_id, name)`` to + the row's UUID ``id``. Returns ``None`` when no row matches — + callers translate to a domain-appropriate response.""" + async with self._db.begin_readonly_session() as db_sess: + pk: uuid.UUID | None = await db_sess.scalar( + sa.select(AppConfigFragmentRow.id).where( + AppConfigFragmentRow.scope_type == key.scope_type, + AppConfigFragmentRow.scope_id == key.scope_id, + AppConfigFragmentRow.name == key.name, + ) + ) + return pk + + @app_config_fragment_db_source_resilience.apply() + async def update(self, updater: Updater[AppConfigFragmentRow]) -> AppConfigFragmentData | None: + """Apply a pre-built Updater. Returns ``None`` when the row + vanished between PK resolution and write; the caller maps this + to :class:`AppConfigFragmentNotFound`.""" + async with self._db.begin_session() as db_sess: + result = await execute_updater(db_sess, updater) + return result.row.to_data() if result is not None else None + + @app_config_fragment_db_source_resilience.apply() + async def purge(self, purger: Purger[AppConfigFragmentRow]) -> bool: + """Apply a pre-built Purger. Returns ``True`` when a row was + actually removed (``False`` if the row vanished concurrently).""" + async with self._db.begin_session() as db_sess: + result = await execute_purger(db_sess, purger) + return result is not None + + @app_config_fragment_db_source_resilience.apply() + async def search( + self, + scope: AppConfigFragmentSearchScope, + querier: BatchQuerier, + ) -> AppConfigFragmentSearchResult: + """Scope-bound search (per `(scope_type, scope_id)`).""" + async with self._db.begin_readonly_session() as db_sess: + query = sa.select(AppConfigFragmentRow) + result = await execute_batch_querier(db_sess, query, querier, scope=scope) + items = [row.AppConfigFragmentRow.to_data() for row in result.rows] + return AppConfigFragmentSearchResult( + items=items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + @app_config_fragment_db_source_resilience.apply() + async def admin_search( + self, + querier: BatchQuerier, + ) -> AppConfigFragmentSearchResult: + """Cross-scope admin search — no scope binding. Authorization + is enforced at the service layer before this is reached.""" + async with self._db.begin_readonly_session() as db_sess: + query = sa.select(AppConfigFragmentRow) + result = await execute_batch_querier(db_sess, query, querier) + items = [row.AppConfigFragmentRow.to_data() for row in result.rows] + return AppConfigFragmentSearchResult( + items=items, + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + + # ── Merged-view reads (AppConfig) ───────────────────────────── + + @staticmethod + def _merge_chain( + rows: Sequence[AppConfigFragmentRow], + chain: Sequence[str], + ) -> _MergedChain: + """Order `rows` by `chain` (low → high) and deep-merge their + `config` in that order. Empty result projects to `None`. + + Shared by the single-doc and search merge methods so both paths + produce the same shape. + """ + by_scope = {row.scope_type: row for row in rows} + ordered = [ + by_scope[AppConfigScopeType(s)] for s in chain if AppConfigScopeType(s) in by_scope + ] + merged: Mapping[str, Any] = {} + for row in ordered: + if row.config is None: + continue + merged = deep_merge(merged, row.config) + return _MergedChain( + fragments=[row.to_data() for row in ordered], + config=(merged or None), + ) + + @app_config_fragment_db_source_resilience.apply() + async def get_user_app_config( + self, + user_id: uuid.UUID, + config_name: str, + ) -> AppConfigData: + """Resolve a single AppConfig view for `(user_id, config_name)`. + + One SQL: resolves `domain_name` via a `users` subquery, joins + `app_config_policies` to derive the chain (`scope_sources`), and + fetches only the scope rows that participate in that chain. The + natural-key UniqueConstraint bounds the result. + """ + user_domain_sq = ( + sa.select(UserRow.domain_name).where(UserRow.uuid == user_id).scalar_subquery() + ) + scope_id_match = sa.case( + ( + AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC, + sa.literal("public"), + ), + ( + AppConfigFragmentRow.scope_type.in_([ + AppConfigScopeType.DOMAIN, + AppConfigScopeType.DOMAIN_USER_DEFAULTS, + ]), + user_domain_sq, + ), + ( + AppConfigFragmentRow.scope_type == AppConfigScopeType.USER, + sa.literal(str(user_id)), + ), + ) + query = ( + sa.select(AppConfigFragmentRow, AppConfigPolicyRow.scope_sources) + .join( + AppConfigPolicyRow, + AppConfigPolicyRow.config_name == AppConfigFragmentRow.name, + ) + .where( + AppConfigFragmentRow.name == config_name, + AppConfigFragmentRow.scope_id == scope_id_match, + sa.cast(AppConfigFragmentRow.scope_type, sa.Text) + == sa.func.any(AppConfigPolicyRow.scope_sources), + ) + ) + async with self._db.begin_readonly_session() as db_sess: + result = (await db_sess.execute(query)).all() + + if not result: + return AppConfigData( + user_id=user_id, + name=config_name, + fragments=[], + config=None, + ) + + # `config_name` is UNIQUE and we filtered on a single value, so + # every result row carries the same `scope_sources`. + chain = result[0].scope_sources + rows = [r.AppConfigFragmentRow for r in result] + merged = self._merge_chain(rows, chain) + return AppConfigData( + user_id=user_id, + name=config_name, + fragments=merged.fragments, + config=merged.config, + ) + + @app_config_fragment_db_source_resilience.apply() + async def search_user_app_configs( + self, + scope: UserAppConfigSearchScope, + querier: BatchQuerier, + ) -> AppConfigSearchResult: + """Connection counterpart of `get_user_app_config`. Joins + `app_config_policies` to derive each `name`'s chain, then + groups results by `name` and feeds each group through + `_merge_chain` to produce one `AppConfigData`. + + Pagination is applied in-memory at the distinct-`name` level + because the natural unit (`AppConfigData`) does not map 1:1 + to a row. The GQL Connection layer that calls this method is + responsible for wrapping the result in the appropriate cursor + / offset envelope. + """ + user_id = scope.user_id + user_domain_sq = ( + sa.select(UserRow.domain_name).where(UserRow.uuid == user_id).scalar_subquery() + ) + scope_id_match = sa.case( + ( + AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC, + sa.literal("public"), + ), + ( + AppConfigFragmentRow.scope_type.in_([ + AppConfigScopeType.DOMAIN, + AppConfigScopeType.DOMAIN_USER_DEFAULTS, + ]), + user_domain_sq, + ), + ( + AppConfigFragmentRow.scope_type == AppConfigScopeType.USER, + sa.literal(str(user_id)), + ), + ) + query = ( + sa.select(AppConfigFragmentRow, AppConfigPolicyRow.scope_sources) + .join( + AppConfigPolicyRow, + AppConfigPolicyRow.config_name == AppConfigFragmentRow.name, + ) + .where( + AppConfigFragmentRow.scope_id == scope_id_match, + sa.cast(AppConfigFragmentRow.scope_type, sa.Text) + == sa.func.any(AppConfigPolicyRow.scope_sources), + ) + .order_by(AppConfigFragmentRow.name) + ) + async with self._db.begin_readonly_session() as db_sess: + result_rows = (await db_sess.execute(query)).all() + + groups: dict[str, tuple[Sequence[str], list[AppConfigFragmentRow]]] = {} + for row in result_rows: + fragment_row = row.AppConfigFragmentRow + chain = row.scope_sources + entry = groups.setdefault(fragment_row.name, (chain, [])) + entry[1].append(fragment_row) + + items: list[AppConfigData] = [] + for name, (chain, rows) in groups.items(): + merged = self._merge_chain(rows, chain) + items.append( + AppConfigData( + user_id=user_id, + name=name, + fragments=merged.fragments, + config=merged.config, + ) + ) + + total = len(items) + return AppConfigSearchResult( + items=items, + total_count=total, + has_next_page=False, + has_previous_page=False, + ) + + @app_config_fragment_db_source_resilience.apply() + async def admin_search_app_configs( + self, + querier: BatchQuerier, + ) -> AppConfigSearchResult: + """Cross-user merged search (admin only). Joins `users` so + each `(user_id, name)` combination is produced; results are + grouped and merged the same way as `search_user_app_configs`. + + As with the per-user variant, pagination is applied at the + merged-group level rather than at the row level — the GQL + Connection that wraps this is responsible for envelope + construction. + """ + scope_id_match = sa.case( + ( + AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC, + sa.literal("public"), + ), + ( + AppConfigFragmentRow.scope_type.in_([ + AppConfigScopeType.DOMAIN, + AppConfigScopeType.DOMAIN_USER_DEFAULTS, + ]), + UserRow.domain_name, + ), + ( + AppConfigFragmentRow.scope_type == AppConfigScopeType.USER, + sa.cast(UserRow.uuid, sa.Text), + ), + ) + query = ( + sa.select( + UserRow.uuid.label("user_id"), + AppConfigFragmentRow, + AppConfigPolicyRow.scope_sources, + ) + .select_from(UserRow) + .join( + AppConfigFragmentRow, + AppConfigFragmentRow.scope_id == scope_id_match, + ) + .join( + AppConfigPolicyRow, + AppConfigPolicyRow.config_name == AppConfigFragmentRow.name, + ) + .where( + sa.cast(AppConfigFragmentRow.scope_type, sa.Text) + == sa.func.any(AppConfigPolicyRow.scope_sources), + ) + .order_by(UserRow.uuid, AppConfigFragmentRow.name) + ) + async with self._db.begin_readonly_session() as db_sess: + result_rows = (await db_sess.execute(query)).all() + + groups: dict[tuple[uuid.UUID, str], tuple[Sequence[str], list[AppConfigFragmentRow]]] = {} + for row in result_rows: + fragment_row = row.AppConfigFragmentRow + chain = row.scope_sources + entry = groups.setdefault((row.user_id, fragment_row.name), (chain, [])) + entry[1].append(fragment_row) + + items: list[AppConfigData] = [] + for (user_id, name), (chain, rows) in groups.items(): + merged = self._merge_chain(rows, chain) + items.append( + AppConfigData( + user_id=user_id, + name=name, + fragments=merged.fragments, + config=merged.config, + ) + ) + + total = len(items) + return AppConfigSearchResult( + items=items, + total_count=total, + has_next_page=False, + has_previous_page=False, + ) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/repositories.py b/src/ai/backend/manager/repositories/app_config_fragment/repositories.py new file mode 100644 index 00000000000..aebd3726082 --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/repositories.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Self + +from ai.backend.manager.repositories.app_config_fragment.admin_repository import ( + AppConfigFragmentAdminRepository, +) +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.types import RepositoryArgs + + +@dataclass +class AppConfigFragmentRepositories: + repository: AppConfigFragmentRepository + admin_repository: AppConfigFragmentAdminRepository + + @classmethod + def create(cls, args: RepositoryArgs) -> Self: + return cls( + repository=AppConfigFragmentRepository(args.db), + admin_repository=AppConfigFragmentAdminRepository(args.db), + ) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/repository.py b/src/ai/backend/manager/repositories/app_config_fragment/repository.py new file mode 100644 index 00000000000..9fc06cce8eb --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/repository.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import uuid + +from ai.backend.common.exception import BackendAIError +from ai.backend.common.metrics.metric import DomainType, LayerType +from ai.backend.common.resilience.policies.metrics import MetricArgs, MetricPolicy +from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy +from ai.backend.common.resilience.resilience import Resilience +from ai.backend.manager.data.app_config.types import AppConfigData, AppConfigSearchResult +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigFragmentKey, + AppConfigFragmentSearchResult, +) +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.repositories.app_config_fragment.db_source import ( + AppConfigFragmentDBSource, +) +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigFragmentSearchScope, + UserAppConfigSearchScope, +) +from ai.backend.manager.repositories.base.querier import BatchQuerier + +app_config_fragment_repository_resilience = Resilience( + policies=[ + MetricPolicy( + MetricArgs( + domain=DomainType.REPOSITORY, + layer=LayerType.APP_CONFIG_FRAGMENT_REPOSITORY, + ) + ), + RetryPolicy( + RetryArgs( + max_retries=5, + retry_delay=0.1, + backoff_strategy=BackoffStrategy.FIXED, + non_retryable_exceptions=(BackendAIError,), + ) + ), + ] +) + + +class AppConfigFragmentRepository: + """Read-side repository for AppConfigFragment. + + Scope-bound reads on raw fragments plus the per-user merged + `AppConfig` view. Mutations and admin cross-scope reads live on + `AppConfigFragmentAdminRepository`. + """ + + _db_source: AppConfigFragmentDBSource + + def __init__(self, db: ExtendedAsyncSAEngine) -> None: + self._db_source = AppConfigFragmentDBSource(db) + + # ── Raw fragment reads ──────────────────────────────────────── + + @app_config_fragment_repository_resilience.apply() + async def get(self, key: AppConfigFragmentKey) -> AppConfigFragmentData | None: + return await self._db_source.get(key) + + @app_config_fragment_repository_resilience.apply() + async def get_by_id(self, id: uuid.UUID) -> AppConfigFragmentData | None: + return await self._db_source.get_by_id(id) + + @app_config_fragment_repository_resilience.apply() + async def search( + self, + scope: AppConfigFragmentSearchScope, + querier: BatchQuerier, + ) -> AppConfigFragmentSearchResult: + return await self._db_source.search(scope, querier) + + # ── Merged view (AppConfig) ─────────────────────────────────── + + @app_config_fragment_repository_resilience.apply() + async def app_config( + self, + user_id: uuid.UUID, + config_name: str, + ) -> AppConfigData: + return await self._db_source.get_user_app_config(user_id, config_name) + + @app_config_fragment_repository_resilience.apply() + async def search_app_configs( + self, + scope: UserAppConfigSearchScope, + querier: BatchQuerier, + ) -> AppConfigSearchResult: + return await self._db_source.search_user_app_configs(scope, querier) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/types.py b/src/ai/backend/manager/repositories/app_config_fragment/types.py new file mode 100644 index 00000000000..66f8143c9fb --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/types.py @@ -0,0 +1,60 @@ +"""SearchScope types for app-config-fragment repository operations.""" + +from __future__ import annotations + +import uuid +from collections.abc import Sequence +from dataclasses import dataclass + +import sqlalchemy as sa + +from ai.backend.manager.data.app_config_fragment.types import AppConfigScopeType +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.base import ExistenceCheck, QueryCondition, SearchScope + + +@dataclass(frozen=True) +class AppConfigFragmentSearchScope(SearchScope): + """Pin search to a single `(scope_type, scope_id)` slice of the table.""" + + scope_type: AppConfigScopeType + scope_id: str + + def to_condition(self) -> QueryCondition: + scope_type = self.scope_type + scope_id = self.scope_id + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.scope_type == scope_type, + AppConfigFragmentRow.scope_id == scope_id, + ) + + return inner + + @property + def existence_checks(self) -> Sequence[ExistenceCheck[str]]: + # Scope existence (domain / user) is validated upstream by RBAC. + return [] + + +@dataclass(frozen=True) +class UserAppConfigSearchScope(SearchScope): + """Pin merged-view search to a target `user_id`.""" + + user_id: uuid.UUID + + def to_condition(self) -> QueryCondition: + # Merge search joins multiple scope rows per user; the per-user + # restriction is applied by the merge-specific SQL builder rather + # than this generic predicate. Returning a `True` condition keeps + # this scope BatchQuerier-compatible without double-filtering. + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.true() + + return inner + + @property + def existence_checks(self) -> Sequence[ExistenceCheck[uuid.UUID]]: + # User existence is guaranteed by RBAC authentication upstream. + return [] diff --git a/src/ai/backend/manager/repositories/app_config_fragment/updaters.py b/src/ai/backend/manager/repositories/app_config_fragment/updaters.py new file mode 100644 index 00000000000..a20fb5d2ca7 --- /dev/null +++ b/src/ai/backend/manager/repositories/app_config_fragment/updaters.py @@ -0,0 +1,31 @@ +"""UpdaterSpec for AppConfigFragment rows.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, override + +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.base.updater import UpdaterSpec + + +@dataclass +class AppConfigFragmentUpdaterSpec(UpdaterSpec[AppConfigFragmentRow]): + """UpdaterSpec for `app_config_fragments`. + + Only `config` is mutable — the ``(scope_type, scope_id, name)`` + natural key is fixed; changing any of those is a new row, not an + update. + """ + + config: Mapping[str, Any] + + @property + @override + def row_class(self) -> type[AppConfigFragmentRow]: + return AppConfigFragmentRow + + @override + def build_values(self) -> dict[str, Any]: + return {"config": dict(self.config)} diff --git a/src/ai/backend/manager/repositories/repositories.py b/src/ai/backend/manager/repositories/repositories.py index 14c9aeed4ec..6b2eec24509 100644 --- a/src/ai/backend/manager/repositories/repositories.py +++ b/src/ai/backend/manager/repositories/repositories.py @@ -2,6 +2,9 @@ from typing import Self from ai.backend.manager.repositories.agent.repositories import AgentRepositories +from ai.backend.manager.repositories.app_config_fragment.repositories import ( + AppConfigFragmentRepositories, +) from ai.backend.manager.repositories.app_config_policy.repositories import ( AppConfigPolicyRepositories, ) @@ -86,6 +89,7 @@ @dataclass class Repositories: agent: AgentRepositories + app_config_fragment: AppConfigFragmentRepositories app_config_policy: AppConfigPolicyRepositories auth: AuthRepositories container_registry: ContainerRegistryRepositories @@ -136,6 +140,7 @@ class Repositories: @classmethod def create(cls, args: RepositoryArgs) -> Self: agent_repositories = AgentRepositories.create(args) + app_config_fragment_repositories = AppConfigFragmentRepositories.create(args) app_config_policy_repositories = AppConfigPolicyRepositories.create(args) auth_repositories = AuthRepositories.create(args) container_registry_repositories = ContainerRegistryRepositories.create(args) @@ -187,6 +192,7 @@ def create(cls, args: RepositoryArgs) -> Self: return cls( agent=agent_repositories, + app_config_fragment=app_config_fragment_repositories, app_config_policy=app_config_policy_repositories, auth=auth_repositories, container_registry=container_registry_repositories, diff --git a/tests/unit/manager/repositories/app_config_fragment/BUILD b/tests/unit/manager/repositories/app_config_fragment/BUILD new file mode 100644 index 00000000000..dabf212d7e7 --- /dev/null +++ b/tests/unit/manager/repositories/app_config_fragment/BUILD @@ -0,0 +1 @@ +python_tests() diff --git a/tests/unit/manager/repositories/app_config_fragment/__init__.py b/tests/unit/manager/repositories/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/manager/repositories/app_config_fragment/test_app_config_fragment.py b/tests/unit/manager/repositories/app_config_fragment/test_app_config_fragment.py new file mode 100644 index 00000000000..d103fbe3069 --- /dev/null +++ b/tests/unit/manager/repositories/app_config_fragment/test_app_config_fragment.py @@ -0,0 +1,233 @@ +"""Repository tests for AppConfigFragment with real database.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +import pytest + +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentKey, + AppConfigScopeType, +) +from ai.backend.manager.errors.app_config import AppConfigFragmentNotFound +from ai.backend.manager.errors.repository import ForeignKeyViolationError +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.models.app_config_policy.row import AppConfigPolicyRow +from ai.backend.manager.models.utils import ExtendedAsyncSAEngine +from ai.backend.manager.repositories.app_config_fragment.admin_repository import ( + AppConfigFragmentAdminRepository, +) +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.app_config_policy.admin_repository import ( + AppConfigPolicyAdminRepository, +) +from ai.backend.testutils.db import with_tables + + +class TestAppConfigFragmentRepository: + """Read-side tests for AppConfigFragmentRepository.""" + + @pytest.fixture + async def db( + self, + database_connection: ExtendedAsyncSAEngine, + ) -> AsyncGenerator[ExtendedAsyncSAEngine, None]: + async with with_tables( + database_connection, + [ + AppConfigPolicyRow, # Parent (FK target) + AppConfigFragmentRow, + ], + ): + yield database_connection + + @pytest.fixture + def repository(self, db: ExtendedAsyncSAEngine) -> AppConfigFragmentRepository: + return AppConfigFragmentRepository(db) + + @pytest.fixture + def admin_repository(self, db: ExtendedAsyncSAEngine) -> AppConfigFragmentAdminRepository: + return AppConfigFragmentAdminRepository(db) + + @pytest.fixture + async def policy_for_theme(self, db: ExtendedAsyncSAEngine) -> AsyncGenerator[None, None]: + # Required-policy invariant: a policy must exist before any + # fragment can reference its `name`. + await AppConfigPolicyAdminRepository(db).create( + config_name="theme", + scope_sources=["domain"], + ) + yield + + async def test_create_and_get_by_key( + self, + policy_for_theme: None, + repository: AppConfigFragmentRepository, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + key = AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="default", + name="theme", + ) + created = await admin_repository.create( + key=key, + config={"color": "blue"}, + ) + assert created.scope_type == AppConfigScopeType.DOMAIN + assert created.scope_id == "default" + assert created.name == "theme" + assert created.config is not None + assert dict(created.config) == {"color": "blue"} + + fetched = await repository.get(key) + assert fetched is not None + assert fetched.id == created.id + assert fetched.config is not None + assert dict(fetched.config) == {"color": "blue"} + + async def test_get_by_id( + self, + policy_for_theme: None, + repository: AppConfigFragmentRepository, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + created = await admin_repository.create( + key=AppConfigFragmentKey( + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + name="theme", + ), + config={"density": "comfortable"}, + ) + + fetched = await repository.get_by_id(created.id) + assert fetched is not None + assert fetched.scope_type == AppConfigScopeType.PUBLIC + assert fetched.config is not None + assert dict(fetched.config) == {"density": "comfortable"} + + async def test_get_returns_none_for_missing_key( + self, + repository: AppConfigFragmentRepository, + ) -> None: + assert ( + await repository.get( + AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="missing", + name="theme", + ) + ) + is None + ) + + +class TestAppConfigFragmentAdminRepository: + """Mutation-side tests for AppConfigFragmentAdminRepository.""" + + @pytest.fixture + async def db( + self, + database_connection: ExtendedAsyncSAEngine, + ) -> AsyncGenerator[ExtendedAsyncSAEngine, None]: + async with with_tables( + database_connection, + [ + AppConfigPolicyRow, + AppConfigFragmentRow, + ], + ): + yield database_connection + + @pytest.fixture + def admin_repository(self, db: ExtendedAsyncSAEngine) -> AppConfigFragmentAdminRepository: + return AppConfigFragmentAdminRepository(db) + + @pytest.fixture + async def policy_for_theme(self, db: ExtendedAsyncSAEngine) -> AsyncGenerator[None, None]: + await AppConfigPolicyAdminRepository(db).create( + config_name="theme", + scope_sources=["domain"], + ) + yield + + async def test_update_replaces_config( + self, + policy_for_theme: None, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + key = AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="default", + name="theme", + ) + await admin_repository.create(key=key, config={"color": "blue"}) + updated = await admin_repository.update( + key=key, config={"color": "red", "density": "compact"} + ) + assert updated is not None + assert updated.config is not None + assert dict(updated.config) == {"color": "red", "density": "compact"} + + async def test_update_raises_for_missing_key( + self, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + with pytest.raises(AppConfigFragmentNotFound): + await admin_repository.update( + key=AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="missing", + name="theme", + ), + config={"color": "blue"}, + ) + + async def test_purge_existing_fragment_returns_true( + self, + policy_for_theme: None, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + key = AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="default", + name="theme", + ) + await admin_repository.create(key=key, config={}) + assert await admin_repository.purge(key) is True + + async def test_purge_missing_fragment_returns_false( + self, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + assert ( + await admin_repository.purge( + AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="missing", + name="theme", + ) + ) + is False + ) + + async def test_create_without_matching_policy_violates_fk( + self, + admin_repository: AppConfigFragmentAdminRepository, + ) -> None: + # Service-layer rejects missing policies up front; the DB FK + # is the defense-in-depth backstop and surfaces as a generic + # ForeignKeyViolationError. + with pytest.raises(ForeignKeyViolationError): + await admin_repository.create( + key=AppConfigFragmentKey( + scope_type=AppConfigScopeType.DOMAIN, + scope_id="default", + name="no-such-policy", + ), + config={"color": "blue"}, + )