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
39 lines
1.7 KiB
C#
39 lines
1.7 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
|
|
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
|
|
|
public class LibraryFolderConfiguration : IEntityTypeConfiguration<LibraryFolder>
|
|
{
|
|
public void Configure(EntityTypeBuilder<LibraryFolder> builder)
|
|
{
|
|
builder.ToTable("LibraryFolder");
|
|
|
|
// ersatztv#491: GetOrAddFolder is a check-then-insert, so two callers racing the same folder both
|
|
// miss the lookup and both insert. The unique index makes the duplicate impossible at the storage
|
|
// layer (the loser gets a constraint violation the repository catches and re-reads).
|
|
// Indexed on PathHash rather than Path because Path is unbounded (MySQL longtext, which cannot be
|
|
// indexed without a prefix length, and whose default collation is case-INsensitive — a prefix
|
|
// index would also false-collide sibling folders differing only in case on a case-sensitive
|
|
// filesystem). This mirrors the existing MediaFile.Path/PathHash pair.
|
|
builder.Property(f => f.PathHash)
|
|
.HasMaxLength(64);
|
|
|
|
builder.HasIndex(f => new { f.LibraryPathId, f.PathHash })
|
|
.IsUnique();
|
|
|
|
builder.HasOne(f => f.Parent)
|
|
.WithMany(p => p.Children)
|
|
.HasForeignKey(f => f.ParentId)
|
|
.IsRequired(false)
|
|
.OnDelete(DeleteBehavior.Restrict);
|
|
|
|
builder.HasOne(f => f.ImageFolderDuration)
|
|
.WithOne(ifd => ifd.LibraryFolder)
|
|
.HasForeignKey<ImageFolderDuration>(ifd => ifd.LibraryFolderId)
|
|
.IsRequired(false)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
}
|
|
}
|