Files
ersatztv/ErsatzTV.Tests/Application/MediaCollections/AddToCollectionIdempotencyConcurrencyTests.cs
T
timothyandClaude Opus 4.8 2281f2e764
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
fix(308): idempotent concurrent Add*ToCollection instead of a composite-PK 500
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>
2026-07-18 13:02:14 +02:00

257 lines
12 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.MediaCollections;
/// <summary>
/// #308: two concurrent adds of the same item both membership-check it absent, both insert the same
/// <c>CollectionItem</c> composite key, and the loser used to 500 on the unhandled
/// <see cref="DbUpdateException" /> (SQLite 19 / MySQL 1062) that <c>SaveChangesForcingVersion</c>
/// does not catch. The fix (<c>TrySaveChangesForcingVersion</c>) makes the loser an idempotent no-op
/// for the single-item handlers, and for the bulk handler retries against fresh membership so the
/// non-colliding items are not dropped.
/// </summary>
[TestFixture]
public class AddToCollectionIdempotencyConcurrencyTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static async Task SeedCollectionWithMovies(Func<TvContext> createContext, int collectionId, params int[] movieIds)
{
await using TvContext context = createContext();
var library = new LocalLibrary
{
Id = 1, Name = "Library", MediaKind = LibraryMediaKind.Movies, Paths = []
};
var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] };
library.Paths.Add(path);
foreach (int movieId in movieIds)
{
context.Movies.Add(new Movie
{
Id = movieId,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Movie {movieId}", SortTitle = $"Movie {movieId}", Artwork = [], Genres = [],
Tags = [], Studios = [], Actors = [], Guids = [], Subtitles = [], Directors = [], Writers = []
}
],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }]
});
}
context.Collections.Add(new Collection
{
Id = collectionId, Name = "Manual", Version = 1, MediaItems = [], CollectionItems = []
});
await context.SaveChangesAsync();
}
private static async Task<int> CollectionItemCount(Func<TvContext> createContext, int collectionId, int mediaItemId)
{
await using TvContext context = createContext();
return await context.CollectionItems
.CountAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == mediaItemId);
}
private static (IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) Deps()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([1]);
Channel<IBackgroundServiceRequest> worker =
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
ChannelWriter<ISearchIndexBackgroundServiceRequest> search =
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer;
return (repo, worker, search);
}
// Simulates the concurrent "winner": exactly once, on a SEPARATE connection, insert the same composite
// key and commit — AFTER the intercepted context has read (stale) membership but BEFORE its own INSERT
// runs. This is what interposes the race deterministically.
private sealed class InsertConflictingItemOnce(SharedCacheTvContext db, int collectionId, int mediaItemId)
: SaveChangesInterceptor
{
private int _fired;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _fired, 1) == 0)
{
await using SqliteConnection connection = db.OpenConnection();
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
"INSERT INTO \"CollectionItem\" (\"CollectionId\", \"MediaItemId\", \"CustomIndex\") " +
$"VALUES ({collectionId}, {mediaItemId}, NULL)";
await command.ExecuteNonQueryAsync(cancellationToken);
}
return result;
}
}
// ----- Non-vacuous anchor + extension-level unit tests (single shared connection) -----
// Proves the race really does throw a DbUpdateException the classifier recognizes — i.e. the pre-fix
// 500 was real and the fix's catch filter is not a no-op. This is the negative control anchoring the
// extension tests below.
[Test]
public async Task Duplicate_CollectionItem_Insert_Throws_A_Classified_UniqueViolation()
{
await SeedCollectionWithMovies(_db.CreateContext, 1, 10);
await using TvContext stale = _db.CreateContext();
Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10);
// A racer inserts the (1, 10) CollectionItem and commits, then the stale context tries the same key.
await InsertRow(_db.CreateContext, 1, 10);
tracked.MediaItems.Add(movie);
DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => stale.SaveChangesAsync());
SqliteErrorClassifier.IsUniqueConstraintViolation(ex).ShouldBeTrue();
// And the classifier does not blanket-return true for an unrelated failure.
SqliteErrorClassifier.IsUniqueConstraintViolation(
new DbUpdateException("nope", new InvalidOperationException())).ShouldBeFalse();
}
[Test]
public async Task TrySaveChangesForcingVersion_Swallows_The_Duplicate_And_Returns_False()
{
await SeedCollectionWithMovies(_db.CreateContext, 1, 10);
await using TvContext stale = _db.CreateContext();
Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10);
await InsertRow(_db.CreateContext, 1, 10);
tracked.MediaItems.Add(movie);
tracked.Version++;
bool committed = await stale.TrySaveChangesForcingVersion(CancellationToken.None);
committed.ShouldBeFalse(); // the loser's save was swallowed, not thrown
(await CollectionItemCount(_db.CreateContext, 1, 10)).ShouldBe(1); // exactly one row, no corruption
}
[Test]
public async Task TrySaveChangesForcingVersion_Rethrows_When_The_Provider_Does_Not_Classify_It()
{
// Guard against an over-broad catch: with the classifier saying "not a unique violation", the
// duplicate must still propagate rather than be silently swallowed.
Func<DbUpdateException, bool> original = TvContext.IsUniqueConstraintViolation;
try
{
await SeedCollectionWithMovies(_db.CreateContext, 1, 10);
await using TvContext stale = _db.CreateContext();
Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10);
await InsertRow(_db.CreateContext, 1, 10);
tracked.MediaItems.Add(movie);
tracked.Version++;
TvContext.IsUniqueConstraintViolation = _ => false;
await Should.ThrowAsync<DbUpdateException>(() => stale.TrySaveChangesForcingVersion(CancellationToken.None));
}
finally
{
TvContext.IsUniqueConstraintViolation = original;
}
}
// ----- End-to-end handler tests over a real cross-connection race (shared cache) -----
[Test]
public async Task SingleItem_Handler_Losing_The_Cross_Connection_Race_Is_A_NoOp_Not_A_500()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv308-single");
await SeedCollectionWithMovies(db.CreateContext, 1, 10);
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
// The interceptor inserts (1, 10) on another connection during this handler's save.
var racer = new InsertConflictingItemOnce(db, 1, 10);
var handler = new AddMovieToCollectionHandler(db.Factory(racer), repo, worker.Writer, search);
Either<BaseError, Unit> result = await handler.Handle(new AddMovieToCollection(1, 10), CancellationToken.None);
result.IsRight.ShouldBeTrue(); // no 500
(await CollectionItemCount(db.CreateContext, 1, 10)).ShouldBe(1); // one row, no duplicate
worker.Reader.Count.ShouldBe(0); // the loser does not fan out a rebuild (the winner already did)
}
[Test]
public async Task Bulk_Handler_Losing_The_Cross_Connection_Race_Retries_And_Keeps_NonColliding_Items()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv308-bulk");
await SeedCollectionWithMovies(db.CreateContext, 1, 10, 11);
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
var movieRepo = Substitute.For<IMovieRepository>();
movieRepo.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
var tvRepo = Substitute.For<ITelevisionRepository>();
tvRepo.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
tvRepo.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
tvRepo.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
// Item 10 is inserted by the racer during the first attempt's save, colliding and rolling back the
// whole [10, 11] batch; the retry must recompute membership and still add 11.
var racer = new InsertConflictingItemOnce(db, 1, 10);
var handler = new AddItemsToCollectionHandler(db.Factory(racer), repo, movieRepo, tvRepo, worker.Writer, search);
var request = new AddItemsToCollection(1, [10, 11], [], [], [], [], [], [], [], [], []);
Either<BaseError, Unit> result = await handler.Handle(request, CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await CollectionItemCount(db.CreateContext, 1, 10)).ShouldBe(1); // the racer's row — not dropped/duped
(await CollectionItemCount(db.CreateContext, 1, 11)).ShouldBe(1); // NOT dropped by the rolled-back attempt
}
private static async Task InsertRow(Func<TvContext> createContext, int collectionId, int mediaItemId)
{
await using TvContext context = createContext();
context.Add(new CollectionItem { CollectionId = collectionId, MediaItemId = mediaItemId, CustomIndex = null });
await context.SaveChangesAsync();
}
}