Skip to content

Commit 07f7480

Browse files
jopemachineclaude
andcommitted
feat(BA-6555): add app_config merge engine (deep-merge across visible fragments)
Adds the read-side AppConfigService.resolve: fetches the fragments visible to a (domain, user) principal for one config_name (list_visible_fragments, via the by_*_visibility conditions) and deep-merges them in rank order; AppConfigResolveScope bundles the principal. Rebased onto the domain/user-keyed scoped_search (BA-6554): ConfigNameSearchScope is gone, so the merged read relies on the visibility conditions. Squash of the BA-6555 development history, re-based onto the updated BA-6554. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab11cd9 commit 07f7480

17 files changed

Lines changed: 471 additions & 1 deletion

File tree

changes/12359.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the app_config service layer: resolve a user's merged AppConfig for a config name by rank-ordering the applicable public / domain / user fragments and deep-merging them.

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

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData
7+
8+
9+
@dataclass(frozen=True)
10+
class AppConfigData:
11+
"""Merged per-user view of one ``config_name`` (BEP-1052).
12+
13+
The ordered contributing ``fragments`` (rank low -> high) plus their deep-merged
14+
``config``. ``config`` is ``None`` when the merge of every fragment is empty.
15+
"""
16+
17+
config_name: str
18+
fragments: list[AppConfigFragmentData]
19+
config: dict[str, Any] | None

src/ai/backend/manager/models/app_config_fragment/conditions.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,46 @@ def inner() -> sa.sql.expression.ColumnElement[bool]:
111111

112112
return inner
113113

114+
# --- per-scope visibility filters (one scope_type each) for a config_name ---
115+
116+
@staticmethod
117+
def by_public_visibility(config_name: str) -> QueryCondition:
118+
"""The ``public`` fragment of ``config_name`` (public has no per-entity scope_id)."""
119+
120+
def inner() -> sa.sql.expression.ColumnElement[bool]:
121+
return sa.and_(
122+
AppConfigFragmentRow.config_name == config_name,
123+
AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC,
124+
)
125+
126+
return inner
127+
128+
@staticmethod
129+
def by_domain_visibility(config_name: str, domain: str) -> QueryCondition:
130+
"""The ``domain`` fragment of ``config_name`` for ``domain``."""
131+
132+
def inner() -> sa.sql.expression.ColumnElement[bool]:
133+
return sa.and_(
134+
AppConfigFragmentRow.config_name == config_name,
135+
AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN,
136+
AppConfigFragmentRow.scope_id == domain,
137+
)
138+
139+
return inner
140+
141+
@staticmethod
142+
def by_user_visibility(config_name: str, user_id: str) -> QueryCondition:
143+
"""The ``user`` fragment of ``config_name`` for ``user_id``."""
144+
145+
def inner() -> sa.sql.expression.ColumnElement[bool]:
146+
return sa.and_(
147+
AppConfigFragmentRow.config_name == config_name,
148+
AppConfigFragmentRow.scope_type == AppConfigScopeType.USER,
149+
AppConfigFragmentRow.scope_id == user_id,
150+
)
151+
152+
return inner
153+
114154
# --- created_at / updated_at datetime filters ---
115155

116156
@staticmethod

src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,23 @@
2323
AppConfigFragmentWriteNotAllowed,
2424
)
2525
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
26+
from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions
2627
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
2728
from ai.backend.manager.models.scopes import SearchScope
2829
from ai.backend.manager.repositories.app_config_fragment.creators import (
2930
AppConfigFragmentCreatorSpec,
3031
)
32+
from ai.backend.manager.repositories.app_config_fragment.types import (
33+
AppConfigResolveScope,
34+
)
3135
from ai.backend.manager.repositories.base import (
3236
BatchQuerier,
3337
BulkConditionalCreator,
3438
BulkConditionalPurger,
3539
BulkConditionalUpdater,
3640
Creator,
3741
ExistsQuerier,
42+
NoPagination,
3843
Purger,
3944
Querier,
4045
Updater,
@@ -245,3 +250,26 @@ async def scoped_search(
245250
has_next_page=result.has_next_page,
246251
has_previous_page=result.has_previous_page,
247252
)
253+
254+
@app_config_fragment_db_source_resilience.apply()
255+
async def list_visible_fragments(
256+
self, config_name: str, scope: AppConfigResolveScope
257+
) -> list[AppConfigFragmentData]:
258+
"""Fragments visible to ``scope`` for ``config_name``, ``rank``-ordered, in one query.
259+
260+
Filters on the fragment's own ``scope_type``/``scope_id`` columns: the public OR the
261+
scope's domain OR the scope's user fragment. Each ``(config_name, scope_type,
262+
scope_id)`` is unique, so the result is bounded (one per scope_type), ready to
263+
deep-merge in order.
264+
"""
265+
public = AppConfigFragmentConditions.by_public_visibility(config_name)
266+
domain = AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id))
267+
user = AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id))
268+
querier = BatchQuerier(
269+
pagination=NoPagination(),
270+
conditions=[lambda: sa.or_(public(), domain(), user())],
271+
orders=[AppConfigFragmentRow.rank.asc()],
272+
)
273+
async with self._ops.read_ops() as r:
274+
result = await r.batch_query_in_global(sa.select(AppConfigFragmentRow), querier)
275+
return [row.AppConfigFragmentRow.to_data() for row in result.rows]

src/ai/backend/manager/repositories/app_config_fragment/repository.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
from ai.backend.manager.repositories.app_config_fragment.db_source import (
2323
AppConfigFragmentDBSource,
2424
)
25+
from ai.backend.manager.repositories.app_config_fragment.types import (
26+
AppConfigResolveScope,
27+
)
2528
from ai.backend.manager.repositories.base import (
2629
BatchQuerier,
2730
BulkConditionalCreator,
@@ -121,3 +124,9 @@ async def bulk_purge(
121124
bulk: BulkConditionalPurger[AppConfigFragmentRow, AppConfigAllowListRow],
122125
) -> AppConfigFragmentBulkWriteResult:
123126
return await self._db_source.bulk_purge(bulk)
127+
128+
@app_config_fragment_repository_resilience.apply()
129+
async def list_visible_fragments(
130+
self, config_name: str, scope: AppConfigResolveScope
131+
) -> list[AppConfigFragmentData]:
132+
return await self._db_source.list_visible_fragments(config_name, scope)

src/ai/backend/manager/repositories/app_config_fragment/types.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Types for app config fragment repository operations (search scopes, gated writes)."""
1+
"""Types for app config fragment repository operations (search scopes, resolve scope)."""
22

33
from __future__ import annotations
44

@@ -16,11 +16,25 @@
1616
from ai.backend.manager.models.scopes import ExistenceCheck, SearchScope
1717

1818
__all__ = (
19+
"AppConfigResolveScope",
1920
"DomainAppConfigFragmentSearchScope",
2021
"UserAppConfigFragmentSearchScope",
2122
)
2223

2324

25+
@dataclass(frozen=True)
26+
class AppConfigResolveScope:
27+
"""The principal an ``AppConfig`` is resolved for: the resolving user and its domain.
28+
29+
Bundles the scope-identifying arguments so they travel together (add new principal
30+
dimensions here rather than growing method signatures). Plain value object — not a
31+
:class:`SearchScope`.
32+
"""
33+
34+
domain_id: DomainID
35+
user_id: UserID
36+
37+
2438
@dataclass(frozen=True)
2539
class DomainAppConfigFragmentSearchScope(SearchScope):
2640
"""Fragments written at one domain scope (``scope_type == domain``, ``scope_id == domain_id``).

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

Whitespace-only changes.

src/ai/backend/manager/services/app_config/actions/__init__.py

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from __future__ import annotations
2+
3+
from typing import override
4+
5+
from ai.backend.common.data.permission.types import EntityType
6+
from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult
7+
8+
9+
class AppConfigScopeAction(BaseScopeAction):
10+
"""Base for scope-level merged app config actions (resolve)."""
11+
12+
@override
13+
@classmethod
14+
def entity_type(cls) -> EntityType:
15+
return EntityType.APP_CONFIG
16+
17+
18+
class AppConfigScopeActionResult(BaseScopeActionResult):
19+
pass

0 commit comments

Comments
 (0)