From af0276d92617ede4ceb09411fbce9fec145f5b4d Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 26 Jun 2026 14:00:03 +0900 Subject: [PATCH] feat(BA-6618): app_config_fragment bulk CRUD service layer Bulk create/update/purge for app_config_fragment at the service layer, on top of the repository primitives in #12426 (BA-6626): - Bulk actions carrying BulkConditional{Creator,Updater,Purger} payloads, service methods, and BulkActionProcessor wiring (per-entity RBAC extension point; no validator yet). - Partial-success results (succeeded + failed[index, message]). Co-Authored-By: Claude Opus 4.8 (1M context) --- changes/12401.feature.md | 1 + .../app_config_fragment/actions/base.py | 70 ++++++++++++++++- .../actions/bulk_create.py | 43 +++++++++++ .../app_config_fragment/actions/bulk_purge.py | 46 +++++++++++ .../actions/bulk_update.py | 45 +++++++++++ .../app_config_fragment/processors.py | 27 +++++++ .../services/app_config_fragment/service.py | 36 +++++++++ .../app_config_fragment/test_service.py | 76 +++++++++++++++++++ 8 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 changes/12401.feature.md create mode 100644 src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py create mode 100644 src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py create mode 100644 src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py diff --git a/changes/12401.feature.md b/changes/12401.feature.md new file mode 100644 index 00000000000..c1f44ec832b --- /dev/null +++ b/changes/12401.feature.md @@ -0,0 +1 @@ +Add bulk create / update / purge for app_config_fragment at the service layer (partial-success batches: each item is independently authorized by the allow-list write-gate, and rejected or failed items are reported per-item rather than failing the whole batch). diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/base.py b/src/ai/backend/manager/services/app_config_fragment/actions/base.py index 6d0a63fdd2c..667a48ac48f 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/base.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/base.py @@ -1,14 +1,22 @@ from __future__ import annotations +from dataclasses import dataclass from typing import override -from ai.backend.common.data.permission.types import EntityType +from ai.backend.common.data.permission.types import EntityType, RBACElementType +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult from ai.backend.manager.actions.action.single_entity import ( BaseSingleEntityAction, BaseSingleEntityActionResult, ) -from ai.backend.manager.actions.action.types import FieldData +from ai.backend.manager.actions.action.types import ActionTarget, FieldData +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentBulkItemError, + AppConfigFragmentData, +) +from ai.backend.manager.data.permission.types import RBACElementRef class AppConfigFragmentScopeAction(BaseScopeAction): @@ -39,3 +47,61 @@ def field_data(self) -> FieldData | None: class AppConfigFragmentSingleEntityActionResult(BaseSingleEntityActionResult): pass + + +@dataclass(frozen=True) +class AppConfigFragmentBulkTarget(ActionTarget): + """One existing fragment touched by a bulk update / purge, exposed for per-entity RBAC. + + Bulk create has no targets — the fragments do not exist yet, so its action returns an + empty sequence. The target lets a future ``BulkActionValidator`` iterate the batch; + richer per-item data stays on the action's ``bulk_*`` payload. + """ + + fragment_id: AppConfigFragmentID + + @override + def to_rbac_element_ref(self) -> RBACElementRef: + return RBACElementRef( + element_type=RBACElementType.APP_CONFIG_FRAGMENT, element_id=str(self.fragment_id) + ) + + +class AppConfigFragmentBulkAction(BaseBulkAction[AppConfigFragmentBulkTarget]): + """Base for bulk app config fragment mutations (bulk create / update / purge). + + Bulk operations span many fragments (potentially across scopes), so there is no single + entity id to report. Each concrete action exposes its per-item targets via ``targets()`` + so a ``BulkActionValidator`` can authorize the batch per entity. No validator is wired + yet — authorization currently lives in the repository's allow-list write-gate. + """ + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG_FRAGMENT + + @override + def entity_id(self) -> str | None: + return None + + +@dataclass +class AppConfigFragmentBulkActionResult(BaseBulkActionResult): + """Partial-success result of a bulk app config fragment mutation. + + ``succeeded`` are the affected fragments; ``failed`` are the rejected/failed items with + their batch index and reason. ``element_refs`` covers the succeeded fragments only. + """ + + succeeded: list[AppConfigFragmentData] + failed: list[AppConfigFragmentBulkItemError] + + @override + def element_refs(self) -> list[RBACElementRef]: + return [ + RBACElementRef( + element_type=RBACElementType.APP_CONFIG_FRAGMENT, element_id=str(fragment.id) + ) + for fragment in self.succeeded + ] diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py new file mode 100644 index 00000000000..12c47b5e752 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import override + +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.repositories.app_config_fragment.creators import ( + GatedAppConfigFragmentCreate, +) +from ai.backend.manager.services.app_config_fragment.actions.base import ( + AppConfigFragmentBulkAction, + AppConfigFragmentBulkActionResult, + AppConfigFragmentBulkTarget, +) + + +@dataclass +class BulkCreateAppConfigFragmentAction(AppConfigFragmentBulkAction): + """Create many fragments with per-item partial success. + + Each item pairs a fragment ``CreatorSpec`` with its own allow-list write-gate + (``GatedAppConfigFragmentCreate.only_if``). The repository checks every gate and + inserts the allowed rows in one transaction; a rejected gate or a failed insert is + reported per item while the rest are created. + """ + + items: Sequence[GatedAppConfigFragmentCreate] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.CREATE + + @override + def targets(self) -> Sequence[AppConfigFragmentBulkTarget]: + # Fragments do not exist yet, so there are no per-entity targets to validate. + return [] + + +@dataclass +class BulkCreateAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult): + pass diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py new file mode 100644 index 00000000000..c1188665fd0 --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast, override + +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.base import Purger +from ai.backend.manager.services.app_config_fragment.actions.base import ( + AppConfigFragmentBulkAction, + AppConfigFragmentBulkActionResult, + AppConfigFragmentBulkTarget, +) + + +@dataclass +class BulkPurgeAppConfigFragmentAction(AppConfigFragmentBulkAction): + """Purge many fragments with per-item partial success. + + No allow-list gate — a fragment row exists only while its ``(config_name, + scope_type)`` allow-list entry does (FK with cascade). A missing target or a + failed delete is reported per item while the rest are purged. Purging the + allow-list entry itself cascades to its fragments without going through this + action. + """ + + purgers: Sequence[Purger[AppConfigFragmentRow]] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.PURGE + + @override + def targets(self) -> Sequence[AppConfigFragmentBulkTarget]: + return [ + AppConfigFragmentBulkTarget(fragment_id=cast(AppConfigFragmentID, purger.pk_value)) + for purger in self.purgers + ] + + +@dataclass +class BulkPurgeAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult): + pass diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py new file mode 100644 index 00000000000..67813d4d96d --- /dev/null +++ b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast, override + +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.base import Updater +from ai.backend.manager.services.app_config_fragment.actions.base import ( + AppConfigFragmentBulkAction, + AppConfigFragmentBulkActionResult, + AppConfigFragmentBulkTarget, +) + + +@dataclass +class BulkUpdateAppConfigFragmentAction(AppConfigFragmentBulkAction): + """Update many fragments' ``config`` with per-item partial success. + + No allow-list gate — a fragment row exists only while its ``(config_name, + scope_type)`` allow-list entry does (FK with cascade), so an existing fragment is + always writable at its own scope. A missing target or a failed update is reported + per item while the rest are updated. + """ + + updaters: Sequence[Updater[AppConfigFragmentRow]] + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.UPDATE + + @override + def targets(self) -> Sequence[AppConfigFragmentBulkTarget]: + return [ + AppConfigFragmentBulkTarget(fragment_id=cast(AppConfigFragmentID, updater.pk_value)) + for updater in self.updaters + ] + + +@dataclass +class BulkUpdateAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult): + pass diff --git a/src/ai/backend/manager/services/app_config_fragment/processors.py b/src/ai/backend/manager/services/app_config_fragment/processors.py index 0dcd7cd7dc9..0d81a523b30 100644 --- a/src/ai/backend/manager/services/app_config_fragment/processors.py +++ b/src/ai/backend/manager/services/app_config_fragment/processors.py @@ -11,6 +11,18 @@ AdminSearchAppConfigFragmentAction, AdminSearchAppConfigFragmentActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create import ( + BulkCreateAppConfigFragmentAction, + BulkCreateAppConfigFragmentActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_purge import ( + BulkPurgeAppConfigFragmentAction, + BulkPurgeAppConfigFragmentActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update import ( + BulkUpdateAppConfigFragmentAction, + BulkUpdateAppConfigFragmentActionResult, +) from ai.backend.manager.services.app_config_fragment.actions.create import ( CreateAppConfigFragmentAction, CreateAppConfigFragmentActionResult, @@ -51,6 +63,15 @@ class AppConfigFragmentProcessors(AbstractProcessorPackage): purge: SingleEntityActionProcessor[ PurgeAppConfigFragmentAction, PurgeAppConfigFragmentActionResult ] + bulk_create: BulkActionProcessor[ + BulkCreateAppConfigFragmentAction, BulkCreateAppConfigFragmentActionResult + ] + bulk_update: BulkActionProcessor[ + BulkUpdateAppConfigFragmentAction, BulkUpdateAppConfigFragmentActionResult + ] + bulk_purge: BulkActionProcessor[ + BulkPurgeAppConfigFragmentAction, BulkPurgeAppConfigFragmentActionResult + ] def __init__( self, @@ -63,6 +84,9 @@ def __init__( self.scoped_search = BulkActionProcessor(service.scoped_search, monitors=action_monitors) self.update = SingleEntityActionProcessor(service.update, action_monitors) self.purge = SingleEntityActionProcessor(service.purge, action_monitors) + self.bulk_create = BulkActionProcessor(service.bulk_create, monitors=action_monitors) + self.bulk_update = BulkActionProcessor(service.bulk_update, monitors=action_monitors) + self.bulk_purge = BulkActionProcessor(service.bulk_purge, monitors=action_monitors) @override def supported_actions(self) -> list[ActionSpec]: @@ -73,4 +97,7 @@ def supported_actions(self) -> list[ActionSpec]: ScopedSearchAppConfigFragmentAction.spec(), UpdateAppConfigFragmentAction.spec(), PurgeAppConfigFragmentAction.spec(), + BulkCreateAppConfigFragmentAction.spec(), + BulkUpdateAppConfigFragmentAction.spec(), + BulkPurgeAppConfigFragmentAction.spec(), ] diff --git a/src/ai/backend/manager/services/app_config_fragment/service.py b/src/ai/backend/manager/services/app_config_fragment/service.py index dc85d728bb5..d611e58148d 100644 --- a/src/ai/backend/manager/services/app_config_fragment/service.py +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -7,6 +7,18 @@ AdminSearchAppConfigFragmentAction, AdminSearchAppConfigFragmentActionResult, ) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create import ( + BulkCreateAppConfigFragmentAction, + BulkCreateAppConfigFragmentActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_purge import ( + BulkPurgeAppConfigFragmentAction, + BulkPurgeAppConfigFragmentActionResult, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update import ( + BulkUpdateAppConfigFragmentAction, + BulkUpdateAppConfigFragmentActionResult, +) from ai.backend.manager.services.app_config_fragment.actions.create import ( CreateAppConfigFragmentAction, CreateAppConfigFragmentActionResult, @@ -95,3 +107,27 @@ async def purge( ) -> PurgeAppConfigFragmentActionResult: data = await self._repository.purge(action.purger) return PurgeAppConfigFragmentActionResult(fragment=data) + + async def bulk_create( + self, action: BulkCreateAppConfigFragmentAction + ) -> BulkCreateAppConfigFragmentActionResult: + result = await self._repository.bulk_create(action.items) + return BulkCreateAppConfigFragmentActionResult( + succeeded=result.succeeded, failed=result.failed + ) + + async def bulk_update( + self, action: BulkUpdateAppConfigFragmentAction + ) -> BulkUpdateAppConfigFragmentActionResult: + result = await self._repository.bulk_update(action.updaters) + return BulkUpdateAppConfigFragmentActionResult( + succeeded=result.succeeded, failed=result.failed + ) + + async def bulk_purge( + self, action: BulkPurgeAppConfigFragmentAction + ) -> BulkPurgeAppConfigFragmentActionResult: + result = await self._repository.bulk_purge(action.purgers) + return BulkPurgeAppConfigFragmentActionResult( + succeeded=result.succeeded, failed=result.failed + ) diff --git a/tests/unit/manager/services/app_config_fragment/test_service.py b/tests/unit/manager/services/app_config_fragment/test_service.py index 838c0ca819e..6b597b649b5 100644 --- a/tests/unit/manager/services/app_config_fragment/test_service.py +++ b/tests/unit/manager/services/app_config_fragment/test_service.py @@ -14,6 +14,7 @@ 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 ( + AppConfigFragmentBulkWriteResult, AppConfigFragmentData, AppConfigFragmentSearchResult, ) @@ -22,6 +23,7 @@ from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, + GatedAppConfigFragmentCreate, ) from ai.backend.manager.repositories.app_config_fragment.repository import ( AppConfigFragmentRepository, @@ -39,6 +41,15 @@ from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( AdminSearchAppConfigFragmentAction, ) +from ai.backend.manager.services.app_config_fragment.actions.bulk_create import ( + BulkCreateAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_purge import ( + BulkPurgeAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.bulk_update import ( + BulkUpdateAppConfigFragmentAction, +) from ai.backend.manager.services.app_config_fragment.actions.create import ( CreateAppConfigFragmentAction, ) @@ -221,6 +232,71 @@ async def test_purge_delegates_to_repository( assert result.fragment == fragment mock_repository.purge.assert_called_once_with(purger) + # --- bulk --- + + async def test_bulk_create_delegates_to_repository( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + fragments = [_fragment(), _fragment()] + mock_repository.bulk_create = AsyncMock( + return_value=AppConfigFragmentBulkWriteResult(succeeded=fragments, failed=[]) + ) + items = [ + GatedAppConfigFragmentCreate( + spec=AppConfigFragmentCreatorSpec( + config_name="theme", + scope_type=AppConfigScopeType.USER, + scope_id=_USER_ID, + config={"k": "v"}, + ), + only_if=ExistsQuerier(row_class=AppConfigAllowListRow), + ) + ] + + result = await service.bulk_create(BulkCreateAppConfigFragmentAction(items=items)) + + assert result.succeeded == fragments + assert result.failed == [] + mock_repository.bulk_create.assert_called_once_with(items) + + async def test_bulk_update_delegates_to_repository( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + fragments = [_fragment(), _fragment()] + mock_repository.bulk_update = AsyncMock( + return_value=AppConfigFragmentBulkWriteResult(succeeded=fragments, failed=[]) + ) + updaters = [ + Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"b": 2})), + pk_value=fragments[0].id, + ) + ] + + result = await service.bulk_update(BulkUpdateAppConfigFragmentAction(updaters=updaters)) + + assert result.succeeded == fragments + assert result.failed == [] + mock_repository.bulk_update.assert_called_once_with(updaters) + + async def test_bulk_purge_delegates_to_repository( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + fragments = [_fragment(), _fragment()] + mock_repository.bulk_purge = AsyncMock( + return_value=AppConfigFragmentBulkWriteResult(succeeded=fragments, failed=[]) + ) + purgers = [ + Purger(row_class=AppConfigFragmentRow, pk_value=fragments[0].id), + Purger(row_class=AppConfigFragmentRow, pk_value=fragments[1].id), + ] + + result = await service.bulk_purge(BulkPurgeAppConfigFragmentAction(purgers=purgers)) + + assert result.succeeded == fragments + assert result.failed == [] + mock_repository.bulk_purge.assert_called_once_with(purgers) + class TestCreateActionScope: """The create action acts at the fragment's own scope — not admin-only/global."""