Concurrent same-item Add*ToCollection can 500 on composite-PK violation (pre-existing idempotency-under-concurrency gap) #308
Closed
opened 2026-07-12 19:20:36 +02:00 by timothy
·
2 comments
No Branch/Tag Specified
main
901-pin-the-artifact-whole
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#308
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #269 (PR #307) — Codex F2, ratified by Fable, deferred as out-of-scope for the rotation PR.
Problem
The
Add*ToCollectionHandlerfamily (11 handlers) short-circuits a sequential idempotent re-add via a membership check (collection.MediaItems.Any(mi => mi.Id == …)) — added in #269. But two concurrent adds of the same item both load the collection, both observe the item absent, both pass the guard, and both insert the sameCollectionItemcomposite key (CollectionConfiguration.cs:33). The second insert throws aDbUpdateException(unique/PK violation, SQLite error 19 / MySQL 1062), whichSaveChangesForcingVersiondoes not catch (it only catchesDbUpdateConcurrencyException) → 500.Severity: low
.Add-then-plain-saved the same insert with no membership check, so the identical concurrent race already 500'd (or dup-inserted). #269 fixed the sequential case, not this one.Fix options (pick when scheduled)
IsUniqueViolationabstraction).SaveChangesForcingVersion's concurrency retry).Apply uniformly across the 11
Add*ToCollectionhandlers (and consider theAdd*ToPlaylistfamily, which has the same shape).RemoveItems/Update*are unaffected (no unique-key insert).Refs #269 #253 ·
docs/api-conventions.md§7a.Done-when
Add*ToCollectionhandlers made idempotent under concurrent same-item add;Add*ToPlaylistaudited + fixed if affected (audited → not affected:PlaylistItemhas its own identity PK, no unique index on(PlaylistId, MediaItemId)— duplicates are legal)docs/api-conventions.md§7a updated to describe the idempotent-insert contractReview-verdict: MERGEABLE @ 2281f2e7)Claiming (Claude Code / Opus 4.8 orchestrator session). Selected off the
priority: mediumbacklog tier — arc/gate/milestone/review tiers are empty; #308 is the lowest-numbered eligible medium, deps clear, unclaimed.Plan
IsUniqueConstraintViolation(DbUpdateException)helper on the Sqlite/MySql infra split, mirroring how the providers already differ.Add*ToCollectionhandlers, catch the unique-violation on insert and translate to an idempotent no-op success (option 1 from the issue). Prefer a single shared code path over editing 11 catch blocks by hand.Add*ToPlaylistfamily for the identical shape and fix uniformly if present.Remove*/Update*are unaffected (no unique-key insert).Done-when (adding to the issue body for the merge-consent gate)
Add*ToCollectionhandlers made idempotent under concurrent same-item add;Add*ToPlaylistaudited + fixed if affecteddocs/api-conventions.md§7a updated to describe the idempotent-insert contractReview-verdict: MERGEABLE @ <head-sha>)Closed — fixed in PR #441 (merged to
main,2281f2e7)Root cause. The #269 membership pre-check (
collection.MediaItems.Any(mi => mi.Id == …)) is not atomic with the insert. Two concurrent adds of the same item both load the collection, both see the item absent, both pass the guard, and both stage theCollectionItemcomposite key(CollectionId, MediaItemId). The loser's insert throws a unique/PK-violationDbUpdateException(SQLite 19 / MySQL 1062) thatSaveChangesForcingVersiondoesn't catch (it only handlesDbUpdateConcurrencyException, a rowcount-mismatch with no DB error) → an unhandled 500.Fix (option 1 + option 2, each where it fits).
ConcurrencyExtensions.TrySaveChangesForcingVersion— abool-returning sibling ofSaveChangesForcingVersionthat catches only a classified unique/PK violation and returnsfalse.Add*ToCollectionhandlers returnUnit.Defaultonfalse(idempotent no-op, skip the reindex/rebuild fan-out — the racing winner already did it).AddItemsToCollectionhandler retries on a fresh context against recomputed membership (bounded loop) so a partial-overlap collision never drops the non-colliding items.TvContextstatic-provider seam: a settableTvContext.IsUniqueConstraintViolationdelegate wired fromStartup.cstoSqliteErrorClassifier(extended codes 1555/2067) /MySqlErrorClassifier(1062), defaulting to a conservative "no".Scope.
Add*ToPlaylistaudited and left untouched —PlaylistItemhas its own identity PK and no unique index on(PlaylistId, MediaItemId); a playlist may legitimately contain the same item more than once, so there's no constraint to violate.Files changed.
ConcurrencyExtensions.cs, the 11Add*ToCollectionhandlers,TvContext.cs,Sqlite/MySqlErrorClassifier.cs(new),Startup.cs; testsAddToCollectionIdempotencyConcurrencyTests.cs+SharedCacheTvContext.cs(new); docsapi-conventions.md§7a +decisions/optimistic-concurrency.md.Verification. Full suite green (~3,800 tests). Every fix-dependent test was verified to fail with the catch disabled (non-vacuous). Independent cold review: MERGEABLE. Live-E2E (real SQLite WAL): 40 + 30 concurrent adds → all 204, exactly-one-row invariant held, 6 genuine
UNIQUE constraint failedcollisions logged and all caught (pre-fix each is a 500).No follow-ups deferred. No migration (no model change) and no OpenAPI regen (no endpoint/DTO change) were needed.
Docs updated:
api-conventions.md§7a (new "Idempotent insert under concurrency" paragraph),decisions/optimistic-concurrency.md(dated entry).