From d0752e254bfc590638103fec25c8173fc699eb14 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Wed, 24 Jun 2026 17:06:30 +0900 Subject: [PATCH 1/2] 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) --- changes/12359.feature.md | 1 + .../manager/data/app_config/__init__.py | 0 .../backend/manager/data/app_config/types.py | 19 ++++ .../models/app_config_fragment/conditions.py | 40 +++++++ .../db_source/db_source.py | 44 ++++++++ .../app_config_fragment/repository.py | 9 ++ .../repositories/app_config_fragment/types.py | 16 ++- .../manager/services/app_config/__init__.py | 0 .../services/app_config/actions/__init__.py | 0 .../services/app_config/actions/base.py | 19 ++++ .../services/app_config/actions/resolve.py | 55 ++++++++++ .../manager/services/app_config/processors.py | 29 +++++ .../manager/services/app_config/service.py | 61 +++++++++++ .../app_config_fragment/test_repository.py | 102 ++++++++++++++++++ tests/unit/manager/services/app_config/BUILD | 3 + .../manager/services/app_config/__init__.py | 0 .../services/app_config/test_service.py | 93 ++++++++++++++++ 17 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 changes/12359.feature.md create mode 100644 src/ai/backend/manager/data/app_config/__init__.py create mode 100644 src/ai/backend/manager/data/app_config/types.py create mode 100644 src/ai/backend/manager/services/app_config/__init__.py create mode 100644 src/ai/backend/manager/services/app_config/actions/__init__.py create mode 100644 src/ai/backend/manager/services/app_config/actions/base.py create mode 100644 src/ai/backend/manager/services/app_config/actions/resolve.py create mode 100644 src/ai/backend/manager/services/app_config/processors.py create mode 100644 src/ai/backend/manager/services/app_config/service.py create mode 100644 tests/unit/manager/services/app_config/BUILD create mode 100644 tests/unit/manager/services/app_config/__init__.py create mode 100644 tests/unit/manager/services/app_config/test_service.py diff --git a/changes/12359.feature.md b/changes/12359.feature.md new file mode 100644 index 00000000000..ca1580d36fd --- /dev/null +++ b/changes/12359.feature.md @@ -0,0 +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. diff --git a/src/ai/backend/manager/data/app_config/__init__.py b/src/ai/backend/manager/data/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/data/app_config/types.py b/src/ai/backend/manager/data/app_config/types.py new file mode 100644 index 00000000000..cc28c880d1d --- /dev/null +++ b/src/ai/backend/manager/data/app_config/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData + + +@dataclass(frozen=True) +class AppConfigData: + """Merged per-user view of one ``config_name`` (BEP-1052). + + The ordered contributing ``fragments`` (rank low -> high) plus their deep-merged + ``config``. ``config`` is ``None`` when the merge of every fragment is empty. + """ + + config_name: str + fragments: list[AppConfigFragmentData] + config: dict[str, Any] | None diff --git a/src/ai/backend/manager/models/app_config_fragment/conditions.py b/src/ai/backend/manager/models/app_config_fragment/conditions.py index 53d3d792131..2dd1863820c 100644 --- a/src/ai/backend/manager/models/app_config_fragment/conditions.py +++ b/src/ai/backend/manager/models/app_config_fragment/conditions.py @@ -111,6 +111,46 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner + # --- per-scope visibility filters (one scope_type each) for a config_name --- + + @staticmethod + def by_public_visibility(config_name: str) -> QueryCondition: + """The ``public`` fragment of ``config_name`` (public has no per-entity scope_id).""" + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.config_name == config_name, + AppConfigFragmentRow.scope_type == AppConfigScopeType.PUBLIC, + ) + + return inner + + @staticmethod + def by_domain_visibility(config_name: str, domain: str) -> QueryCondition: + """The ``domain`` fragment of ``config_name`` for ``domain``.""" + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.config_name == config_name, + AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN, + AppConfigFragmentRow.scope_id == domain, + ) + + return inner + + @staticmethod + def by_user_visibility(config_name: str, user_id: str) -> QueryCondition: + """The ``user`` fragment of ``config_name`` for ``user_id``.""" + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return sa.and_( + AppConfigFragmentRow.config_name == config_name, + AppConfigFragmentRow.scope_type == AppConfigScopeType.USER, + AppConfigFragmentRow.scope_id == user_id, + ) + + return inner + # --- created_at / updated_at datetime filters --- @staticmethod diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index eab00844fdd..d6ab405891f 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -23,18 +23,23 @@ AppConfigFragmentWriteNotAllowed, ) from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow +from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow from ai.backend.manager.models.scopes import SearchScope from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, GatedAppConfigFragmentCreate, ) +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigResolveScope, +) from ai.backend.manager.repositories.base import ( BatchQuerier, BulkCreator, Creator, CreatorSpec, ExistsQuerier, + NoPagination, Purger, Querier, Updater, @@ -269,3 +274,42 @@ async def scoped_search( has_next_page=result.has_next_page, has_previous_page=result.has_previous_page, ) + + @staticmethod + def _visible_fragments_selector() -> sa.Select: + """Fragments joined to their allow-list entry, which carries the merge ``rank``. + + The join is on the indexed ``(config_name, scope_type)`` FK pair; ordering by + ``AppConfigAllowListRow.rank`` yields the merge order. + """ + return sa.select(AppConfigFragmentRow).join( + AppConfigAllowListRow, + sa.and_( + AppConfigAllowListRow.config_name == AppConfigFragmentRow.config_name, + AppConfigAllowListRow.scope_type == AppConfigFragmentRow.scope_type, + ), + ) + + @app_config_fragment_db_source_resilience.apply() + async def list_visible_fragments( + self, config_name: str, scope: AppConfigResolveScope + ) -> list[AppConfigFragmentData]: + """Fragments visible to ``scope`` for ``config_name``, rank-ordered, in one query. + + Filters on the fragment's own ``scope_type``/``scope_id`` columns: the public OR the + scope's domain OR the scope's user fragment. Merge priority lives on the allow-list + entry, so the query joins it and orders by its ``rank``. Each ``(config_name, + scope_type, scope_id)`` is unique, so the result is bounded (one per scope_type), + ready to deep-merge in order. + """ + public = AppConfigFragmentConditions.by_public_visibility(config_name) + domain = AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id)) + user = AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id)) + querier = BatchQuerier( + pagination=NoPagination(), + conditions=[lambda: sa.or_(public(), domain(), user())], + orders=[AppConfigAllowListRow.rank.asc()], + ) + async with self._ops.read_ops() as r: + result = await r.batch_query_in_global(self._visible_fragments_selector(), querier) + return [row.AppConfigFragmentRow.to_data() for row in result.rows] diff --git a/src/ai/backend/manager/repositories/app_config_fragment/repository.py b/src/ai/backend/manager/repositories/app_config_fragment/repository.py index 5aa3963dcd6..7b0caa5dabe 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/repository.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/repository.py @@ -23,6 +23,9 @@ from ai.backend.manager.repositories.app_config_fragment.db_source import ( AppConfigFragmentDBSource, ) +from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigResolveScope, +) from ai.backend.manager.repositories.base import ( BatchQuerier, ExistsQuerier, @@ -111,3 +114,9 @@ async def bulk_purge( purgers: Sequence[Purger[AppConfigFragmentRow]], ) -> AppConfigFragmentBulkWriteResult: return await self._db_source.bulk_purge(purgers) + + @app_config_fragment_repository_resilience.apply() + async def list_visible_fragments( + self, config_name: str, scope: AppConfigResolveScope + ) -> list[AppConfigFragmentData]: + return await self._db_source.list_visible_fragments(config_name, scope) diff --git a/src/ai/backend/manager/repositories/app_config_fragment/types.py b/src/ai/backend/manager/repositories/app_config_fragment/types.py index 190dd9def78..abba25f2dda 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/types.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/types.py @@ -1,4 +1,4 @@ -"""Types for app config fragment repository operations (search scopes).""" +"""Types for app config fragment repository operations (search scopes, resolve scope).""" from __future__ import annotations @@ -16,11 +16,25 @@ from ai.backend.manager.models.scopes import ExistenceCheck, SearchScope __all__ = ( + "AppConfigResolveScope", "DomainAppConfigFragmentSearchScope", "UserAppConfigFragmentSearchScope", ) +@dataclass(frozen=True) +class AppConfigResolveScope: + """The principal an ``AppConfig`` is resolved for: the resolving user and its domain. + + Bundles the scope-identifying arguments so they travel together (add new principal + dimensions here rather than growing method signatures). Plain value object — not a + :class:`SearchScope`. + """ + + domain_id: DomainID + user_id: UserID + + @dataclass(frozen=True) class DomainAppConfigFragmentSearchScope(SearchScope): """Fragments written at one domain scope (``scope_type == domain``, ``scope_id == domain_id``). diff --git a/src/ai/backend/manager/services/app_config/__init__.py b/src/ai/backend/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config/actions/__init__.py b/src/ai/backend/manager/services/app_config/actions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config/actions/base.py b/src/ai/backend/manager/services/app_config/actions/base.py new file mode 100644 index 00000000000..d134ddd3700 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult + + +class AppConfigScopeAction(BaseScopeAction): + """Base for scope-level merged app config actions (resolve).""" + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + +class AppConfigScopeActionResult(BaseScopeActionResult): + pass diff --git a/src/ai/backend/manager/services/app_config/actions/resolve.py b/src/ai/backend/manager/services/app_config/actions/resolve.py new file mode 100644 index 00000000000..e5a71500576 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/resolve.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.services.app_config.actions.base import ( + AppConfigScopeAction, + AppConfigScopeActionResult, +) + + +@dataclass +class ResolveAppConfigAction(AppConfigScopeAction): + """Resolve the merged ``AppConfig`` for one ``(user, config_name)``.""" + + config_name: str + domain_id: DomainID + user_id: UserID + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self.user_id) + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef(RBACElementType.APP_CONFIG, "") + + +@dataclass +class ResolveAppConfigActionResult(AppConfigScopeActionResult): + app_config: AppConfigData + user_id: UserID + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self.user_id) diff --git a/src/ai/backend/manager/services/app_config/processors.py b/src/ai/backend/manager/services/app_config/processors.py new file mode 100644 index 00000000000..f4921cff86e --- /dev/null +++ b/src/ai/backend/manager/services/app_config/processors.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import override + +from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.processor.scope import ScopeActionProcessor +from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.services.app_config.actions.resolve import ( + ResolveAppConfigAction, + ResolveAppConfigActionResult, +) +from ai.backend.manager.services.app_config.service import AppConfigService + + +class AppConfigProcessors(AbstractProcessorPackage): + resolve: ScopeActionProcessor[ResolveAppConfigAction, ResolveAppConfigActionResult] + + def __init__( + self, + service: AppConfigService, + action_monitors: list[ActionMonitor], + ) -> None: + self.resolve = ScopeActionProcessor(service.resolve, action_monitors) + + @override + def supported_actions(self) -> list[ActionSpec]: + return [ + ResolveAppConfigAction.spec(), + ] diff --git a/src/ai/backend/manager/services/app_config/service.py b/src/ai/backend/manager/services/app_config/service.py new file mode 100644 index 00000000000..6e5844c4564 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/service.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from ai.backend.common.utils import deep_merge +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigResolveScope +from ai.backend.manager.services.app_config.actions.resolve import ( + ResolveAppConfigAction, + ResolveAppConfigActionResult, +) + +__all__ = ("AppConfigService",) + + +class AppConfigService: + """Read-side service for the merged ``AppConfig`` view (BEP-1052). + + Resolves the merged config for a ``(user, config_name)`` by querying the domain-visible + scope (public + domain) and the user-visible scope (the user's own fragment) + independently, then deep-merging both in ``rank`` order. Reads are unconditional — no + allow-list / write-gate applies. + """ + + _fragment_repository: AppConfigFragmentRepository + + def __init__(self, fragment_repository: AppConfigFragmentRepository) -> None: + self._fragment_repository = fragment_repository + + @staticmethod + def _merge_configs(fragments: Sequence[AppConfigFragmentData]) -> dict[str, Any] | None: + """Deep-merge fragment configs in ascending ``rank`` order; empty result is ``None``. + + ``fragments`` must be pre-ordered low -> high so higher-rank fragments override. + """ + merged = dict(deep_merge(*(fragment.config for fragment in fragments))) + return merged or None + + async def resolve(self, action: ResolveAppConfigAction) -> ResolveAppConfigActionResult: + """Resolve the merged ``AppConfig`` for ``(user, config_name)``. + + The domain-visible scope (public + domain) and the user-visible scope (the user's + own fragment) are fetched together in a single ``rank``-ordered query, then + deep-merged. An unregistered name simply yields an empty merge (``config`` is + ``None``). + """ + fragments = await self._fragment_repository.list_visible_fragments( + action.config_name, + AppConfigResolveScope(domain_id=action.domain_id, user_id=action.user_id), + ) + app_config = AppConfigData( + config_name=action.config_name, + fragments=fragments, + config=self._merge_configs(fragments), + ) + return ResolveAppConfigActionResult(app_config=app_config, user_id=action.user_id) diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index 984fb3a5d79..54eedd606b3 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -34,6 +34,7 @@ AppConfigFragmentRepository, ) from ai.backend.manager.repositories.app_config_fragment.types import ( + AppConfigResolveScope, DomainAppConfigFragmentSearchScope, UserAppConfigFragmentSearchScope, ) @@ -587,3 +588,104 @@ async def test_partial_when_one_missing( assert "not found" in result.failed[0].message with pytest.raises(AppConfigFragmentNotFound): await repository.get_by_id(two_fragments[0].id) + + +class TestVisibilityConditions: + async def test_public_visibility_selects_only_public( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + result = await repository.admin_search( + BatchQuerier( + pagination=OffsetPagination(limit=10, offset=0), + conditions=[AppConfigFragmentConditions.by_public_visibility("theme")], + ) + ) + expected = { + f.id + for f in fragments_across_scopes + if f.config_name == "theme" and f.scope_type is AppConfigScopeType.PUBLIC + } + assert {item.id for item in result.items} == expected + + async def test_domain_visibility_selects_only_that_domain( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + result = await repository.admin_search( + BatchQuerier( + pagination=OffsetPagination(limit=10, offset=0), + conditions=[AppConfigFragmentConditions.by_domain_visibility("theme", _DOMAIN_ID)], + ) + ) + expected = { + f.id + for f in fragments_across_scopes + if f.config_name == "theme" + and f.scope_type is AppConfigScopeType.DOMAIN + and f.scope_id == _DOMAIN_ID + } + assert {item.id for item in result.items} == expected + + async def test_user_visibility_selects_only_that_user( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + result = await repository.admin_search( + BatchQuerier( + pagination=OffsetPagination(limit=10, offset=0), + conditions=[AppConfigFragmentConditions.by_user_visibility("theme", _USER_ID)], + ) + ) + expected = { + f.id + for f in fragments_across_scopes + if f.config_name == "theme" + and f.scope_type is AppConfigScopeType.USER + and f.scope_id == _USER_ID + } + assert {item.id for item in result.items} == expected + + +class TestApplicableFragments: + async def test_one_query_returns_public_domain_user_rank_ordered( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments( + "theme", + AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + # public + the caller's domain + the caller's own user fragment, ordered by the + # allow-list entries' ranks (scope-type defaults: public < domain < user). + expected = [ + f + for f in fragments_across_scopes + if f.config_name == "theme" + and ( + f.scope_type is AppConfigScopeType.PUBLIC + or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + ) + ] + assert [f.id for f in applicable] == [f.id for f in expected] + assert [f.scope_type for f in applicable] == [ + AppConfigScopeType.PUBLIC, + AppConfigScopeType.DOMAIN, + AppConfigScopeType.USER, + ] + + async def test_unknown_config_name_returns_empty( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments( + "unregistered", + AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + assert applicable == [] diff --git a/tests/unit/manager/services/app_config/BUILD b/tests/unit/manager/services/app_config/BUILD new file mode 100644 index 00000000000..57341b1358b --- /dev/null +++ b/tests/unit/manager/services/app_config/BUILD @@ -0,0 +1,3 @@ +python_tests( + name="tests", +) diff --git a/tests/unit/manager/services/app_config/__init__.py b/tests/unit/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/manager/services/app_config/test_service.py b/tests/unit/manager/services/app_config/test_service.py new file mode 100644 index 00000000000..9abdf702d99 --- /dev/null +++ b/tests/unit/manager/services/app_config/test_service.py @@ -0,0 +1,93 @@ +"""Tests for AppConfigService (merged AppConfig resolution) with a mocked repository.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + AppConfigScopeType, +) +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigResolveScope +from ai.backend.manager.services.app_config.actions.resolve import ResolveAppConfigAction +from ai.backend.manager.services.app_config.service import AppConfigService + +_USER_UUID = uuid.uuid4() +_USER_ID = UserID(_USER_UUID) +_DOMAIN_ID = DomainID(uuid.uuid4()) + + +def _fragment( + *, scope_type: AppConfigScopeType, scope_id: str, config: dict[str, object] +) -> AppConfigFragmentData: + now = datetime.now(UTC) + return AppConfigFragmentData( + id=AppConfigFragmentID(uuid.uuid4()), + config_name="theme", + scope_type=scope_type, + scope_id=scope_id, + config=config, + created_at=now, + updated_at=now, + ) + + +class TestAppConfigService: + @pytest.fixture + def mock_fragment_repository(self) -> MagicMock: + return MagicMock(spec=AppConfigFragmentRepository) + + @pytest.fixture + def service(self, mock_fragment_repository: MagicMock) -> AppConfigService: + return AppConfigService(fragment_repository=mock_fragment_repository) + + async def test_resolve_deep_merges_applicable_fragments( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + # The repository returns the applicable fragments ordered by their allow-list + # entries' ranks (low -> high), so the later fragment overrides on merge. + public = _fragment( + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + config={"theme": "light", "lang": "en"}, + ) + user = _fragment( + scope_type=AppConfigScopeType.USER, + scope_id=str(_USER_ID), + config={"theme": "dark"}, + ) + mock_fragment_repository.list_visible_fragments = AsyncMock(return_value=[public, user]) + + result = await service.resolve( + ResolveAppConfigAction(config_name="theme", domain_id=_DOMAIN_ID, user_id=_USER_ID) + ) + + assert result.app_config.config_name == "theme" + assert result.app_config.fragments == [public, user] + assert result.app_config.config == {"theme": "dark", "lang": "en"} + assert result.user_id == _USER_ID + mock_fragment_repository.list_visible_fragments.assert_called_once_with( + "theme", AppConfigResolveScope(domain_id=_DOMAIN_ID, user_id=_USER_ID) + ) + + async def test_resolve_empty_projects_config_to_none( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + mock_fragment_repository.list_visible_fragments = AsyncMock(return_value=[]) + + result = await service.resolve( + ResolveAppConfigAction(config_name="unknown", domain_id=_DOMAIN_ID, user_id=_USER_ID) + ) + + assert result.app_config.fragments == [] + assert result.app_config.config is None From 25dbbe5b1b671f1831e9484a882f7595d699e574 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Wed, 24 Jun 2026 19:05:36 +0900 Subject: [PATCH 2/2] 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) --- .../db_source/db_source.py | 36 +++++++++-- .../app_config_fragment/repository.py | 6 ++ .../app_config/actions/resolve_bulk.py | 59 +++++++++++++++++++ .../manager/services/app_config/processors.py | 7 +++ .../manager/services/app_config/service.py | 30 ++++++++++ .../app_config_fragment/test_repository.py | 35 +++++++++++ .../services/app_config/test_service.py | 58 +++++++++++++++++- 7 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 src/ai/backend/manager/services/app_config/actions/resolve_bulk.py diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index d6ab405891f..84db91e1bcc 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -170,9 +170,7 @@ async def bulk_create( ), ) ) - result = await w.bulk_create_partial( - BulkCreator(specs=[spec for _, spec in allowed]) - ) + result = await w.bulk_create_partial(BulkCreator(specs=[spec for _, spec in allowed])) failed.extend( AppConfigFragmentBulkItemError( index=allowed[error.index][0], message=str(error.exception) @@ -276,7 +274,7 @@ async def scoped_search( ) @staticmethod - def _visible_fragments_selector() -> sa.Select: + def _visible_fragments_selector() -> sa.Select[tuple[AppConfigFragmentRow]]: """Fragments joined to their allow-list entry, which carries the merge ``rank``. The join is on the indexed ``(config_name, scope_type)`` FK pair; ordering by @@ -313,3 +311,33 @@ async def list_visible_fragments( async with self._ops.read_ops() as r: result = await r.batch_query_in_global(self._visible_fragments_selector(), querier) return [row.AppConfigFragmentRow.to_data() for row in result.rows] + + @app_config_fragment_db_source_resilience.apply() + async def list_visible_fragments_bulk( + self, config_names: list[str], scope: AppConfigResolveScope + ) -> list[AppConfigFragmentData]: + """Visible fragments for several ``config_names`` at once, in a single query. + + The bulk form of :meth:`list_visible_fragments`: per name, the public OR the scope's + domain OR the scope's user fragment. Ordered by ``(config_name, allow-list rank)`` + so the caller can group by name and deep-merge each in order. + """ + if not config_names: + return [] + visibility = [] + for config_name in config_names: + visibility.append(AppConfigFragmentConditions.by_public_visibility(config_name)) + visibility.append( + AppConfigFragmentConditions.by_domain_visibility(config_name, str(scope.domain_id)) + ) + visibility.append( + AppConfigFragmentConditions.by_user_visibility(config_name, str(scope.user_id)) + ) + querier = BatchQuerier( + pagination=NoPagination(), + conditions=[lambda: sa.or_(*(condition() for condition in visibility))], + orders=[AppConfigFragmentRow.config_name.asc(), AppConfigAllowListRow.rank.asc()], + ) + async with self._ops.read_ops() as r: + result = await r.batch_query_in_global(self._visible_fragments_selector(), querier) + return [row.AppConfigFragmentRow.to_data() for row in result.rows] diff --git a/src/ai/backend/manager/repositories/app_config_fragment/repository.py b/src/ai/backend/manager/repositories/app_config_fragment/repository.py index 7b0caa5dabe..4bae58a7ab0 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/repository.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/repository.py @@ -120,3 +120,9 @@ async def list_visible_fragments( self, config_name: str, scope: AppConfigResolveScope ) -> list[AppConfigFragmentData]: return await self._db_source.list_visible_fragments(config_name, scope) + + @app_config_fragment_repository_resilience.apply() + async def list_visible_fragments_bulk( + self, config_names: list[str], scope: AppConfigResolveScope + ) -> list[AppConfigFragmentData]: + return await self._db_source.list_visible_fragments_bulk(config_names, scope) diff --git a/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py b/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py new file mode 100644 index 00000000000..052ad2d7da1 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.services.app_config.actions.base import ( + AppConfigScopeAction, + AppConfigScopeActionResult, +) + + +@dataclass +class ResolveBulkAppConfigAction(AppConfigScopeAction): + """Resolve several merged ``AppConfig``s for one ``(user, domain)`` principal at once. + + A batch over ``config_names`` for a single principal (one ``USER`` scope) — not a + scoped search. Each name is merged independently and returned in request order. + """ + + config_names: list[str] + domain_id: DomainID + user_id: UserID + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self.user_id) + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef(RBACElementType.APP_CONFIG, "") + + +@dataclass +class ResolveBulkAppConfigActionResult(AppConfigScopeActionResult): + app_configs: list[AppConfigData] + user_id: UserID + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER + + @override + def scope_id(self) -> str: + return str(self.user_id) diff --git a/src/ai/backend/manager/services/app_config/processors.py b/src/ai/backend/manager/services/app_config/processors.py index f4921cff86e..ffc975f7878 100644 --- a/src/ai/backend/manager/services/app_config/processors.py +++ b/src/ai/backend/manager/services/app_config/processors.py @@ -9,11 +9,16 @@ ResolveAppConfigAction, ResolveAppConfigActionResult, ) +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, + ResolveBulkAppConfigActionResult, +) from ai.backend.manager.services.app_config.service import AppConfigService class AppConfigProcessors(AbstractProcessorPackage): resolve: ScopeActionProcessor[ResolveAppConfigAction, ResolveAppConfigActionResult] + resolve_bulk: ScopeActionProcessor[ResolveBulkAppConfigAction, ResolveBulkAppConfigActionResult] def __init__( self, @@ -21,9 +26,11 @@ def __init__( action_monitors: list[ActionMonitor], ) -> None: self.resolve = ScopeActionProcessor(service.resolve, action_monitors) + self.resolve_bulk = ScopeActionProcessor(service.resolve_bulk, action_monitors) @override def supported_actions(self) -> list[ActionSpec]: return [ ResolveAppConfigAction.spec(), + ResolveBulkAppConfigAction.spec(), ] diff --git a/src/ai/backend/manager/services/app_config/service.py b/src/ai/backend/manager/services/app_config/service.py index 6e5844c4564..294aef8ff6c 100644 --- a/src/ai/backend/manager/services/app_config/service.py +++ b/src/ai/backend/manager/services/app_config/service.py @@ -14,6 +14,10 @@ ResolveAppConfigAction, ResolveAppConfigActionResult, ) +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, + ResolveBulkAppConfigActionResult, +) __all__ = ("AppConfigService",) @@ -59,3 +63,29 @@ async def resolve(self, action: ResolveAppConfigAction) -> ResolveAppConfigActio config=self._merge_configs(fragments), ) return ResolveAppConfigActionResult(app_config=app_config, user_id=action.user_id) + + async def resolve_bulk( + self, action: ResolveBulkAppConfigAction + ) -> ResolveBulkAppConfigActionResult: + """Resolve several merged ``AppConfig``s for one principal in a single query. + + Fetches the visible fragments of all ``config_names`` at once, groups them by name, + and deep-merges each in ``rank`` order. Returns one ``AppConfigData`` per requested + name, in request order (an unregistered name yields ``config = None``). + """ + scope = AppConfigResolveScope(domain_id=action.domain_id, user_id=action.user_id) + fragments = await self._fragment_repository.list_visible_fragments_bulk( + action.config_names, scope + ) + grouped: dict[str, list[AppConfigFragmentData]] = {} + for fragment in fragments: + grouped.setdefault(fragment.config_name, []).append(fragment) + app_configs = [ + AppConfigData( + config_name=config_name, + fragments=grouped.get(config_name, []), + config=self._merge_configs(grouped.get(config_name, [])), + ) + for config_name in action.config_names + ] + return ResolveBulkAppConfigActionResult(app_configs=app_configs, user_id=action.user_id) diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index 54eedd606b3..d2069527cab 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -689,3 +689,38 @@ async def test_unknown_config_name_returns_empty( AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), ) assert applicable == [] + + async def test_bulk_returns_visible_fragments_for_all_names_ordered( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments_bulk( + ["theme", "menu"], + AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + # public + the caller's domain + the caller's own user fragment, for both names. + expected = { + f.id + for f in fragments_across_scopes + if f.config_name in ("theme", "menu") + and ( + f.scope_type is AppConfigScopeType.PUBLIC + or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + ) + } + assert {f.id for f in applicable} == expected + # Grouped by config_name, rank-ordered within each. + assert [f.config_name for f in applicable] == sorted(f.config_name for f in applicable) + + async def test_bulk_empty_names_returns_empty( + self, + repository: AppConfigFragmentRepository, + fragments_across_scopes: list[AppConfigFragmentData], + ) -> None: + applicable = await repository.list_visible_fragments_bulk( + [], + AppConfigResolveScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ) + assert applicable == [] diff --git a/tests/unit/manager/services/app_config/test_service.py b/tests/unit/manager/services/app_config/test_service.py index 9abdf702d99..188d07b1013 100644 --- a/tests/unit/manager/services/app_config/test_service.py +++ b/tests/unit/manager/services/app_config/test_service.py @@ -8,18 +8,21 @@ import pytest +from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, - AppConfigScopeType, ) from ai.backend.manager.repositories.app_config_fragment.repository import ( AppConfigFragmentRepository, ) from ai.backend.manager.repositories.app_config_fragment.types import AppConfigResolveScope from ai.backend.manager.services.app_config.actions.resolve import ResolveAppConfigAction +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, +) from ai.backend.manager.services.app_config.service import AppConfigService _USER_UUID = uuid.uuid4() @@ -28,12 +31,16 @@ def _fragment( - *, scope_type: AppConfigScopeType, scope_id: str, config: dict[str, object] + *, + scope_type: AppConfigScopeType, + scope_id: str, + config: dict[str, object], + config_name: str = "theme", ) -> AppConfigFragmentData: now = datetime.now(UTC) return AppConfigFragmentData( id=AppConfigFragmentID(uuid.uuid4()), - config_name="theme", + config_name=config_name, scope_type=scope_type, scope_id=scope_id, config=config, @@ -91,3 +98,48 @@ async def test_resolve_empty_projects_config_to_none( assert result.app_config.fragments == [] assert result.app_config.config is None + + async def test_resolve_bulk_groups_by_name_and_merges_each( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + # Repo returns visible fragments for both names, (config_name, rank)-ordered. + theme_public = _fragment( + config_name="theme", + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + config={"theme": "light", "lang": "en"}, + ) + theme_user = _fragment( + config_name="theme", + scope_type=AppConfigScopeType.USER, + scope_id=str(_USER_ID), + config={"theme": "dark"}, + ) + menu_public = _fragment( + config_name="menu", + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + config={"items": ["a"]}, + ) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=[theme_public, theme_user, menu_public] + ) + + result = await service.resolve_bulk( + ResolveBulkAppConfigAction( + config_names=["theme", "menu", "unknown"], + domain_id=_DOMAIN_ID, + user_id=_USER_ID, + ) + ) + + # One AppConfigData per requested name, in request order. + assert [c.config_name for c in result.app_configs] == ["theme", "menu", "unknown"] + assert result.app_configs[0].config == {"theme": "dark", "lang": "en"} + assert result.app_configs[1].config == {"items": ["a"]} + assert result.app_configs[2].config is None # unregistered -> empty merge + assert result.user_id == _USER_ID + mock_fragment_repository.list_visible_fragments_bulk.assert_called_once_with( + ["theme", "menu", "unknown"], + AppConfigResolveScope(domain_id=_DOMAIN_ID, user_id=_USER_ID), + )