Files
ersatztv/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.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

295 lines
12 KiB
C#

using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvContext> dbContextFactory)
: ILibraryRepository
{
public async Task<LibraryPath> Add(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.LibraryPaths.AddAsync(libraryPath);
await dbContext.SaveChangesAsync();
return libraryPath;
}
public async Task<Option<Library>> GetLibrary(int libraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Libraries
.Include(l => l.Paths)
.ThenInclude(p => p.LibraryFolders)
.ThenInclude(lf => lf.ImageFolderDuration)
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional);
}
public async Task<Option<LocalLibrary>> GetLocal(int libraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.LocalLibraries
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional);
}
public async Task<Option<int>> GetLibraryIdForPath(int libraryPathId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
int? libraryId = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
"SELECT LibraryId FROM LibraryPath WHERE Id = @LibraryPathId",
new { LibraryPathId = libraryPathId });
return Optional(libraryId);
}
public async Task<List<Library>> GetAll()
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Libraries
.AsNoTracking()
.Include(l => l.MediaSource)
.Include(l => l.Paths)
.ToListAsync();
}
public async Task<Unit> UpdateLastScan(Library library)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE Library SET LastScan = @LastScan WHERE Id = @Id",
new { library.LastScan, library.Id }).ToUnit();
}
public async Task<Unit> UpdateLastScan(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id",
new { libraryPath.LastScan, libraryPath.Id }).ToUnit();
}
public async Task<List<LibraryPath>> GetLocalPaths(int libraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.LocalLibraries
.Include(l => l.Paths)
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional)
.Match(l => l.Paths, () => new List<LibraryPath>());
}
public async Task<int> CountMediaItemsByPath(int libraryPathId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPathId });
}
public async Task SetEtag(
LibraryPath libraryPath,
Option<LibraryFolder> knownFolder,
string path,
string etag)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
foreach (LibraryFolder folder in knownFolder)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET Etag = @Etag WHERE Id = @Id",
new { folder.Id, Etag = etag });
}
if (knownFolder.IsNone)
{
var newFolder = new LibraryFolder
{
Path = path,
PathHash = PathUtils.GetPathHash(path),
Etag = etag,
LibraryPathId = libraryPath.Id
};
try
{
await dbContext.LibraryFolders.AddAsync(newFolder);
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
// ersatztv#491: a concurrent caller created this folder between the caller's lookup and
// this insert. The etag write is the whole point of the call, so apply it to the winner's
// row rather than failing the scan.
dbContext.Entry(newFolder).State = EntityState.Detached;
LibraryFolder winner = await GetFolder(dbContext, libraryPath.Id, path);
if (winner is null)
{
throw;
}
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET Etag = @Etag WHERE Id = @Id",
new { winner.Id, Etag = etag });
}
}
}
public async Task CleanEtagsForLibraryPath(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
IOrderedEnumerable<LibraryFolder> orderedFolders = libraryPath.LibraryFolders
.Where(f => !fileSystem.Directory.Exists(f.Path))
.OrderByDescending(lp => lp.Path.Length);
foreach (LibraryFolder folder in orderedFolders)
{
await dbContext.Connection.ExecuteAsync(
"""
DELETE FROM LibraryFolder WHERE Id = @LibraryFolderId
AND NOT EXISTS (SELECT Id FROM MediaFile WHERE LibraryFolderId = @LibraryFolderId)
AND NOT EXISTS (SELECT Id FROM LibraryFolder WHERE ParentId = @LibraryFolderId)
""",
new { LibraryFolderId = folder.Id });
}
}
public async Task<Option<int>> GetParentFolderId(
LibraryPath libraryPath,
string folder,
CancellationToken cancellationToken)
{
DirectoryInfo parent = new DirectoryInfo(folder).Parent;
if (parent is null)
{
return Option<int>.None;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders
.AsNoTracking()
.Filter(lf => lf.LibraryPathId == libraryPath.Id)
.SelectOneAsync(lf => lf.Path, lf => lf.Path == parent.FullName, cancellationToken)
.MapT(lf => lf.Id);
}
public async Task<LibraryFolder> GetOrAddFolder(
LibraryPath libraryPath,
Option<int> maybeParentFolder,
string folder)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
// load from db or create new folder. Look the folder up by (LibraryPathId, Path) rather than
// reading libraryPath.LibraryFolders: that navigation collection is only eager-loaded on the
// local scan path (via GetLibrary) and is null on the remote (Jellyfin) sync path, which used
// to NRE every Jellyfin music-video scan here (ersatztv#488). The local scanners already hit
// the db once per folder via GetParentFolderId, so this adds no new query pattern.
LibraryFolder knownFolder = await GetFolder(dbContext, libraryPath.Id, folder);
// add new folder to library path
if (knownFolder is null)
{
LibraryFolder newFolder = CreateNewFolder(libraryPath, maybeParentFolder, folder);
try
{
await dbContext.LibraryFolders.AddAsync(newFolder);
await dbContext.SaveChangesAsync();
knownFolder = newFolder;
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
// ersatztv#491: the lookup above is not atomic with this insert, so a concurrent caller
// scanning the same folder can slip its row in between. The unique index on
// (LibraryPathId, PathHash) turns that lost race into a constraint violation instead of a
// duplicate row; adopt the winner's row rather than failing the scan. Detach first so the
// failed insert is not retried by anything reusing this context.
dbContext.Entry(newFolder).State = EntityState.Detached;
knownFolder = await GetFolder(dbContext, libraryPath.Id, folder);
if (knownFolder is null)
{
// no winner to adopt — the violation came from somewhere else, so don't swallow it
throw;
}
}
}
else if (string.IsNullOrEmpty(knownFolder.PathHash))
{
// heal a row created before the PathHash column existed, so it participates in the unique
// index from here on (a null hash is distinct from every other value, so it does not).
knownFolder.PathHash = PathUtils.GetPathHash(folder);
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET PathHash = @PathHash WHERE Id = @Id",
new { knownFolder.PathHash, knownFolder.Id });
}
// update parent folder if not present
foreach (int parentFolder in maybeParentFolder)
{
if (knownFolder.ParentId != parentFolder)
{
knownFolder.ParentId = parentFolder;
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET ParentId = @ParentId WHERE Id = @Id",
new { ParentId = parentFolder, knownFolder.Id });
}
}
return knownFolder;
}
public async Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
mediaFile.LibraryFolderId = libraryFolderId;
await dbContext.Connection.ExecuteAsync(
"UPDATE MediaFile SET LibraryFolderId = @LibraryFolderId WHERE Id = @Id",
new { LibraryFolderId = libraryFolderId, mediaFile.Id });
}
public async Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
libraryPath.Path = normalizedLibraryPath;
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryPath SET Path = @Path WHERE Id = @Id",
new { Path = normalizedLibraryPath, libraryPath.Id });
}
private static Task<LibraryFolder> GetFolder(TvContext dbContext, int libraryPathId, string folder) =>
dbContext.LibraryFolders
.AsNoTracking()
.Filter(f => f.LibraryPathId == libraryPathId && f.Path == folder)
.FirstOrDefaultAsync();
private static LibraryFolder CreateNewFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder)
{
int? parentId = null;
foreach (int parentFolder in maybeParentFolder)
{
parentId = parentFolder;
}
return new LibraryFolder
{
Path = folder,
PathHash = PathUtils.GetPathHash(folder),
Etag = null,
LibraryPathId = libraryPath.Id,
ParentId = parentId
};
}
}