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");
}
}