Skip to content

Commit 60bf490

Browse files
jopemachineclaude
andcommitted
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) <noreply@anthropic.com>
1 parent ef67230 commit 60bf490

8 files changed

Lines changed: 365 additions & 2 deletions

File tree

changes/12401.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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).

src/ai/backend/manager/services/app_config_fragment/actions/base.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
from __future__ import annotations
22

3+
from dataclasses import dataclass
34
from typing import override
45

5-
from ai.backend.common.data.permission.types import EntityType
6+
from ai.backend.common.data.permission.types import EntityType, RBACElementType
7+
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
8+
from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult
69
from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult
710
from ai.backend.manager.actions.action.single_entity import (
811
BaseSingleEntityAction,
912
BaseSingleEntityActionResult,
1013
)
11-
from ai.backend.manager.actions.action.types import FieldData
14+
from ai.backend.manager.actions.action.types import ActionTarget, FieldData
15+
from ai.backend.manager.data.app_config_fragment.types import (
16+
AppConfigFragmentBulkItemError,
17+
AppConfigFragmentData,
18+
)
19+
from ai.backend.manager.data.permission.types import RBACElementRef
1220

1321

1422
class AppConfigFragmentScopeAction(BaseScopeAction):
@@ -39,3 +47,61 @@ def field_data(self) -> FieldData | None:
3947

4048
class AppConfigFragmentSingleEntityActionResult(BaseSingleEntityActionResult):
4149
pass
50+
51+
52+
@dataclass(frozen=True)
53+
class AppConfigFragmentBulkTarget(ActionTarget):
54+
"""One existing fragment touched by a bulk update / purge, exposed for per-entity RBAC.
55+
56+
Bulk create has no targets — the fragments do not exist yet, so its action returns an
57+
empty sequence. The target lets a future ``BulkActionValidator`` iterate the batch;
58+
richer per-item data stays on the action's ``bulk_*`` payload.
59+
"""
60+
61+
fragment_id: AppConfigFragmentID
62+
63+
@override
64+
def to_rbac_element_ref(self) -> RBACElementRef:
65+
return RBACElementRef(
66+
element_type=RBACElementType.APP_CONFIG_FRAGMENT, element_id=str(self.fragment_id)
67+
)
68+
69+
70+
class AppConfigFragmentBulkAction(BaseBulkAction[AppConfigFragmentBulkTarget]):
71+
"""Base for bulk app config fragment mutations (bulk create / update / purge).
72+
73+
Bulk operations span many fragments (potentially across scopes), so there is no single
74+
entity id to report. Each concrete action exposes its per-item targets via ``targets()``
75+
so a ``BulkActionValidator`` can authorize the batch per entity. No validator is wired
76+
yet — authorization currently lives in the repository's allow-list write-gate.
77+
"""
78+
79+
@override
80+
@classmethod
81+
def entity_type(cls) -> EntityType:
82+
return EntityType.APP_CONFIG_FRAGMENT
83+
84+
@override
85+
def entity_id(self) -> str | None:
86+
return None
87+
88+
89+
@dataclass
90+
class AppConfigFragmentBulkActionResult(BaseBulkActionResult):
91+
"""Partial-success result of a bulk app config fragment mutation.
92+
93+
``succeeded`` are the affected fragments; ``failed`` are the rejected/failed items with
94+
their batch index and reason. ``element_refs`` covers the succeeded fragments only.
95+
"""
96+
97+
succeeded: list[AppConfigFragmentData]
98+
failed: list[AppConfigFragmentBulkItemError]
99+
100+
@override
101+
def element_refs(self) -> list[RBACElementRef]:
102+
return [
103+
RBACElementRef(
104+
element_type=RBACElementType.APP_CONFIG_FRAGMENT, element_id=str(fragment.id)
105+
)
106+
for fragment in self.succeeded
107+
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
from typing import override
6+
7+
from ai.backend.manager.actions.types import ActionOperationType
8+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
9+
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
10+
from ai.backend.manager.repositories.base import BulkConditionalCreator
11+
from ai.backend.manager.services.app_config_fragment.actions.base import (
12+
AppConfigFragmentBulkAction,
13+
AppConfigFragmentBulkActionResult,
14+
AppConfigFragmentBulkTarget,
15+
)
16+
17+
18+
@dataclass
19+
class BulkCreateAppConfigFragmentAction(AppConfigFragmentBulkAction):
20+
"""Create many fragments in a single atomic batch (all-or-nothing).
21+
22+
Carries one ``BulkConditionalCreator``: each item pairs a fragment ``CreatorSpec`` with its
23+
own allow-list write-gate (``ConditionalCreator.only_if``). The repository checks every gate
24+
and inserts all rows in one transaction; a rejected gate or a failed insert rolls back the
25+
whole batch.
26+
"""
27+
28+
bulk_creator: BulkConditionalCreator[AppConfigFragmentRow, AppConfigAllowListRow]
29+
30+
@override
31+
@classmethod
32+
def operation_type(cls) -> ActionOperationType:
33+
return ActionOperationType.CREATE
34+
35+
@override
36+
def targets(self) -> Sequence[AppConfigFragmentBulkTarget]:
37+
# Fragments do not exist yet, so there are no per-entity targets to validate.
38+
return []
39+
40+
41+
@dataclass
42+
class BulkCreateAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult):
43+
pass
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
from typing import cast, override
6+
7+
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
8+
from ai.backend.manager.actions.types import ActionOperationType
9+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
10+
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
11+
from ai.backend.manager.repositories.base import BulkConditionalPurger
12+
from ai.backend.manager.services.app_config_fragment.actions.base import (
13+
AppConfigFragmentBulkAction,
14+
AppConfigFragmentBulkActionResult,
15+
AppConfigFragmentBulkTarget,
16+
)
17+
18+
19+
@dataclass
20+
class BulkPurgeAppConfigFragmentAction(AppConfigFragmentBulkAction):
21+
"""Purge many fragments in a single atomic batch (all-or-nothing).
22+
23+
Carries one ``BulkConditionalPurger``: each item pairs a ``Purger`` with its own allow-list
24+
write-gate (``ConditionalPurger.only_if``) against the target fragment's
25+
``(config_name, scope_type)``.
26+
"""
27+
28+
bulk_purger: BulkConditionalPurger[AppConfigFragmentRow, AppConfigAllowListRow]
29+
30+
@override
31+
@classmethod
32+
def operation_type(cls) -> ActionOperationType:
33+
return ActionOperationType.PURGE
34+
35+
@override
36+
def targets(self) -> Sequence[AppConfigFragmentBulkTarget]:
37+
return [
38+
AppConfigFragmentBulkTarget(
39+
fragment_id=cast(AppConfigFragmentID, conditional.purger.pk_value)
40+
)
41+
for conditional in self.bulk_purger.purgers
42+
]
43+
44+
45+
@dataclass
46+
class BulkPurgeAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult):
47+
pass
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
from typing import cast, override
6+
7+
from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID
8+
from ai.backend.manager.actions.types import ActionOperationType
9+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
10+
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
11+
from ai.backend.manager.repositories.base import BulkConditionalUpdater
12+
from ai.backend.manager.services.app_config_fragment.actions.base import (
13+
AppConfigFragmentBulkAction,
14+
AppConfigFragmentBulkActionResult,
15+
AppConfigFragmentBulkTarget,
16+
)
17+
18+
19+
@dataclass
20+
class BulkUpdateAppConfigFragmentAction(AppConfigFragmentBulkAction):
21+
"""Update many fragments' ``config`` in a single atomic batch (all-or-nothing).
22+
23+
Carries one ``BulkConditionalUpdater``: each item pairs an ``Updater`` with its own
24+
allow-list write-gate (``ConditionalUpdater.only_if``) against the target fragment's
25+
``(config_name, scope_type)``; ``rank`` is not updatable.
26+
"""
27+
28+
bulk_updater: BulkConditionalUpdater[AppConfigFragmentRow, AppConfigAllowListRow]
29+
30+
@override
31+
@classmethod
32+
def operation_type(cls) -> ActionOperationType:
33+
return ActionOperationType.UPDATE
34+
35+
@override
36+
def targets(self) -> Sequence[AppConfigFragmentBulkTarget]:
37+
return [
38+
AppConfigFragmentBulkTarget(
39+
fragment_id=cast(AppConfigFragmentID, conditional.updater.pk_value)
40+
)
41+
for conditional in self.bulk_updater.updaters
42+
]
43+
44+
45+
@dataclass
46+
class BulkUpdateAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult):
47+
pass

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111
AdminSearchAppConfigFragmentAction,
1212
AdminSearchAppConfigFragmentActionResult,
1313
)
14+
from ai.backend.manager.services.app_config_fragment.actions.bulk_create import (
15+
BulkCreateAppConfigFragmentAction,
16+
BulkCreateAppConfigFragmentActionResult,
17+
)
18+
from ai.backend.manager.services.app_config_fragment.actions.bulk_purge import (
19+
BulkPurgeAppConfigFragmentAction,
20+
BulkPurgeAppConfigFragmentActionResult,
21+
)
22+
from ai.backend.manager.services.app_config_fragment.actions.bulk_update import (
23+
BulkUpdateAppConfigFragmentAction,
24+
BulkUpdateAppConfigFragmentActionResult,
25+
)
1426
from ai.backend.manager.services.app_config_fragment.actions.create import (
1527
CreateAppConfigFragmentAction,
1628
CreateAppConfigFragmentActionResult,
@@ -51,6 +63,15 @@ class AppConfigFragmentProcessors(AbstractProcessorPackage):
5163
purge: SingleEntityActionProcessor[
5264
PurgeAppConfigFragmentAction, PurgeAppConfigFragmentActionResult
5365
]
66+
bulk_create: BulkActionProcessor[
67+
BulkCreateAppConfigFragmentAction, BulkCreateAppConfigFragmentActionResult
68+
]
69+
bulk_update: BulkActionProcessor[
70+
BulkUpdateAppConfigFragmentAction, BulkUpdateAppConfigFragmentActionResult
71+
]
72+
bulk_purge: BulkActionProcessor[
73+
BulkPurgeAppConfigFragmentAction, BulkPurgeAppConfigFragmentActionResult
74+
]
5475

5576
def __init__(
5677
self,
@@ -63,6 +84,9 @@ def __init__(
6384
self.scoped_search = BulkActionProcessor(service.scoped_search, monitors=action_monitors)
6485
self.update = SingleEntityActionProcessor(service.update, action_monitors)
6586
self.purge = SingleEntityActionProcessor(service.purge, action_monitors)
87+
self.bulk_create = BulkActionProcessor(service.bulk_create, monitors=action_monitors)
88+
self.bulk_update = BulkActionProcessor(service.bulk_update, monitors=action_monitors)
89+
self.bulk_purge = BulkActionProcessor(service.bulk_purge, monitors=action_monitors)
6690

6791
@override
6892
def supported_actions(self) -> list[ActionSpec]:
@@ -73,4 +97,7 @@ def supported_actions(self) -> list[ActionSpec]:
7397
ScopedSearchAppConfigFragmentAction.spec(),
7498
UpdateAppConfigFragmentAction.spec(),
7599
PurgeAppConfigFragmentAction.spec(),
100+
BulkCreateAppConfigFragmentAction.spec(),
101+
BulkUpdateAppConfigFragmentAction.spec(),
102+
BulkPurgeAppConfigFragmentAction.spec(),
76103
]

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@
77
AdminSearchAppConfigFragmentAction,
88
AdminSearchAppConfigFragmentActionResult,
99
)
10+
from ai.backend.manager.services.app_config_fragment.actions.bulk_create import (
11+
BulkCreateAppConfigFragmentAction,
12+
BulkCreateAppConfigFragmentActionResult,
13+
)
14+
from ai.backend.manager.services.app_config_fragment.actions.bulk_purge import (
15+
BulkPurgeAppConfigFragmentAction,
16+
BulkPurgeAppConfigFragmentActionResult,
17+
)
18+
from ai.backend.manager.services.app_config_fragment.actions.bulk_update import (
19+
BulkUpdateAppConfigFragmentAction,
20+
BulkUpdateAppConfigFragmentActionResult,
21+
)
1022
from ai.backend.manager.services.app_config_fragment.actions.create import (
1123
CreateAppConfigFragmentAction,
1224
CreateAppConfigFragmentActionResult,
@@ -93,3 +105,27 @@ async def purge(
93105
) -> PurgeAppConfigFragmentActionResult:
94106
data = await self._repository.purge(action.purger, action.only_if)
95107
return PurgeAppConfigFragmentActionResult(fragment=data)
108+
109+
async def bulk_create(
110+
self, action: BulkCreateAppConfigFragmentAction
111+
) -> BulkCreateAppConfigFragmentActionResult:
112+
result = await self._repository.bulk_create(action.bulk_creator)
113+
return BulkCreateAppConfigFragmentActionResult(
114+
succeeded=result.succeeded, failed=result.failed
115+
)
116+
117+
async def bulk_update(
118+
self, action: BulkUpdateAppConfigFragmentAction
119+
) -> BulkUpdateAppConfigFragmentActionResult:
120+
result = await self._repository.bulk_update(action.bulk_updater)
121+
return BulkUpdateAppConfigFragmentActionResult(
122+
succeeded=result.succeeded, failed=result.failed
123+
)
124+
125+
async def bulk_purge(
126+
self, action: BulkPurgeAppConfigFragmentAction
127+
) -> BulkPurgeAppConfigFragmentActionResult:
128+
result = await self._repository.bulk_purge(action.bulk_purger)
129+
return BulkPurgeAppConfigFragmentActionResult(
130+
succeeded=result.succeeded, failed=result.failed
131+
)

0 commit comments

Comments
 (0)