using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; 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 IFileSystem = System.IO.Abstractions.IFileSystem; namespace ErsatzTV.Tests.Integration; /// /// ersatztv#491: ILibraryRepository.GetOrAddFolder is a check-then-insert, so two callers /// racing the same (LibraryPathId, Path) both miss the lookup and both insert. The fix is a /// unique index on (LibraryPathId, PathHash) plus a catch-and-re-read in the repository, so /// the loser adopts the winner's row instead of creating a duplicate. /// [TestFixture] public class LibraryFolderConcurrencyTests { private const string LibraryPathValue = "/data/music"; private const string FolderPath = "/data/music/artist1"; private static LibraryRepository Repository(IDbContextFactory factory) => new(Substitute.For(), factory); private static async Task SeedLibraryPath(Func createContext) { await using TvContext context = createContext(); var libraryPath = new LibraryPath { Path = LibraryPathValue }; await context.LibraryPaths.AddAsync(libraryPath); await context.SaveChangesAsync(); return libraryPath.Id; } private static async Task FolderCount(Func createContext, int libraryPathId, string path) { await using TvContext context = createContext(); return await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId && f.Path == path); } private static async Task InsertFolderRaw( SharedCacheTvContext db, int libraryPathId, string path, string pathHash, CancellationToken cancellationToken = default) { await using SqliteConnection connection = db.OpenConnection(); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "INSERT INTO \"LibraryFolder\" (\"LibraryPathId\", \"Path\", \"PathHash\", \"Etag\", \"ParentId\") " + "VALUES ($libraryPathId, $path, $pathHash, NULL, NULL)"; command.Parameters.AddWithValue("$libraryPathId", libraryPathId); command.Parameters.AddWithValue("$path", path); command.Parameters.AddWithValue("$pathHash", (object?)pathHash ?? DBNull.Value); await command.ExecuteNonQueryAsync(cancellationToken); } /// /// Simulates the concurrent "winner": exactly once, on a SEPARATE connection, insert the same /// folder and commit — AFTER the intercepted context read the (stale) absent lookup but BEFORE its /// own INSERT runs. This interposes the race deterministically instead of hoping for a timing /// window. proves the race actually happened (non-vacuity). /// private sealed class InsertConflictingFolderOnce(SharedCacheTvContext db, int libraryPathId, string path) : SaveChangesInterceptor { private int _fired; public int Fired => _fired; public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _fired, 1) == 0) { await InsertFolderRaw(db, libraryPathId, path, PathUtils.GetPathHash(path), cancellationToken); } return result; } } /// Counts insert attempts so the multi-threaded test can prove it really raced. private sealed class CountSaveAttempts : SaveChangesInterceptor { private int _attempts; public int Attempts => _attempts; public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Interlocked.Increment(ref _attempts); return ValueTask.FromResult(result); } } // ----- Negative control #1: the index itself. Without the new unique index this test fails, because // the second insert simply succeeds and there is no violation to classify. ----- [Test] public async Task Duplicate_LibraryFolder_Insert_Throws_A_Classified_UniqueViolation() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-index"); int libraryPathId = await SeedLibraryPath(db.CreateContext); string hash = PathUtils.GetPathHash(FolderPath); await InsertFolderRaw(db, libraryPathId, FolderPath, hash); await using TvContext context = db.CreateContext(); await context.LibraryFolders.AddAsync( new LibraryFolder { LibraryPathId = libraryPathId, Path = FolderPath, PathHash = hash }); DbUpdateException ex = await Should.ThrowAsync(() => context.SaveChangesAsync()); SqliteErrorClassifier.IsUniqueConstraintViolation(ex).ShouldBeTrue(); // and the classifier is not a blanket "true" SqliteErrorClassifier.IsUniqueConstraintViolation( new DbUpdateException("nope", new InvalidOperationException())).ShouldBeFalse(); } // The migration leaves pre-#491 rows with a null hash; a unique index treats nulls as distinct, so // applying the index to an existing database can never fail on them. Documents that premise. [Test] public async Task Legacy_Null_PathHash_Rows_Do_Not_Collide() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-nulls"); int libraryPathId = await SeedLibraryPath(db.CreateContext); await InsertFolderRaw(db, libraryPathId, "/data/music/a", null); await InsertFolderRaw(db, libraryPathId, "/data/music/b", null); await using TvContext context = db.CreateContext(); (await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(2); } // ----- The deterministic cross-connection race through the real repository ----- [Test] public async Task GetOrAddFolder_Losing_The_Race_Adopts_The_Winner_Instead_Of_Duplicating() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-race"); int libraryPathId = await SeedLibraryPath(db.CreateContext); var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null }; var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath); LibraryRepository repository = Repository(db.Factory(racer)); LibraryFolder result = await repository.GetOrAddFolder(libraryPath, Option.None, FolderPath); racer.Fired.ShouldBe(1); // the race genuinely occurred — this assertion is the vacuity guard result.ShouldNotBeNull(); result.Id.ShouldBeGreaterThan(0); result.Path.ShouldBe(FolderPath); (await FolderCount(db.CreateContext, libraryPathId, FolderPath)).ShouldBe(1); // the returned row is the winner's persisted row, not a phantom await using TvContext context = db.CreateContext(); LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.LibraryPathId == libraryPathId); persisted.Id.ShouldBe(result.Id); persisted.PathHash.ShouldBe(PathUtils.GetPathHash(FolderPath)); } // The loser must still apply the parent id it was asked to set — to the WINNER's row. [Test] public async Task GetOrAddFolder_Losing_The_Race_Still_Persists_The_ParentId() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-race-parent"); int libraryPathId = await SeedLibraryPath(db.CreateContext); var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null }; LibraryFolder parent = await Repository(db.Factory()) .GetOrAddFolder(libraryPath, Option.None, LibraryPathValue); var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath); LibraryFolder result = await Repository(db.Factory(racer)) .GetOrAddFolder(libraryPath, Option.Some(parent.Id), FolderPath); racer.Fired.ShouldBe(1); result.ParentId.ShouldBe(parent.Id); await using TvContext context = db.CreateContext(); LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Path == FolderPath); persisted.Id.ShouldBe(result.Id); persisted.ParentId.ShouldBe(parent.Id); } // ----- Negative control #2: invert the real condition (the provider classifier) and the SAME race // must blow up, proving the catch in GetOrAddFolder is load-bearing rather than decorative. ----- [Test] public async Task GetOrAddFolder_Rethrows_When_The_Provider_Does_Not_Classify_The_Violation() { Func original = TvContext.IsUniqueConstraintViolation; try { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-negctl"); int libraryPathId = await SeedLibraryPath(db.CreateContext); var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null }; var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath); LibraryRepository repository = Repository(db.Factory(racer)); TvContext.IsUniqueConstraintViolation = _ => false; await Should.ThrowAsync( () => repository.GetOrAddFolder(libraryPath, Option.None, FolderPath)); racer.Fired.ShouldBe(1); } finally { TvContext.IsUniqueConstraintViolation = original; } } // ----- N threads x rounds over a single (LibraryPathId, Path) ----- [Test] public async Task Concurrent_GetOrAddFolder_Never_Produces_Duplicate_Rows() { const int threads = 8; const int rounds = 10; await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-threads"); int libraryPathId = await SeedLibraryPath(db.CreateContext); var counter = new CountSaveAttempts(); IDbContextFactory factory = db.Factory(counter); for (var round = 0; round < rounds; round++) { string path = $"{LibraryPathValue}/round{round}"; using var gate = new Barrier(threads); var tasks = new Task[threads]; for (var thread = 0; thread < threads; thread++) { tasks[thread] = Task.Run(async () => { // every thread carries its own detached LibraryPath, as the scanners do var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null }; gate.SignalAndWait(); return await Repository(factory).GetOrAddFolder(libraryPath, Option.None, path); }); } LibraryFolder[] results = await Task.WhenAll(tasks); // every caller got the same single row back... results.Select(f => f.Id).Distinct().Count().ShouldBe(1); results[0].Id.ShouldBeGreaterThan(0); // ...and exactly one row exists for it (await FolderCount(db.CreateContext, libraryPathId, path)).ShouldBe(1); } await using TvContext context = db.CreateContext(); (await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(rounds); // Vacuity guard: one insert attempt per round would mean the threads never actually collided and // the test proved nothing. More attempts than rounds means at least one caller lost the race and // was rescued by the index + catch. counter.Attempts.ShouldBeGreaterThan(rounds); } // ----- SetEtag is the repository's other check-then-insert on LibraryFolder ----- [Test] public async Task SetEtag_Losing_The_Race_Updates_The_Winner_Instead_Of_Duplicating() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-setetag"); int libraryPathId = await SeedLibraryPath(db.CreateContext); var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null }; var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath); LibraryRepository repository = Repository(db.Factory(racer)); await repository.SetEtag(libraryPath, Option.None, FolderPath, "etag-1"); racer.Fired.ShouldBe(1); await using TvContext context = db.CreateContext(); LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Path == FolderPath); persisted.Etag.ShouldBe("etag-1"); } }