fix(308): idempotent concurrent Add*ToCollection instead of a composite-PK 500
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 40s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 40s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Two concurrent adds of the same item both membership-check it absent, both insert the CollectionItem composite key, and the loser's SaveChangesForcingVersion threw an uncaught DbUpdateException (SQLite 19 / MySQL 1062) -> 500. Now the loser is an idempotent no-op. - ConcurrencyExtensions.TrySaveChangesForcingVersion: bool-returning sibling that catches only a classified unique/PK violation and returns false. - 10 single-item Add*ToCollection handlers: return Unit.Default (no-op, skip fan-out) on false — the racing winner already inserted + rotated + rebuilt. - Bulk AddItemsToCollection: retry on a fresh context against recomputed membership so a partial-overlap collision doesn't drop the non-colliding items (bounded loop; common no-collision path runs once). - Provider detection via a TvContext.IsUniqueConstraintViolation static delegate (matches the existing IsSqlite/LastInsertedRowId provider seam), wired from Startup to SqliteErrorClassifier / MySqlErrorClassifier. - Add*ToPlaylist is NOT affected (PlaylistItem has its own identity PK; a playlist may legitimately contain the same item more than once). Tests: a negative-control anchor proves the race genuinely throws a classified exception; end-to-end handler tests reproduce a real cross-connection race via a shared-cache SQLite harness + a SavingChanges interceptor (the single-conn in-memory fixture cannot). Every fix-dependent test verified to fail with the catch disabled. Docs: api-conventions.md §7a (idempotent insert under concurrency) + decisions/optimistic-concurrency.md. fixes #308 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-5
@@ -577,11 +577,25 @@ rotate the editor ETag). **No-op idempotence (the trap):** these handlers gate t
|
||||
fan-out on `SaveChanges() > 0`; an *unconditional* bump makes that gate always-true, so an idempotent re-add /
|
||||
same-value re-submit would fire spurious rebuilds. Each therefore short-circuits a genuine no-op **before** the
|
||||
bump — the Add handlers by an explicit membership check (also fixing the latent duplicate-`CollectionItem`
|
||||
insert on a *sequential* re-add; two *concurrent* adds of the same item can still both pass the check and the
|
||||
loser 500s on the composite-PK violation — a narrow, pre-existing race, tracked as #308), the scalar
|
||||
writers by `ChangeTracker.HasChanges()` — so a no-op neither bumps nor rebuilds. This is an
|
||||
invalidation-completeness refinement; the primary endpoints' own bump+guard already covered the two-tab
|
||||
lost-update the contract targets.
|
||||
insert on a *sequential* re-add), the scalar writers by `ChangeTracker.HasChanges()` — so a no-op neither
|
||||
bumps nor rebuilds. This is an invalidation-completeness refinement; the primary endpoints' own bump+guard
|
||||
already covered the two-tab lost-update the contract targets.
|
||||
|
||||
**Idempotent insert under concurrency (#308).** The membership pre-check is not atomic with the insert, so
|
||||
two *concurrent* adds of the same item both observe it absent, both stage the `CollectionItem` composite key,
|
||||
and the loser's `SaveChangesForcingVersion` throws a unique/PK-violation `DbUpdateException` (SQLite error 19 /
|
||||
MySQL 1062) it does not catch → a 500. The `Add*ToCollection` family therefore saves through
|
||||
**`ConcurrencyExtensions.TrySaveChangesForcingVersion`** (a `bool`-returning sibling of `SaveChangesForcingVersion`)
|
||||
which catches *only* that classified violation and returns `false`. The single-item handlers treat `false` as an
|
||||
idempotent **no-op** (the racing winner already inserted the row, rotated the ETag, and fanned out the rebuild);
|
||||
the bulk `AddItemsToCollection` handler instead **retries** on a fresh context against recomputed membership so
|
||||
the non-colliding items in the batch are not dropped (bounded loop; the common no-collision path runs once).
|
||||
The provider-specific classifier is wired the same way as the other provider statics on `TvContext` — a settable
|
||||
`TvContext.IsUniqueConstraintViolation` delegate pointed at `SqliteErrorClassifier` / `MySqlErrorClassifier`
|
||||
(`ErsatzTV.Infrastructure.Sqlite/MySql.Data`) from `Startup.cs`, defaulting to a conservative "no" so an unwired
|
||||
provider never silently swallows a save failure. `Add*ToPlaylist` is **not** affected: `PlaylistItem` has its own
|
||||
identity PK and no unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item
|
||||
more than once, so there is no constraint to violate.
|
||||
|
||||
## 7b. Post-commit side effects run on `CancellationToken.None`
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ cross-editor ETag rotation). Refs #197.
|
||||
- [2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)](#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump)
|
||||
- [2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)](#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269)
|
||||
- [2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)](#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265)
|
||||
- [2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)](#2026-07-18--concurrent-same-item-add-is-idempotent-not-a-500-catch-the-unique-violation-per-provider-308)
|
||||
|
||||
---
|
||||
|
||||
@@ -254,3 +255,36 @@ worried about was already correct (each handler loads/validates → 404 before `
|
||||
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
|
||||
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
|
||||
§7a. Refs #265 #253 #197.
|
||||
|
||||
## 2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)
|
||||
|
||||
**Decision.** The `Add*ToCollection` family's membership pre-check (#269) is not atomic with the insert, so two
|
||||
*concurrent* adds of the same item both observe it absent and both stage the `CollectionItem` composite key; the
|
||||
loser's `SaveChangesForcingVersion` threw a unique/PK-violation `DbUpdateException` (SQLite error 19 / MySQL 1062)
|
||||
it did not catch → **500**. We now treat that loss as an **idempotent no-op**, not an error: the desired end state
|
||||
(the item is a member) already holds because the racing winner inserted it, rotated the ETag, and fanned out the
|
||||
rebuild.
|
||||
|
||||
**Mechanism.** A `bool`-returning sibling `ConcurrencyExtensions.TrySaveChangesForcingVersion` wraps
|
||||
`SaveChangesForcingVersion` and catches *only* a classified unique/PK violation, returning `false`. The 10
|
||||
single-item handlers return `Unit.Default` on `false` (skip the reindex/rebuild fan-out — the winner did it). The
|
||||
bulk `AddItemsToCollection` handler cannot no-op — that would silently drop the non-colliding items when a batch
|
||||
partially overlaps a concurrent add — so it **retries** on a fresh context against recomputed membership (bounded
|
||||
loop; the common no-collision path runs once).
|
||||
|
||||
**Provider seam.** Detection is provider-specific but the Application layer must not reference the provider
|
||||
packages, so it follows the existing `TvContext` static-provider-config idiom (`IsSqlite`, `LastInsertedRowId`): a
|
||||
settable `TvContext.IsUniqueConstraintViolation` delegate, pointed at `SqliteErrorClassifier` (extended codes 1555
|
||||
PK / 2067 UNIQUE) or `MySqlErrorClassifier` (`Number == 1062`) from `Startup.cs`, defaulting to a conservative
|
||||
"no" so an unwired provider never silently swallows a save failure. Chosen over DI to avoid threading a new
|
||||
service through 11 handlers, and because the provider discriminator already lives as a `TvContext` static.
|
||||
|
||||
**Scope boundary.** `Add*ToPlaylist` is deliberately **untouched**: `PlaylistItem` has its own identity PK and no
|
||||
unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item more than once, so
|
||||
there is no constraint to violate.
|
||||
|
||||
**Tests.** A negative-control anchor proves the race genuinely throws a classified `DbUpdateException`; the fix's
|
||||
end-to-end handler tests reproduce a *real cross-connection* race via a shared-cache SQLite harness + a
|
||||
`SavingChanges` interceptor that inserts the conflicting row on another connection mid-save (the single-connection
|
||||
in-memory fixture cannot). Every fix-dependent test was verified to fail with the catch disabled. Mechanics:
|
||||
`api-conventions.md` §7a ("Idempotent insert under concurrency"). Refs #308 #269 #253.
|
||||
|
||||
Reference in New Issue
Block a user