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>
257 lines
12 KiB
C#
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();
|
|
}
|
|
}
|