Skip to content

Commit 1e71fac

Browse files
jopemachineclaude
andcommitted
feat(BA-6555): add bulk AppConfig resolve (batch over config_names)
Add AppConfigService.resolve_bulk + ResolveBulkAppConfigAction (USER ScopeAction taking config_names): resolve several merged AppConfigs for one (domain, user) principal in a single query. AppConfigFragmentRepository.list_visible_fragments_bulk fetches the visible fragments of all names at once ((config_name, rank)-ordered); the service groups by name and deep-merges each, returning one AppConfigData per requested name in order. This is the bulk-query path (no scoped search over merged AppConfig, per BA-6614). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ec964b4 commit 1e71fac

7 files changed

Lines changed: 226 additions & 3 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,33 @@ async def list_visible_fragments(
158158
async with self._ops.read_ops() as r:
159159
result = await r.batch_query_in_global(sa.select(AppConfigFragmentRow), querier)
160160
return [row.AppConfigFragmentRow.to_data() for row in result.rows]
161+
162+
@app_config_fragment_db_source_resilience.apply()
163+
async def list_visible_fragments_bulk(
164+
self, config_names: list[str], scope: AppConfigResolveScope
165+
) -> list[AppConfigFragmentData]:
166+
"""Visible fragments for several ``config_names`` at once, in a single query.
167+
168+
The bulk form of :meth:`list_visible_fragments`: per name, the public OR the scope's
169+
domain OR the scope's user fragment. Ordered by ``(config_name, rank)`` so the caller
170+
can group by name and deep-merge each in order.
171+
"""
172+
if not config_names:
173+
return []
174+
visibility = []
175+
for config_name in config_names:
176+
visibility.append(AppConfigFragmentConditions.by_public_visibility(config_name))
177+
visibility.append(
178+
AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id))
179+
)
180+
visibility.append(
181+
AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id))
182+
)
183+
querier = BatchQuerier(
184+
pagination=NoPagination(),
185+
conditions=[lambda: sa.or_(*(condition() for condition in visibility))],
186+
orders=[AppConfigFragmentRow.config_name.asc(), AppConfigFragmentRow.rank.asc()],
187+
)
188+
async with self._ops.read_ops() as r:
189+
result = await r.batch_query_in_global(sa.select(AppConfigFragmentRow), querier)
190+
return [row.AppConfigFragmentRow.to_data() for row in result.rows]

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,9 @@ async def list_visible_fragments(
8484
self, config_name: str, scope: AppConfigResolveScope
8585
) -> list[AppConfigFragmentData]:
8686
return await self._db_source.list_visible_fragments(config_name, scope)
87+
88+
@app_config_fragment_repository_resilience.apply()
89+
async def list_visible_fragments_bulk(
90+
self, config_names: list[str], scope: AppConfigResolveScope
91+
) -> list[AppConfigFragmentData]:
92+
return await self._db_source.list_visible_fragments_bulk(config_names, scope)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import override
5+
6+
from ai.backend.common.data.permission.types import RBACElementType, ScopeType
7+
from ai.backend.common.identifier.domain import DomainID
8+
from ai.backend.common.identifier.user import UserID
9+
from ai.backend.manager.actions.types import ActionOperationType
10+
from ai.backend.manager.data.app_config.types import AppConfigData
11+
from ai.backend.manager.data.permission.types import RBACElementRef
12+
from ai.backend.manager.services.app_config.actions.base import (
13+
AppConfigScopeAction,
14+
AppConfigScopeActionResult,
15+
)
16+
17+
18+
@dataclass
19+
class ResolveBulkAppConfigAction(AppConfigScopeAction):
20+
"""Resolve several merged ``AppConfig``s for one ``(user, domain)`` principal at once.
21+
22+
A batch over ``config_names`` for a single principal (one ``USER`` scope) — not a
23+
scoped search. Each name is merged independently and returned in request order.
24+
"""
25+
26+
config_names: list[str]
27+
domain_id: DomainID
28+
user_id: UserID
29+
30+
@override
31+
@classmethod
32+
def operation_type(cls) -> ActionOperationType:
33+
return ActionOperationType.GET
34+
35+
@override
36+
def scope_type(self) -> ScopeType:
37+
return ScopeType.USER
38+
39+
@override
40+
def scope_id(self) -> str:
41+
return str(self.user_id)
42+
43+
@override
44+
def target_element(self) -> RBACElementRef:
45+
return RBACElementRef(RBACElementType.APP_CONFIG, "")
46+
47+
48+
@dataclass
49+
class ResolveBulkAppConfigActionResult(AppConfigScopeActionResult):
50+
app_configs: list[AppConfigData]
51+
user_id: UserID
52+
53+
@override
54+
def scope_type(self) -> ScopeType:
55+
return ScopeType.USER
56+
57+
@override
58+
def scope_id(self) -> str:
59+
return str(self.user_id)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,28 @@
99
ResolveAppConfigAction,
1010
ResolveAppConfigActionResult,
1111
)
12+
from ai.backend.manager.services.app_config.actions.resolve_bulk import (
13+
ResolveBulkAppConfigAction,
14+
ResolveBulkAppConfigActionResult,
15+
)
1216
from ai.backend.manager.services.app_config.service import AppConfigService
1317

1418

1519
class AppConfigProcessors(AbstractProcessorPackage):
1620
resolve: ScopeActionProcessor[ResolveAppConfigAction, ResolveAppConfigActionResult]
21+
resolve_bulk: ScopeActionProcessor[ResolveBulkAppConfigAction, ResolveBulkAppConfigActionResult]
1722

1823
def __init__(
1924
self,
2025
service: AppConfigService,
2126
action_monitors: list[ActionMonitor],
2227
) -> None:
2328
self.resolve = ScopeActionProcessor(service.resolve, action_monitors)
29+
self.resolve_bulk = ScopeActionProcessor(service.resolve_bulk, action_monitors)
2430

2531
@override
2632
def supported_actions(self) -> list[ActionSpec]:
2733
return [
2834
ResolveAppConfigAction.spec(),
35+
ResolveBulkAppConfigAction.spec(),
2936
]

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
ResolveAppConfigAction,
1515
ResolveAppConfigActionResult,
1616
)
17+
from ai.backend.manager.services.app_config.actions.resolve_bulk import (
18+
ResolveBulkAppConfigAction,
19+
ResolveBulkAppConfigActionResult,
20+
)
1721

1822
__all__ = ("AppConfigService",)
1923

@@ -59,3 +63,29 @@ async def resolve(self, action: ResolveAppConfigAction) -> ResolveAppConfigActio
5963
config=self._merge_configs(fragments),
6064
)
6165
return ResolveAppConfigActionResult(app_config=app_config, user_id=action.user_id)
66+
67+
async def resolve_bulk(
68+
self, action: ResolveBulkAppConfigAction
69+
) -> ResolveBulkAppConfigActionResult:
70+
"""Resolve several merged ``AppConfig``s for one principal in a single query.
71+
72+
Fetches the visible fragments of all ``config_names`` at once, groups them by name,
73+
and deep-merges each in ``rank`` order. Returns one ``AppConfigData`` per requested
74+
name, in request order (an unregistered name yields ``config = None``).
75+
"""
76+
scope = AppConfigResolveScope(domain_id=action.domain_id, user_id=action.user_id)
77+
fragments = await self._fragment_repository.list_visible_fragments_bulk(
78+
action.config_names, scope
79+
)
80+
grouped: dict[str, list[AppConfigFragmentData]] = {}
81+
for fragment in fragments:
82+
grouped.setdefault(fragment.config_name, []).append(fragment)
83+
app_configs = [
84+
AppConfigData(
85+
config_name=config_name,
86+
fragments=grouped.get(config_name, []),
87+
config=self._merge_configs(grouped.get(config_name, [])),
88+
)
89+
for config_name in action.config_names
90+
]
91+
return ResolveBulkAppConfigActionResult(app_configs=app_configs, user_id=action.user_id)

tests/unit/manager/repositories/app_config_fragment/test_repository.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,38 @@ async def test_unknown_config_name_returns_empty(
491491
AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
492492
)
493493
assert applicable == []
494+
495+
async def test_bulk_returns_visible_fragments_for_all_names_ordered(
496+
self,
497+
repository: AppConfigFragmentRepository,
498+
fragments_across_scopes: list[AppConfigFragmentData],
499+
) -> None:
500+
applicable = await repository.list_visible_fragments_bulk(
501+
["theme", "menu"],
502+
AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
503+
)
504+
# public + the caller's domain + the caller's own user fragment, for both names.
505+
expected = {
506+
f.id
507+
for f in fragments_across_scopes
508+
if f.config_name in ("theme", "menu")
509+
and (
510+
f.scope_type is AppConfigScopeType.PUBLIC
511+
or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID)
512+
or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID)
513+
)
514+
}
515+
assert {f.id for f in applicable} == expected
516+
# Grouped by config_name, rank-ordered within each.
517+
assert [f.config_name for f in applicable] == sorted(f.config_name for f in applicable)
518+
519+
async def test_bulk_empty_names_returns_empty(
520+
self,
521+
repository: AppConfigFragmentRepository,
522+
fragments_across_scopes: list[AppConfigFragmentData],
523+
) -> None:
524+
applicable = await repository.list_visible_fragments_bulk(
525+
[],
526+
AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
527+
)
528+
assert applicable == []

tests/unit/manager/services/app_config/test_service.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@
88

99
import pytest
1010

11+
from ai.backend.common.data.app_config.types import AppConfigScopeType
1112
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
1213
from ai.backend.common.identifier.domain import DomainID
1314
from ai.backend.common.identifier.user import UserID
1415
from ai.backend.manager.data.app_config_fragment.types import (
1516
AppConfigFragmentData,
16-
AppConfigScopeType,
1717
)
1818
from ai.backend.manager.repositories.app_config_fragment.repository import (
1919
AppConfigFragmentRepository,
2020
)
2121
from ai.backend.manager.repositories.app_config_fragment.types import AppConfigResolveScope
2222
from ai.backend.manager.services.app_config.actions.resolve import ResolveAppConfigAction
23+
from ai.backend.manager.services.app_config.actions.resolve_bulk import (
24+
ResolveBulkAppConfigAction,
25+
)
2326
from ai.backend.manager.services.app_config.service import AppConfigService
2427

2528
_USER_UUID = uuid.uuid4()
@@ -28,12 +31,17 @@
2831

2932

3033
def _fragment(
31-
*, scope_type: AppConfigScopeType, scope_id: str, rank: int, config: dict[str, object]
34+
*,
35+
scope_type: AppConfigScopeType,
36+
scope_id: str,
37+
rank: int,
38+
config: dict[str, object],
39+
config_name: str = "theme",
3240
) -> AppConfigFragmentData:
3341
now = datetime.now(UTC)
3442
return AppConfigFragmentData(
3543
id=AppConfigFragmentID(uuid.uuid4()),
36-
config_name="theme",
44+
config_name=config_name,
3745
scope_type=scope_type,
3846
scope_id=scope_id,
3947
rank=rank,
@@ -93,3 +101,51 @@ async def test_resolve_empty_projects_config_to_none(
93101

94102
assert result.app_config.fragments == []
95103
assert result.app_config.config is None
104+
105+
async def test_resolve_bulk_groups_by_name_and_merges_each(
106+
self, service: AppConfigService, mock_fragment_repository: MagicMock
107+
) -> None:
108+
# Repo returns visible fragments for both names, (config_name, rank)-ordered.
109+
theme_public = _fragment(
110+
config_name="theme",
111+
scope_type=AppConfigScopeType.PUBLIC,
112+
scope_id="public",
113+
rank=100,
114+
config={"theme": "light", "lang": "en"},
115+
)
116+
theme_user = _fragment(
117+
config_name="theme",
118+
scope_type=AppConfigScopeType.USER,
119+
scope_id=str(_USER_ID),
120+
rank=300,
121+
config={"theme": "dark"},
122+
)
123+
menu_public = _fragment(
124+
config_name="menu",
125+
scope_type=AppConfigScopeType.PUBLIC,
126+
scope_id="public",
127+
rank=100,
128+
config={"items": ["a"]},
129+
)
130+
mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(
131+
return_value=[theme_public, theme_user, menu_public]
132+
)
133+
134+
result = await service.resolve_bulk(
135+
ResolveBulkAppConfigAction(
136+
config_names=["theme", "menu", "unknown"],
137+
domain_id=_DOMAIN_ID,
138+
user_id=_USER_ID,
139+
)
140+
)
141+
142+
# One AppConfigData per requested name, in request order.
143+
assert [c.config_name for c in result.app_configs] == ["theme", "menu", "unknown"]
144+
assert result.app_configs[0].config == {"theme": "dark", "lang": "en"}
145+
assert result.app_configs[1].config == {"items": ["a"]}
146+
assert result.app_configs[2].config is None # unregistered -> empty merge
147+
assert result.user_id == _USER_ID
148+
mock_fragment_repository.list_visible_fragments_bulk.assert_called_once_with(
149+
["theme", "menu", "unknown"],
150+
AppConfigResolveScope(domain_id=_DOMAIN_ID, user_id=_USER_ID),
151+
)

0 commit comments

Comments
 (0)