Skip to content

Commit 6d68096

Browse files
jopemachineclaude
andauthored
feat(BA-6810): AppConfigFragment visible-fragments query (repository layer) (#12706)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2d3059f commit 6d68096

6 files changed

Lines changed: 255 additions & 1 deletion

File tree

changes/12706.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the AppConfigFragment visible-fragments repository query: fetch the public / domain / user fragments visible to a (domain, user) principal for one or many config names, rank-ordered via the allow-list, in a single query.

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ def inner() -> sa.sql.expression.ColumnElement[bool]:
7272

7373
by_config_name_in = staticmethod(make_string_in_factory(AppConfigFragmentRow.config_name))
7474

75+
@staticmethod
76+
def by_config_names(config_names: Sequence[str]) -> QueryCondition:
77+
"""Fragments whose ``config_name`` is one of ``config_names``.
78+
79+
The plain-name membership filter the merged read AND-combines with a visibility
80+
filter — kept separate from the ``by_*_visibility`` scope builders.
81+
"""
82+
83+
def inner() -> sa.sql.expression.ColumnElement[bool]:
84+
return AppConfigFragmentRow.config_name.in_(list(config_names))
85+
86+
return inner
87+
7588
# --- scope_type enum filters ---
7689

7790
@staticmethod
@@ -111,6 +124,41 @@ def inner() -> sa.sql.expression.ColumnElement[bool]:
111124

112125
return inner
113126

127+
# --- per-scope visibility filters (one scope_type each), independent of config_name ---
128+
129+
@staticmethod
130+
def by_public_visibility() -> QueryCondition:
131+
"""The ``public`` scope (public has no per-entity scope_id)."""
132+
133+
def inner() -> sa.sql.expression.ColumnElement[bool]:
134+
return AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC
135+
136+
return inner
137+
138+
@staticmethod
139+
def by_domain_visibility(domain: str) -> QueryCondition:
140+
"""The ``domain`` scope for ``domain``."""
141+
142+
def inner() -> sa.sql.expression.ColumnElement[bool]:
143+
return sa.and_(
144+
AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN,
145+
AppConfigFragmentRow.scope_id == domain,
146+
)
147+
148+
return inner
149+
150+
@staticmethod
151+
def by_user_visibility(user_id: str) -> QueryCondition:
152+
"""The ``user`` scope for ``user_id``."""
153+
154+
def inner() -> sa.sql.expression.ColumnElement[bool]:
155+
return sa.and_(
156+
AppConfigFragmentRow.scope_type == AppConfigScopeType.USER,
157+
AppConfigFragmentRow.scope_id == user_id,
158+
)
159+
160+
return inner
161+
114162
# --- created_at / updated_at datetime filters ---
115163

116164
@staticmethod

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@
2121
from ai.backend.manager.errors.app_config import (
2222
AppConfigFragmentNotFound,
2323
)
24+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
25+
from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions
2426
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
2527
from ai.backend.manager.models.scopes import SearchScope
28+
from ai.backend.manager.repositories.app_config_fragment.types import (
29+
AppConfigScopeArguments,
30+
)
2631
from ai.backend.manager.repositories.base import (
2732
BatchQuerier,
2833
BulkCreator,
2934
Creator,
35+
NoPagination,
3036
Purger,
3137
Querier,
3238
Updater,
@@ -193,3 +199,44 @@ async def scoped_search(
193199
has_next_page=result.has_next_page,
194200
has_previous_page=result.has_previous_page,
195201
)
202+
203+
@app_config_fragment_db_source_resilience.apply()
204+
async def list_visible_fragments_bulk(
205+
self, config_names: list[str], scope: AppConfigScopeArguments
206+
) -> list[AppConfigFragmentData]:
207+
"""Visible fragments for several ``config_names`` at once, in a single query.
208+
209+
Selects the requested names AND any one of the principal's visible scopes (public,
210+
its domain, or its own user). The scope filter is name-independent, so it is a single
211+
OR group AND-combined with the name membership. Merge priority (``rank``) lives on the
212+
joined allow-list entry; the result is always ordered by ascending ``rank`` so the
213+
caller can group by name (each name's subset stays rank-ordered) and deep-merge each
214+
name's fragments in order.
215+
"""
216+
if not config_names:
217+
return []
218+
scope_visibility = [
219+
AppConfigFragmentConditions.by_public_visibility(),
220+
AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)),
221+
AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)),
222+
]
223+
querier = BatchQuerier(
224+
pagination=NoPagination(),
225+
conditions=[
226+
AppConfigFragmentConditions.by_config_names(config_names),
227+
lambda: sa.or_(*(visibility() for visibility in scope_visibility)),
228+
],
229+
orders=[AppConfigAllowListRow.rank.asc()],
230+
)
231+
# Join each fragment to its allow-list entry (indexed ``(config_name, scope_type)`` FK
232+
# pair), which carries the merge ``rank`` the result is ordered by.
233+
selector = sa.select(AppConfigFragmentRow).join(
234+
AppConfigAllowListRow,
235+
sa.and_(
236+
AppConfigAllowListRow.config_name == AppConfigFragmentRow.config_name,
237+
AppConfigAllowListRow.scope_type == AppConfigFragmentRow.scope_type,
238+
),
239+
)
240+
async with self._ops.read_ops() as r:
241+
result = await r.batch_query_in_global(selector, querier)
242+
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
@@ -18,6 +18,9 @@
1818
from ai.backend.manager.repositories.app_config_fragment.db_source import (
1919
AppConfigFragmentDBSource,
2020
)
21+
from ai.backend.manager.repositories.app_config_fragment.types import (
22+
AppConfigScopeArguments,
23+
)
2124
from ai.backend.manager.repositories.base import (
2225
BatchQuerier,
2326
BulkCreator,
@@ -103,3 +106,9 @@ async def bulk_purge(
103106
purgers: Sequence[Purger[AppConfigFragmentRow]],
104107
) -> AppConfigFragmentBulkResult:
105108
return await self._db_source.bulk_purge(purgers)
109+
110+
@app_config_fragment_repository_resilience.apply()
111+
async def list_visible_fragments_bulk(
112+
self, config_names: list[str], scope: AppConfigScopeArguments
113+
) -> list[AppConfigFragmentData]:
114+
return await self._db_source.list_visible_fragments_bulk(config_names, 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)."""
1+
"""Types for app config fragment repository operations (search scopes, resolve arguments)."""
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+
"AppConfigScopeArguments",
1920
"DomainAppConfigFragmentSearchScope",
2021
"UserAppConfigFragmentSearchScope",
2122
)
2223

2324

25+
@dataclass(frozen=True)
26+
class AppConfigScopeArguments:
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``).

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
AppConfigFragmentRepository,
3232
)
3333
from ai.backend.manager.repositories.app_config_fragment.types import (
34+
AppConfigScopeArguments,
3435
DomainAppConfigFragmentSearchScope,
3536
UserAppConfigFragmentSearchScope,
3637
)
@@ -569,3 +570,137 @@ async def test_partial_when_one_missing(
569570
assert [f.index for f in result.failed] == [1]
570571
with pytest.raises(AppConfigFragmentNotFound):
571572
await repository.get_by_id(two_fragments[0].id)
573+
574+
575+
class TestVisibilityConditions:
576+
async def test_public_visibility_selects_only_public(
577+
self,
578+
repository: AppConfigFragmentRepository,
579+
fragments_across_scopes: list[AppConfigFragmentData],
580+
) -> None:
581+
result = await repository.admin_search(
582+
BatchQuerier(
583+
pagination=OffsetPagination(limit=10, offset=0),
584+
conditions=[AppConfigFragmentConditions.by_public_visibility()],
585+
)
586+
)
587+
# Visibility is scope-only (name-independent): every public fragment, any name.
588+
expected = {
589+
f.id for f in fragments_across_scopes if f.scope_type is AppConfigScopeType.PUBLIC
590+
}
591+
assert {item.id for item in result.items} == expected
592+
593+
async def test_domain_visibility_selects_only_that_domain(
594+
self,
595+
repository: AppConfigFragmentRepository,
596+
fragments_across_scopes: list[AppConfigFragmentData],
597+
) -> None:
598+
result = await repository.admin_search(
599+
BatchQuerier(
600+
pagination=OffsetPagination(limit=10, offset=0),
601+
conditions=[AppConfigFragmentConditions.by_domain_visibility(_DOMAIN_ID)],
602+
)
603+
)
604+
expected = {
605+
f.id
606+
for f in fragments_across_scopes
607+
if f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID
608+
}
609+
assert {item.id for item in result.items} == expected
610+
611+
async def test_user_visibility_selects_only_that_user(
612+
self,
613+
repository: AppConfigFragmentRepository,
614+
fragments_across_scopes: list[AppConfigFragmentData],
615+
) -> None:
616+
result = await repository.admin_search(
617+
BatchQuerier(
618+
pagination=OffsetPagination(limit=10, offset=0),
619+
conditions=[AppConfigFragmentConditions.by_user_visibility(_USER_ID)],
620+
)
621+
)
622+
expected = {
623+
f.id
624+
for f in fragments_across_scopes
625+
if f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID
626+
}
627+
assert {item.id for item in result.items} == expected
628+
629+
630+
class TestApplicableFragments:
631+
async def test_one_query_returns_public_domain_user_rank_ordered(
632+
self,
633+
repository: AppConfigFragmentRepository,
634+
fragments_across_scopes: list[AppConfigFragmentData],
635+
) -> None:
636+
applicable = await repository.list_visible_fragments_bulk(
637+
["theme"],
638+
AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
639+
)
640+
# public + the caller's domain + the caller's own user fragment, ordered by the
641+
# allow-list entries' ranks (scope-type defaults: public < domain < user).
642+
expected = [
643+
f
644+
for f in fragments_across_scopes
645+
if f.config_name == "theme"
646+
and (
647+
f.scope_type is AppConfigScopeType.PUBLIC
648+
or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID)
649+
or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID)
650+
)
651+
]
652+
assert [f.id for f in applicable] == [f.id for f in expected]
653+
assert [f.scope_type for f in applicable] == [
654+
AppConfigScopeType.PUBLIC,
655+
AppConfigScopeType.DOMAIN,
656+
AppConfigScopeType.USER,
657+
]
658+
659+
async def test_unknown_config_name_returns_empty(
660+
self,
661+
repository: AppConfigFragmentRepository,
662+
fragments_across_scopes: list[AppConfigFragmentData],
663+
) -> None:
664+
applicable = await repository.list_visible_fragments_bulk(
665+
["unregistered"],
666+
AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
667+
)
668+
assert applicable == []
669+
670+
async def test_bulk_returns_visible_fragments_for_all_names_ordered(
671+
self,
672+
repository: AppConfigFragmentRepository,
673+
fragments_across_scopes: list[AppConfigFragmentData],
674+
) -> None:
675+
applicable = await repository.list_visible_fragments_bulk(
676+
["theme", "menu"],
677+
AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
678+
)
679+
# public + the caller's domain + the caller's own user fragment, for both names.
680+
expected = {
681+
f.id
682+
for f in fragments_across_scopes
683+
if f.config_name in ("theme", "menu")
684+
and (
685+
f.scope_type is AppConfigScopeType.PUBLIC
686+
or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID)
687+
or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID)
688+
)
689+
}
690+
assert {f.id for f in applicable} == expected
691+
# Rank-ordered globally, so each config_name's subset stays rank-ordered
692+
# (public < domain < user) for the caller to group by name and deep-merge in order.
693+
for name in ("theme", "menu"):
694+
ranks = [f.scope_type.default_rank() for f in applicable if f.config_name == name]
695+
assert ranks == sorted(ranks)
696+
697+
async def test_bulk_empty_names_returns_empty(
698+
self,
699+
repository: AppConfigFragmentRepository,
700+
fragments_across_scopes: list[AppConfigFragmentData],
701+
) -> None:
702+
applicable = await repository.list_visible_fragments_bulk(
703+
[],
704+
AppConfigScopeArguments(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)),
705+
)
706+
assert applicable == []

0 commit comments

Comments
 (0)