Files
ersatztv/ErsatzTV.Tests/Integration/LibraryFolderConcurrencyTests.cs
T
timothy 9a4f3e832d fix(491): unique index on LibraryFolder(LibraryPathId, PathHash) + tolerate concurrent insert
GetOrAddFolder was a check-then-insert with no unique constraint behind it,
so two callers racing the same folder could both miss the lookup and both
insert. Enforce identity in the schema and make the loser adopt the winner.

- LibraryFolder gains a SHA-256 PathHash (the MediaFile.Path/PathHash
  precedent): Path is MySQL longtext, which cannot be indexed without a
  prefix length and collates case-insensitively, so the unique index is on
  (LibraryPathId, PathHash) instead.
- GetOrAddFolder and SetEtag catch a classified unique violation via the
  existing TvContext.IsUniqueConstraintViolation seam (#308) and re-read.
- Dual-provider migration audits and collapses pre-existing duplicates
  (repointing MediaFile, ParentId and ImageFolderDuration) before creating
  the index; legacy rows keep a null hash and heal on the next scan.
- Tests: deterministic cross-connection race, 8x10 barrier stress with an
  insert-attempt vacuity guard, classifier-inversion negative control, and
  a real-migration dedupe test.

Refs #488 #308
fix #491
2026-07-25 21:13:22 +02:00

303 lines
13 KiB
C#

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;
/// <summary>
/// ersatztv#491: <c>ILibraryRepository.GetOrAddFolder</c> is a check-then-insert, so two callers
/// racing the same <c>(LibraryPathId, Path)</c> both miss the lookup and both insert. The fix is a
/// unique index on <c>(LibraryPathId, PathHash)</c> plus a catch-and-re-read in the repository, so
/// the loser adopts the winner's row instead of creating a duplicate.
/// </summary>
[TestFixture]
public class LibraryFolderConcurrencyTests
{
private const string LibraryPathValue = "/data/music";
private const string FolderPath = "/data/music/artist1";
private static LibraryRepository Repository(IDbContextFactory<TvContext> factory) =>
new(Substitute.For<IFileSystem>(), factory);
private static async Task<int> SeedLibraryPath(Func<TvContext> 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<int> FolderCount(Func<TvContext> 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);
}
/// <summary>
/// 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. <see cref="Fired" /> proves the race actually happened (non-vacuity).
/// </summary>
private sealed class InsertConflictingFolderOnce(SharedCacheTvContext db, int libraryPathId, string path)
: SaveChangesInterceptor
{
private int _fired;
public int Fired => _fired;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _fired, 1) == 0)
{
await InsertFolderRaw(db, libraryPathId, path, PathUtils.GetPathHash(path), cancellationToken);
}
return result;
}
}
/// <summary>Counts insert attempts so the multi-threaded test can prove it really raced.</summary>
private sealed class CountSaveAttempts : SaveChangesInterceptor
{
private int _attempts;
public int Attempts => _attempts;
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> 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<DbUpdateException>(() => 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<int>.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<int>.None, LibraryPathValue);
var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath);
LibraryFolder result = await Repository(db.Factory(racer))
.GetOrAddFolder(libraryPath, Option<int>.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<DbUpdateException, bool> 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<DbUpdateException>(
() => repository.GetOrAddFolder(libraryPath, Option<int>.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<TvContext> 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<LibraryFolder>[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<int>.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<LibraryFolder>.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");
}
}