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

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:
2026-07-18 13:02:14 +02:00
co-authored by Claude Opus 4.8
parent 3a463db36a
commit 2281f2e764
21 changed files with 611 additions and 40 deletions
+19 -5
View File
@@ -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`