Skip to content

Commit bebbaa6

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 152cfac commit bebbaa6

8 files changed

Lines changed: 342 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.repositories.app_config_fragment.creators import (
9+
GatedAppConfigFragmentCreate,
10+
)
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 with per-item partial success.
21+
22+
Each item pairs a fragment ``CreatorSpec`` with its own allow-list write-gate
23+
(``GatedAppConfigFragmentCreate.only_if``). The repository checks every gate and
24+
inserts the allowed rows in one transaction; a rejected gate or a failed insert is
25+
reported per item while the rest are created.
26+
"""
27+
28+
items: Sequence[GatedAppConfigFragmentCreate]
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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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_fragment.row import AppConfigFragmentRow
10+
from ai.backend.manager.repositories.base import Purger
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 BulkPurgeAppConfigFragmentAction(AppConfigFragmentBulkAction):
20+
"""Purge many fragments with per-item partial success.
21+
22+
No allow-list gate — a fragment row exists only while its ``(config_name,
23+
scope_type)`` allow-list entry does (FK with cascade). A missing target or a
24+
failed delete is reported per item while the rest are purged. Purging the
25+
allow-list entry itself cascades to its fragments without going through this
26+
action.
27+
"""
28+
29+
purgers: Sequence[Purger[AppConfigFragmentRow]]
30+
31+
@override
32+
@classmethod
33+
def operation_type(cls) -> ActionOperationType:
34+
return ActionOperationType.PURGE
35+
36+
@override
37+
def targets(self) -> Sequence[AppConfigFragmentBulkTarget]:
38+
return [
39+
AppConfigFragmentBulkTarget(fragment_id=cast(AppConfigFragmentID, purger.pk_value))
40+
for purger in self.purgers
41+
]
42+
43+
44+
@dataclass
45+
class BulkPurgeAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult):
46+
pass
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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_fragment.row import AppConfigFragmentRow
10+
from ai.backend.manager.repositories.base import Updater
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 BulkUpdateAppConfigFragmentAction(AppConfigFragmentBulkAction):
20+
"""Update many fragments' ``config`` with per-item partial success.
21+
22+
No allow-list gate — a fragment row exists only while its ``(config_name,
23+
scope_type)`` allow-list entry does (FK with cascade), so an existing fragment is
24+
always writable at its own scope. A missing target or a failed update is reported
25+
per item while the rest are updated.
26+
"""
27+
28+
updaters: Sequence[Updater[AppConfigFragmentRow]]
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(fragment_id=cast(AppConfigFragmentID, updater.pk_value))
39+
for updater in self.updaters
40+
]
41+
42+
43+
@dataclass
44+
class BulkUpdateAppConfigFragmentActionResult(AppConfigFragmentBulkActionResult):
45+
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,
@@ -95,3 +107,27 @@ async def purge(
95107
) -> PurgeAppConfigFragmentActionResult:
96108
data = await self._repository.purge(action.purger)
97109
return PurgeAppConfigFragmentActionResult(fragment=data)
110+
111+
async def bulk_create(
112+
self, action: BulkCreateAppConfigFragmentAction
113+
) -> BulkCreateAppConfigFragmentActionResult:
114+
result = await self._repository.bulk_create(action.items)
115+
return BulkCreateAppConfigFragmentActionResult(
116+
succeeded=result.succeeded, failed=result.failed
117+
)
118+
119+
async def bulk_update(
120+
self, action: BulkUpdateAppConfigFragmentAction
121+
) -> BulkUpdateAppConfigFragmentActionResult:
122+
result = await self._repository.bulk_update(action.updaters)
123+
return BulkUpdateAppConfigFragmentActionResult(
124+
succeeded=result.succeeded, failed=result.failed
125+
)
126+
127+
async def bulk_purge(
128+
self, action: BulkPurgeAppConfigFragmentAction
129+
) -> BulkPurgeAppConfigFragmentActionResult:
130+
result = await self._repository.bulk_purge(action.purgers)
131+
return BulkPurgeAppConfigFragmentActionResult(
132+
succeeded=result.succeeded, failed=result.failed
133+
)

0 commit comments

Comments
 (0)