Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/11282.feature.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions src/ai/backend/common/metrics/metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Empty file.
33 changes: 33 additions & 0 deletions src/ai/backend/manager/data/app_config/types.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
53 changes: 53 additions & 0 deletions src/ai/backend/manager/data/app_config_fragment/types.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions src/ai/backend/manager/errors/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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")
8 changes: 8 additions & 0 deletions src/ai/backend/manager/models/app_config_fragment/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from ai.backend.manager.data.app_config_fragment.types import AppConfigScopeType

from .row import AppConfigFragmentRow

__all__ = (
"AppConfigFragmentRow",
"AppConfigScopeType",
)
82 changes: 82 additions & 0 deletions src/ai/backend/manager/models/app_config_fragment/row.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file.
Loading
Loading