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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user