Skip to content

Commit 30e7fb4

Browse files
jopemachineclaude
andauthored
feat(BA-6555): AppConfig merge engine + resolve service (service layer) (#12359)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a384005 commit 30e7fb4

21 files changed

Lines changed: 669 additions & 42 deletions

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 read service: resolve a user's merged AppConfig for one or many config names by rank-ordering the visible public / domain / user fragments and deep-merging them.

proposals/BEP-1052-scoped-app-config-redesign.md

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ each managed independently.
2121
`config_name` is the **deep merge of every fragment that applies to
2222
them**, taken from `app_config_fragments` ordered by the `rank` of each
2323
fragment's allow-list entry — one indexed join, **no permission
24-
lookup**.
24+
lookup**. Nor is one needed to keep callers apart: a read names only the
25+
domain, and the user half of the scope is injected from the session, so
26+
a caller has no way to ask for anyone else's config.
2527

2628
**Write authorization is set up ahead of time.** Every config name is
2729
**explicitly registered** in `app_config_definitions`, and `app_config_allow_list`
@@ -71,11 +73,14 @@ Three scopes cover the use cases (`public` for the pre-login shell):
7173
fragment requires an entry, and the entry requires a registered name — so
7274
no direct fragment FK is needed). No fragment may exist for an
7375
unregistered `config_name`.
74-
- **Reads are unconditional.** The merge **must** join
76+
- **Reads cost no permission query.** The merge **must** join
7577
`app_config_fragments` to `app_config_allow_list``rank` lives only on
7678
the allow-list entry, so the ordering cannot be computed without it (an
7779
indexed `(config_name, scope_type)` join). The join is for `rank` alone;
78-
no permission or policy is evaluated at read time.
80+
no RBAC or policy is evaluated at read time. Tenancy is structural rather
81+
than checked: the read takes a `domain_id` and nothing else, and the
82+
service fills the user half of the scope from the session, so there is no
83+
field in which to name another user.
7984
- **Allow-list = the write gate and the merge order.** `app_config_allow_list`
8085
holds **one record per `(config_name, scope_type)`**; a fragment at
8186
that scope may be created **only if** the record exists — through the
@@ -192,10 +197,11 @@ Two kinds of mutation:
192197

193198
`create` errors if the natural key already exists; `update` errors if it
194199
does not; `purge` removes the row (and thus its contribution to the
195-
merge). A caller "clears" a config without deleting it by `update`-ing
196-
with `{}`, which reads back as `null` (null projection, §3). `update`
197-
replaces the stored JSON wholesale — no partial/deep update at the write
198-
boundary.
200+
merge). `update` replaces the stored JSON wholesale — no partial/deep
201+
update at the write boundary. A caller "clears" a config without deleting
202+
it by `update`-ing with `{}`; the fragment stays and still counts as a
203+
contribution, so the merge succeeds and yields whatever the other
204+
fragments hold. Removing the contribution entirely is `purge`.
199205

200206
**Overridability is a write-grant decision:**
201207

@@ -260,18 +266,35 @@ those whose scope applies to them:
260266
- the user's `user` fragment (`scope_id = the user's id`).
261267

262268
A single `app_config_fragments` query selects exactly those rows (the
263-
user's domain is known from the session — no permission check), joins
269+
user's domain is known from the session — no RBAC lookup), joins
264270
each to its allow-list entry for the `rank`, orders by it (low → high),
265271
and deep-merges: nested objects recurse, scalars and lists are
266272
wholesale-replaced, and the higher `rank` wins on conflict.
267273

268-
**Null projection.** A stored `config` of `{}` reads back as `null`, and
269-
a merged `config` that is empty after combining every fragment is
270-
likewise `null` — clients fall back to their built-in defaults.
274+
**Nothing to merge is a 404.** A `config_name` no visible fragment
275+
contributes to raises `AppConfigFragmentNotFound` rather than resolving
276+
to an empty or null value — the name is unregistered, or nothing is
277+
visible at the caller's scopes. Clients fall back to their built-in
278+
defaults on the 404.
279+
280+
The merged `config` is therefore always an object, never null. It is
281+
empty only when every contributing fragment's own `config` was `{}`: the
282+
merge adds and replaces keys but never drops one, so no combination of
283+
non-empty fragments can reduce to `{}`.
271284

272285
### Read variants
273286

274-
- **Single** — resolve one `(user, config_name)` to its `AppConfig`.
287+
- **Resolve** — the only read. Takes a list of `config_name`s and
288+
returns one `AppConfig` per requested name, in request order; a
289+
repeated name is repeated in the output. A single name is a
290+
one-element request — there is no separate single-name variant, since
291+
a client bootstrapping its shell asks for several configs at once and
292+
two entry points would only differ in how they report a missing name.
293+
**All-or-nothing:** one requested name nothing contributes to fails
294+
the whole call. A partial result would have to mark the absent names
295+
somehow, and every way of doing that pushes the caller into branching
296+
on a second, quieter kind of failure. The cost is that a client cannot
297+
batch optional config names together with required ones.
275298
- **Search (self)** — paginate the user's own `AppConfig`s, grouped by
276299
`(user_id, config_name)`; each name's merge is evaluated
277300
independently.

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

Whitespace-only changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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``.
12+
13+
The contributing ``fragments`` (rank low -> high) plus their deep-merged ``merged_config``.
14+
At least one fragment always contributes, so an empty ``merged_config`` means the
15+
fragments themselves were empty — not that none were found.
16+
"""
17+
18+
config_name: str
19+
fragments: list[AppConfigFragmentData]
20+
merged_config: dict[str, Any]

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

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
AppConfigFragmentPurgerSpec,
3535
)
3636
from ai.backend.manager.repositories.app_config_fragment.types import (
37-
AppConfigScopeArguments,
37+
ResolvedAppConfigScope,
3838
)
3939
from ai.backend.manager.repositories.base import (
4040
BatchQuerier,
@@ -210,24 +210,22 @@ async def scoped_search(
210210

211211
@app_config_fragment_db_source_resilience.apply()
212212
async def list_visible_fragments_bulk(
213-
self, config_names: list[str], scope: AppConfigScopeArguments
213+
self, config_names: list[str], scope: ResolvedAppConfigScope | None = None
214214
) -> list[AppConfigFragmentData]:
215-
"""Visible fragments for several ``config_names`` at once, in a single query.
216-
217-
Selects the requested names AND any one of the principal's visible scopes (public,
218-
its domain, or its own user). The scope filter is name-independent, so it is a single
219-
OR group AND-combined with the name membership. Merge priority (``rank``) lives on the
220-
joined allow-list entry; the result is always ordered by ascending ``rank`` so the
221-
caller can group by name (each name's subset stays rank-ordered) and deep-merge each
222-
name's fragments in order.
215+
"""Visible fragments for several ``config_names`` in one query, ordered by ascending ``rank``.
216+
217+
``public`` always contributes; a ``scope`` additionally admits its domain and user
218+
overlay, while ``scope=None`` (anonymous) sees only ``public``. Rank-ordered so the
219+
caller can group by name and deep-merge each name's fragments in order.
223220
"""
224221
if not config_names:
225222
return []
226-
scope_visibility = [
227-
AppConfigFragmentConditions.by_public_visibility(),
228-
AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)),
229-
AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)),
230-
]
223+
scope_visibility = [AppConfigFragmentConditions.by_public_visibility()]
224+
if scope is not None:
225+
scope_visibility += [
226+
AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)),
227+
AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)),
228+
]
231229
querier = BatchQuerier(
232230
pagination=NoPagination(),
233231
conditions=[

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
AppConfigFragmentPurgerSpec,
2626
)
2727
from ai.backend.manager.repositories.app_config_fragment.types import (
28-
AppConfigScopeArguments,
28+
ResolvedAppConfigScope,
2929
)
3030
from ai.backend.manager.repositories.base import (
3131
BatchQuerier,
@@ -105,6 +105,6 @@ async def bulk_purge(
105105

106106
@app_config_fragment_repository_resilience.apply()
107107
async def list_visible_fragments_bulk(
108-
self, config_names: list[str], scope: AppConfigScopeArguments
108+
self, config_names: list[str], scope: ResolvedAppConfigScope | None = None
109109
) -> list[AppConfigFragmentData]:
110110
return await self._db_source.list_visible_fragments_bulk(config_names, scope)

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,27 @@
1717

1818
__all__ = (
1919
"AppConfigScopeArguments",
20+
"ResolvedAppConfigScope",
2021
"DomainAppConfigFragmentSearchScope",
2122
"UserAppConfigFragmentSearchScope",
2223
)
2324

2425

2526
@dataclass(frozen=True)
2627
class AppConfigScopeArguments:
28+
"""The scope arguments a caller supplies for a resolve — the domain, never the user.
29+
30+
Add new caller-supplied scope dimensions here rather than growing method signatures.
31+
"""
32+
33+
domain_id: DomainID
34+
35+
36+
@dataclass(frozen=True)
37+
class ResolvedAppConfigScope:
2738
"""The principal an ``AppConfig`` is resolved for: the resolving user and its domain.
2839
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
40+
:class:`AppConfigScopeArguments` plus the session user. Plain value object — not a
3141
:class:`SearchScope`.
3242
"""
3343

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from ai.backend.manager.repositories.app_config_definition.repositories import (
99
AppConfigDefinitionRepositories,
1010
)
11+
from ai.backend.manager.repositories.app_config_fragment.repositories import (
12+
AppConfigFragmentRepositories,
13+
)
1114
from ai.backend.manager.repositories.artifact.repositories import ArtifactRepositories
1215
from ai.backend.manager.repositories.artifact_registry.repositories import (
1316
ArtifactRegistryRepositories,
@@ -98,6 +101,7 @@ class Repositories:
98101
agent: AgentRepositories
99102
app_config_allow_list: AppConfigAllowListRepositories
100103
app_config_definition: AppConfigDefinitionRepositories
104+
app_config_fragment: AppConfigFragmentRepositories
101105
auth: AuthRepositories
102106
container_registry: ContainerRegistryRepositories
103107
deployment: DeploymentRepositories
@@ -154,6 +158,7 @@ def create(cls, args: RepositoryArgs) -> Self:
154158
agent_repositories = AgentRepositories.create(args)
155159
app_config_allow_list_repositories = AppConfigAllowListRepositories.create(args)
156160
app_config_definition_repositories = AppConfigDefinitionRepositories.create(args)
161+
app_config_fragment_repositories = AppConfigFragmentRepositories.create(args)
157162
auth_repositories = AuthRepositories.create(args)
158163
container_registry_repositories = ContainerRegistryRepositories.create(args)
159164
deployment_repositories = DeploymentRepositories.create(args)
@@ -211,6 +216,7 @@ def create(cls, args: RepositoryArgs) -> Self:
211216
agent=agent_repositories,
212217
app_config_allow_list=app_config_allow_list_repositories,
213218
app_config_definition=app_config_definition_repositories,
219+
app_config_fragment=app_config_fragment_repositories,
214220
auth=auth_repositories,
215221
container_registry=container_registry_repositories,
216222
deployment=deployment_repositories,

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.

0 commit comments

Comments
 (0)