Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/12401.feature.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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]:
Expand All @@ -73,4 +97,7 @@ def supported_actions(self) -> list[ActionSpec]:
ScopedSearchAppConfigFragmentAction.spec(),
UpdateAppConfigFragmentAction.spec(),
PurgeAppConfigFragmentAction.spec(),
BulkCreateAppConfigFragmentAction.spec(),
BulkUpdateAppConfigFragmentAction.spec(),
BulkPurgeAppConfigFragmentAction.spec(),
]
36 changes: 36 additions & 0 deletions src/ai/backend/manager/services/app_config_fragment/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Loading
Loading