Skip to content

Commit 152cfac

Browse files
jopemachineclaude
andcommitted
feat(BA-6626): app_config_fragment bulk repository layer
Build the app_config_fragment bulk repository on the conditional-bulk primitives (#12429): - AppConfigFragmentCreatorSpec as a plain CreatorSpec with scope-based rank (public < domain < user). - bulk_create / bulk_update / bulk_purge in db_source + repository, returning the AppConfigFragmentBulkWriteResult data type (succeeded + failed[index, message]) — partial success via the WriteOps.bulk_conditional_*_partial primitives. - Single create rewritten to gate + plain create (no next-value); GatedWrite removed. - Repository unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 054589a commit 152cfac

6 files changed

Lines changed: 385 additions & 2 deletions

File tree

changes/12426.misc.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `app_config_fragment` bulk repository operations (`bulk_create`/`bulk_update`/`bulk_purge` with per-item partial success): bulk create pairs each item with its allow-list write-gate, bulk update/purge report missing targets per item, and the `AppConfigFragmentBulkWriteResult` data type carries the partial result

src/ai/backend/manager/data/app_config_fragment/types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,23 @@ class AppConfigFragmentSearchResult:
2929
total_count: int
3030
has_next_page: bool
3131
has_previous_page: bool
32+
33+
34+
@dataclass(frozen=True)
35+
class AppConfigFragmentBulkItemError:
36+
"""One failed item of a partial bulk mutation: its batch position and a reason."""
37+
38+
index: int
39+
message: str
40+
41+
42+
@dataclass(frozen=True)
43+
class AppConfigFragmentBulkWriteResult:
44+
"""Partial-success result of a bulk mutation.
45+
46+
``succeeded`` are the fragments that were created/updated/purged; ``failed`` are the items
47+
whose gate was rejected or whose write failed, each with its batch ``index`` and a reason.
48+
"""
49+
50+
succeeded: list[AppConfigFragmentData]
51+
failed: list[AppConfigFragmentBulkItemError]

src/ai/backend/manager/repositories/app_config_fragment/creators.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from typing import Any, override
77

88
from ai.backend.common.data.app_config.types import AppConfigScopeType
9+
from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow
910
from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow
10-
from ai.backend.manager.repositories.base import CreatorSpec
11+
from ai.backend.manager.repositories.base import CreatorSpec, ExistsQuerier
1112

1213

1314
@dataclass
@@ -31,3 +32,15 @@ def build_row(self) -> AppConfigFragmentRow:
3132
scope_id=self.scope_id,
3233
config=self.config,
3334
)
35+
36+
37+
@dataclass(frozen=True)
38+
class GatedAppConfigFragmentCreate:
39+
"""One bulk-create item: a fragment spec paired with its allow-list write-gate.
40+
41+
Only creation is gated — update/purge of an existing fragment need no gate because
42+
the FK to the allow-list guarantees the entry exists while the fragment does.
43+
"""
44+
45+
spec: AppConfigFragmentCreatorSpec
46+
only_if: ExistsQuerier[AppConfigAllowListRow]

src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy
1414
from ai.backend.common.resilience.resilience import Resilience
1515
from ai.backend.manager.data.app_config_fragment.types import (
16+
AppConfigFragmentBulkItemError,
17+
AppConfigFragmentBulkWriteResult,
1618
AppConfigFragmentData,
1719
AppConfigFragmentSearchResult,
1820
)
@@ -25,10 +27,13 @@
2527
from ai.backend.manager.models.scopes import SearchScope
2628
from ai.backend.manager.repositories.app_config_fragment.creators import (
2729
AppConfigFragmentCreatorSpec,
30+
GatedAppConfigFragmentCreate,
2831
)
2932
from ai.backend.manager.repositories.base import (
3033
BatchQuerier,
34+
BulkCreator,
3135
Creator,
36+
CreatorSpec,
3237
ExistsQuerier,
3338
Purger,
3439
Querier,
@@ -58,6 +63,25 @@
5863
)
5964

6065

66+
def _not_found_failures(
67+
pk_values: Sequence[object],
68+
succeeded_ids: set[AppConfigFragmentID],
69+
errored_indices: set[int],
70+
) -> list[AppConfigFragmentBulkItemError]:
71+
"""Per-item not-found errors for targets that neither succeeded nor errored.
72+
73+
The generic partial bulk ops silently skip a missing primary key (no row returned,
74+
no error), so the missing targets are reconstructed from the input here.
75+
"""
76+
return [
77+
AppConfigFragmentBulkItemError(
78+
index=index, message=f"App config fragment {pk_value} not found"
79+
)
80+
for index, pk_value in enumerate(pk_values)
81+
if index not in errored_indices and pk_value not in succeeded_ids
82+
]
83+
84+
6185
class AppConfigFragmentDBSource:
6286
"""Database source for app config fragment operations."""
6387

@@ -112,6 +136,110 @@ async def purge(self, purger: Purger[AppConfigFragmentRow]) -> AppConfigFragment
112136
raise AppConfigFragmentNotFound(f"App config fragment {purger.pk_value} not found")
113137
return result.row.to_data()
114138

139+
@app_config_fragment_db_source_resilience.apply()
140+
async def bulk_create(
141+
self,
142+
items: Sequence[GatedAppConfigFragmentCreate],
143+
) -> AppConfigFragmentBulkWriteResult:
144+
"""Create many fragments with partial success.
145+
146+
Every item's allow-list gate is checked in the same transaction as the writes,
147+
so the gate checks and the inserts commit atomically. Rejected items are
148+
reported in ``failed`` with their batch index; the allowed items are inserted
149+
each in its own savepoint, so a failed insert (e.g. a duplicate natural key)
150+
is also reported per item while the rest are created.
151+
"""
152+
async with self._ops.write_ops() as w:
153+
allowed: list[tuple[int, CreatorSpec[AppConfigFragmentRow]]] = []
154+
failed: list[AppConfigFragmentBulkItemError] = []
155+
for index, item in enumerate(items):
156+
if await w.exists(item.only_if):
157+
allowed.append((index, item.spec))
158+
else:
159+
failed.append(
160+
AppConfigFragmentBulkItemError(
161+
index=index,
162+
message=(
163+
f"Writing app config {item.spec.config_name!r} at scope "
164+
f"{item.spec.scope_type.value!r} is not allowed."
165+
),
166+
)
167+
)
168+
result = await w.bulk_create_partial(
169+
BulkCreator(specs=[spec for _, spec in allowed])
170+
)
171+
failed.extend(
172+
AppConfigFragmentBulkItemError(
173+
index=allowed[error.index][0], message=str(error.exception)
174+
)
175+
for error in result.errors
176+
)
177+
return AppConfigFragmentBulkWriteResult(
178+
succeeded=[row.to_data() for row in result.successes],
179+
failed=sorted(failed, key=lambda e: e.index),
180+
)
181+
182+
@app_config_fragment_db_source_resilience.apply()
183+
async def bulk_update(
184+
self,
185+
updaters: Sequence[Updater[AppConfigFragmentRow]],
186+
) -> AppConfigFragmentBulkWriteResult:
187+
"""Update many fragments with partial success.
188+
189+
No write-gate — see ``update``. Each updater runs in its own savepoint: a
190+
missing target or a failed update is reported in ``failed`` (with its batch
191+
index) while the rest are updated. The batch shares one transaction, so the
192+
successful updates commit together.
193+
"""
194+
async with self._ops.write_ops() as w:
195+
result = await w.bulk_update_partial(updaters)
196+
succeeded = [row.to_data() for row in result.successes]
197+
failed = [
198+
AppConfigFragmentBulkItemError(index=e.index, message=str(e.exception))
199+
for e in result.errors
200+
]
201+
failed.extend(
202+
_not_found_failures(
203+
[updater.pk_value for updater in updaters],
204+
{data.id for data in succeeded},
205+
{e.index for e in result.errors},
206+
)
207+
)
208+
return AppConfigFragmentBulkWriteResult(
209+
succeeded=succeeded,
210+
failed=sorted(failed, key=lambda e: e.index),
211+
)
212+
213+
@app_config_fragment_db_source_resilience.apply()
214+
async def bulk_purge(
215+
self,
216+
purgers: Sequence[Purger[AppConfigFragmentRow]],
217+
) -> AppConfigFragmentBulkWriteResult:
218+
"""Purge many fragments with partial success.
219+
220+
No write-gate — see ``update``. Each purger runs in its own savepoint: a
221+
missing target or a failed delete is reported in ``failed`` (with its batch
222+
index) while the rest are purged.
223+
"""
224+
async with self._ops.write_ops() as w:
225+
result = await w.bulk_purge_partial(list(purgers))
226+
succeeded = [row.to_data() for row in result.successes]
227+
failed = [
228+
AppConfigFragmentBulkItemError(index=e.index, message=str(e.exception))
229+
for e in result.errors
230+
]
231+
failed.extend(
232+
_not_found_failures(
233+
[purger.pk_value for purger in purgers],
234+
{data.id for data in succeeded},
235+
{e.index for e in result.errors},
236+
)
237+
)
238+
return AppConfigFragmentBulkWriteResult(
239+
succeeded=succeeded,
240+
failed=sorted(failed, key=lambda e: e.index),
241+
)
242+
115243
@app_config_fragment_db_source_resilience.apply()
116244
async def admin_search(self, querier: BatchQuerier) -> AppConfigFragmentSearchResult:
117245
"""Superadmin/internal path: query across all fragments with no scope filter."""

src/ai/backend/manager/repositories/app_config_fragment/repository.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy
1010
from ai.backend.common.resilience.resilience import Resilience
1111
from ai.backend.manager.data.app_config_fragment.types import (
12+
AppConfigFragmentBulkWriteResult,
1213
AppConfigFragmentData,
1314
AppConfigFragmentSearchResult,
1415
)
@@ -17,11 +18,17 @@
1718
from ai.backend.manager.models.scopes import SearchScope
1819
from ai.backend.manager.repositories.app_config_fragment.creators import (
1920
AppConfigFragmentCreatorSpec,
21+
GatedAppConfigFragmentCreate,
2022
)
2123
from ai.backend.manager.repositories.app_config_fragment.db_source import (
2224
AppConfigFragmentDBSource,
2325
)
24-
from ai.backend.manager.repositories.base import BatchQuerier, ExistsQuerier, Purger, Updater
26+
from ai.backend.manager.repositories.base import (
27+
BatchQuerier,
28+
ExistsQuerier,
29+
Purger,
30+
Updater,
31+
)
2532
from ai.backend.manager.repositories.ops import DBOpsProvider
2633

2734
__all__ = ("AppConfigFragmentRepository",)
@@ -83,3 +90,24 @@ async def scoped_search(
8390
self, querier: BatchQuerier, scopes: Sequence[SearchScope]
8491
) -> AppConfigFragmentSearchResult:
8592
return await self._db_source.scoped_search(querier, scopes)
93+
94+
@app_config_fragment_repository_resilience.apply()
95+
async def bulk_create(
96+
self,
97+
items: Sequence[GatedAppConfigFragmentCreate],
98+
) -> AppConfigFragmentBulkWriteResult:
99+
return await self._db_source.bulk_create(items)
100+
101+
@app_config_fragment_repository_resilience.apply()
102+
async def bulk_update(
103+
self,
104+
updaters: Sequence[Updater[AppConfigFragmentRow]],
105+
) -> AppConfigFragmentBulkWriteResult:
106+
return await self._db_source.bulk_update(updaters)
107+
108+
@app_config_fragment_repository_resilience.apply()
109+
async def bulk_purge(
110+
self,
111+
purgers: Sequence[Purger[AppConfigFragmentRow]],
112+
) -> AppConfigFragmentBulkWriteResult:
113+
return await self._db_source.bulk_purge(purgers)

0 commit comments

Comments
 (0)