Skip to content

Commit f72f012

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): - bulk_create / bulk_update / bulk_purge in db_source + repository, returning AppConfigFragmentBulkWriteResult (succeeded + failed[index, message]) — partial success via the WriteOps.bulk_*_partial primitives. - bulk_create takes plain Creators; the FK to the allow-list is the gate, so an item with no allow-list row fails per-item as AppConfigFragmentWriteNotAllowed (via the spec's integrity checks) while the rest are created. - Repository unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent e74d747 commit f72f012

5 files changed

Lines changed: 325 additions & 0 deletions

File tree

changes/12426.feature.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/db_source/db_source.py

Lines changed: 109 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
)
@@ -23,6 +25,7 @@
2325
from ai.backend.manager.models.scopes import SearchScope
2426
from ai.backend.manager.repositories.base import (
2527
BatchQuerier,
28+
BulkCreator,
2629
Creator,
2730
Purger,
2831
Querier,
@@ -52,6 +55,25 @@
5255
)
5356

5457

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

@@ -97,6 +119,93 @@ async def purge(self, purger: Purger[AppConfigFragmentRow]) -> AppConfigFragment
97119
raise AppConfigFragmentNotFound(f"App config fragment {purger.pk_value} not found")
98120
return result.row.to_data()
99121

122+
@app_config_fragment_db_source_resilience.apply()
123+
async def bulk_create(
124+
self,
125+
creators: Sequence[Creator[AppConfigFragmentRow]],
126+
) -> AppConfigFragmentBulkWriteResult:
127+
"""Create many fragments with partial success.
128+
129+
Each insert runs in its own savepoint: the FK to the allow-list is the gate, so
130+
an item with no allow-list row for its ``(config_name, scope_type)`` fails as
131+
``AppConfigFragmentWriteNotAllowed`` (see the spec's integrity checks), and a
132+
duplicate natural key fails likewise — both reported in ``failed`` with their
133+
batch index while the rest are created. The batch shares one transaction.
134+
"""
135+
async with self._ops.write_ops() as w:
136+
result = await w.bulk_create_partial(
137+
BulkCreator(specs=[creator.spec for creator in creators])
138+
)
139+
failed = [
140+
AppConfigFragmentBulkItemError(index=error.index, message=str(error.exception))
141+
for error in result.errors
142+
]
143+
return AppConfigFragmentBulkWriteResult(
144+
succeeded=[row.to_data() for row in result.successes],
145+
failed=sorted(failed, key=lambda e: e.index),
146+
)
147+
148+
@app_config_fragment_db_source_resilience.apply()
149+
async def bulk_update(
150+
self,
151+
updaters: Sequence[Updater[AppConfigFragmentRow]],
152+
) -> AppConfigFragmentBulkWriteResult:
153+
"""Update many fragments with partial success.
154+
155+
No write-gate — see ``update``. Each updater runs in its own savepoint: a
156+
missing target or a failed update is reported in ``failed`` (with its batch
157+
index) while the rest are updated. The batch shares one transaction, so the
158+
successful updates commit together.
159+
"""
160+
async with self._ops.write_ops() as w:
161+
result = await w.bulk_update_partial(updaters)
162+
succeeded = [row.to_data() for row in result.successes]
163+
failed = [
164+
AppConfigFragmentBulkItemError(index=e.index, message=str(e.exception))
165+
for e in result.errors
166+
]
167+
failed.extend(
168+
_not_found_failures(
169+
[updater.pk_value for updater in updaters],
170+
{data.id for data in succeeded},
171+
{e.index for e in result.errors},
172+
)
173+
)
174+
return AppConfigFragmentBulkWriteResult(
175+
succeeded=succeeded,
176+
failed=sorted(failed, key=lambda e: e.index),
177+
)
178+
179+
@app_config_fragment_db_source_resilience.apply()
180+
async def bulk_purge(
181+
self,
182+
purgers: Sequence[Purger[AppConfigFragmentRow]],
183+
) -> AppConfigFragmentBulkWriteResult:
184+
"""Purge many fragments with partial success.
185+
186+
No write-gate — see ``update``. Each purger runs in its own savepoint: a
187+
missing target or a failed delete is reported in ``failed`` (with its batch
188+
index) while the rest are purged.
189+
"""
190+
async with self._ops.write_ops() as w:
191+
result = await w.bulk_purge_partial(list(purgers))
192+
succeeded = [row.to_data() for row in result.successes]
193+
failed = [
194+
AppConfigFragmentBulkItemError(index=e.index, message=str(e.exception))
195+
for e in result.errors
196+
]
197+
failed.extend(
198+
_not_found_failures(
199+
[purger.pk_value for purger in purgers],
200+
{data.id for data in succeeded},
201+
{e.index for e in result.errors},
202+
)
203+
)
204+
return AppConfigFragmentBulkWriteResult(
205+
succeeded=succeeded,
206+
failed=sorted(failed, key=lambda e: e.index),
207+
)
208+
100209
@app_config_fragment_db_source_resilience.apply()
101210
async def admin_search(self, querier: BatchQuerier) -> AppConfigFragmentSearchResult:
102211
"""Superadmin/internal path: query across all fragments with no scope filter."""

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

Lines changed: 22 additions & 0 deletions
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
)
@@ -75,3 +76,24 @@ async def scoped_search(
7576
self, querier: BatchQuerier, scopes: Sequence[SearchScope]
7677
) -> AppConfigFragmentSearchResult:
7778
return await self._db_source.scoped_search(querier, scopes)
79+
80+
@app_config_fragment_repository_resilience.apply()
81+
async def bulk_create(
82+
self,
83+
creators: Sequence[Creator[AppConfigFragmentRow]],
84+
) -> AppConfigFragmentBulkWriteResult:
85+
return await self._db_source.bulk_create(creators)
86+
87+
@app_config_fragment_repository_resilience.apply()
88+
async def bulk_update(
89+
self,
90+
updaters: Sequence[Updater[AppConfigFragmentRow]],
91+
) -> AppConfigFragmentBulkWriteResult:
92+
return await self._db_source.bulk_update(updaters)
93+
94+
@app_config_fragment_repository_resilience.apply()
95+
async def bulk_purge(
96+
self,
97+
purgers: Sequence[Purger[AppConfigFragmentRow]],
98+
) -> AppConfigFragmentBulkWriteResult:
99+
return await self._db_source.bulk_purge(purgers)

tests/unit/manager/repositories/app_config_fragment/test_repository.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,176 @@ async def test_scoped_search_unknown_scope_returns_empty(
397397
[DomainAppConfigFragmentSearchScope(domain_id=DomainID(uuid.uuid4()))],
398398
)
399399
assert result.items == []
400+
401+
402+
@pytest.fixture
403+
async def menu_defined(database: ExtendedAsyncSAEngine, theme_registered: None) -> None:
404+
"""``theme`` is allow-listed at every scope (theme_registered); ``menu`` is defined, NOT allow-listed."""
405+
async with database.begin_session() as db_sess:
406+
db_sess.add(AppConfigDefinitionRow(config_name="menu"))
407+
await db_sess.flush()
408+
409+
410+
@pytest.fixture
411+
async def two_fragments(database: ExtendedAsyncSAEngine) -> list[AppConfigFragmentData]:
412+
"""A domain-scoped and a user-scoped ``theme`` fragment, both allow-listed."""
413+
async with database.begin_session() as db_sess:
414+
db_sess.add(AppConfigDefinitionRow(config_name="theme"))
415+
await db_sess.flush()
416+
db_sess.add_all([
417+
_allow_list_row("theme", AppConfigScopeType.DOMAIN),
418+
_allow_list_row("theme", AppConfigScopeType.USER),
419+
])
420+
await db_sess.flush()
421+
rows = [
422+
AppConfigFragmentRow(
423+
config_name="theme",
424+
scope_type=AppConfigScopeType.DOMAIN,
425+
scope_id=_DOMAIN_ID,
426+
config={"a": 1},
427+
),
428+
AppConfigFragmentRow(
429+
config_name="theme",
430+
scope_type=AppConfigScopeType.USER,
431+
scope_id=_USER_ID,
432+
config={"b": 2},
433+
),
434+
]
435+
db_sess.add_all(rows)
436+
await db_sess.flush()
437+
return [row.to_data() for row in rows]
438+
439+
440+
class TestBulkCreate:
441+
async def test_all_created(
442+
self, repository: AppConfigFragmentRepository, theme_registered: None
443+
) -> None:
444+
result = await repository.bulk_create([
445+
Creator(
446+
spec=AppConfigFragmentCreatorSpec(
447+
config_name="theme",
448+
scope_type=AppConfigScopeType.PUBLIC,
449+
scope_id="public",
450+
config={"a": 1},
451+
)
452+
),
453+
Creator(
454+
spec=AppConfigFragmentCreatorSpec(
455+
config_name="theme",
456+
scope_type=AppConfigScopeType.DOMAIN,
457+
scope_id=_DOMAIN_ID,
458+
config={"b": 2},
459+
)
460+
),
461+
])
462+
assert len(result.succeeded) == 2
463+
assert result.failed == []
464+
for fragment in result.succeeded:
465+
assert (await repository.get_by_id(fragment.id)).id == fragment.id
466+
467+
async def test_partial_when_one_not_allow_listed(
468+
self, repository: AppConfigFragmentRepository, menu_defined: None
469+
) -> None:
470+
result = await repository.bulk_create([
471+
Creator(
472+
spec=AppConfigFragmentCreatorSpec(
473+
config_name="theme", # allow-listed
474+
scope_type=AppConfigScopeType.DOMAIN,
475+
scope_id=_DOMAIN_ID,
476+
config={"a": 1},
477+
)
478+
),
479+
Creator(
480+
spec=AppConfigFragmentCreatorSpec(
481+
config_name="menu", # defined but NOT allow-listed -> FK rejects the insert
482+
scope_type=AppConfigScopeType.PUBLIC,
483+
scope_id="public",
484+
config={"b": 2},
485+
)
486+
),
487+
])
488+
# partial: the allow-listed theme fragment is created; the menu item (index 1) is rejected
489+
assert [f.config_name for f in result.succeeded] == ["theme"]
490+
assert [f.index for f in result.failed] == [1]
491+
# the created theme fragment persists (not rolled back with the rejected one)
492+
search = await repository.admin_search(
493+
BatchQuerier(pagination=OffsetPagination(limit=10, offset=0))
494+
)
495+
assert {item.config_name for item in search.items} == {"theme"}
496+
497+
498+
class TestBulkUpdate:
499+
async def test_all_updated(
500+
self,
501+
repository: AppConfigFragmentRepository,
502+
two_fragments: list[AppConfigFragmentData],
503+
) -> None:
504+
result = await repository.bulk_update([
505+
Updater(
506+
spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"x": 1})),
507+
pk_value=two_fragments[0].id,
508+
),
509+
Updater(
510+
spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"y": 2})),
511+
pk_value=two_fragments[1].id,
512+
),
513+
])
514+
assert [u.config for u in result.succeeded] == [{"x": 1}, {"y": 2}]
515+
assert result.failed == []
516+
assert (await repository.get_by_id(two_fragments[0].id)).config == {"x": 1}
517+
518+
async def test_partial_when_one_missing(
519+
self,
520+
repository: AppConfigFragmentRepository,
521+
two_fragments: list[AppConfigFragmentData],
522+
) -> None:
523+
missing_id = AppConfigFragmentID(uuid.uuid4())
524+
result = await repository.bulk_update([
525+
Updater(
526+
spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"x": 1})),
527+
pk_value=two_fragments[0].id,
528+
),
529+
Updater(
530+
spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"z": 9})),
531+
pk_value=missing_id, # missing -> reported
532+
),
533+
])
534+
# partial: the existing fragment is updated; the missing one (index 1) is reported
535+
assert [u.config for u in result.succeeded] == [{"x": 1}]
536+
assert [f.index for f in result.failed] == [1]
537+
assert "not found" in result.failed[0].message
538+
assert (await repository.get_by_id(two_fragments[0].id)).config == {"x": 1}
539+
540+
541+
class TestBulkPurge:
542+
async def test_all_purged(
543+
self,
544+
repository: AppConfigFragmentRepository,
545+
two_fragments: list[AppConfigFragmentData],
546+
) -> None:
547+
result = await repository.bulk_purge([
548+
Purger(row_class=AppConfigFragmentRow, pk_value=fragment.id)
549+
for fragment in two_fragments
550+
])
551+
assert {p.id for p in result.succeeded} == {f.id for f in two_fragments}
552+
assert result.failed == []
553+
for fragment in two_fragments:
554+
with pytest.raises(AppConfigFragmentNotFound):
555+
await repository.get_by_id(fragment.id)
556+
557+
async def test_partial_when_one_missing(
558+
self,
559+
repository: AppConfigFragmentRepository,
560+
two_fragments: list[AppConfigFragmentData],
561+
) -> None:
562+
missing_id = AppConfigFragmentID(uuid.uuid4())
563+
result = await repository.bulk_purge([
564+
Purger(row_class=AppConfigFragmentRow, pk_value=two_fragments[0].id),
565+
Purger(row_class=AppConfigFragmentRow, pk_value=missing_id), # missing -> reported
566+
])
567+
# partial: the existing fragment is purged; the missing one (index 1) is reported
568+
assert [p.id for p in result.succeeded] == [two_fragments[0].id]
569+
assert [f.index for f in result.failed] == [1]
570+
assert "not found" in result.failed[0].message
571+
with pytest.raises(AppConfigFragmentNotFound):
572+
await repository.get_by_id(two_fragments[0].id)

0 commit comments

Comments
 (0)