Files
ersatztv/ErsatzTV.Infrastructure.Sqlite/Migrations/20260725121714_Add_LibraryFolder_PathHash_UniqueIndex.cs
T
timothy 14e9b03433 fix(491): wire the unique-violation classifier in the scanner; make folder lookup case-exact
Review of 491f5099 found the fix inert in the only process that runs it, plus
a MySQL collation defect in the lookup.

B1 — TvContext.IsUniqueConstraintViolation was assigned only in ErsatzTV/
Startup.cs, but ErsatzTV.Scanner is a separate executable and every production
caller of GetOrAddFolder/SetEtag lives there. The classifier kept its '_ =>
false' default, so the catch never ran and the DbUpdateException failed the
whole scan - worse than the duplicate row it replaced. Wire both provider
branches in ErsatzTV.Scanner/Program.cs, and add ProviderStaticsWiringTests
(architecture) asserting the scanner assigns every TvContext static the host
assigns, with IsSqlite documented as the one exemption.

H1 — GetFolder's 'Path == folder' is case-insensitive on MySQL while PathHash
is case-sensitive, and FirstOrDefault was unordered: a scan of '/x/foo' could
resolve the '/x/Foo' row and stamp the wrong hash onto it (verified on MySQL
8.4: the WHERE matches both, LIMIT 1 returns the wrong one). Treat the SQL
equality as a narrowing filter, order by Id, and settle identity ordinally.
Route the heal through EF and drop a classified violation, so an opportunistic
maintenance write can never abort a scan.

Also: run the dedupe migration test with foreign keys ON (matching prod), clear
the keeper's etag, null out a self-parent, and document the cleanup's limits
(NULL paths excluded, Down does not restore deleted rows, CI's fresh-DB apply
covers none of the data mutation).

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

158 lines
7.2 KiB
C#

using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_LibraryFolder_PathHash_UniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ersatztv#491 — audit/clean pre-existing duplicate LibraryFolder rows before the unique index.
// Duplicates were reachable before #488 (the folder lookup read a scan-start in-memory snapshot,
// so a folder created earlier in the SAME scan was invisible and inserted again) and via the
// check-then-insert race the index now closes. Keep the lowest Id per (LibraryPathId, Path) and
// repoint every dependent row at it before deleting the losers. The helper tables keep the
// statements readable; DROP TABLE IF EXISTS makes a retry after a partial failure safe.
migrationBuilder.Sql("DROP TABLE IF EXISTS __LibraryFolderDedupe");
migrationBuilder.Sql(
"""
CREATE TABLE __LibraryFolderDedupe (
LoserId INTEGER NOT NULL PRIMARY KEY,
KeeperId INTEGER NOT NULL
)
""");
migrationBuilder.Sql(
"""
INSERT INTO __LibraryFolderDedupe (LoserId, KeeperId)
SELECT l.Id, k.KeeperId
FROM LibraryFolder l
INNER JOIN (
SELECT LibraryPathId, Path, MIN(Id) AS KeeperId
FROM LibraryFolder
GROUP BY LibraryPathId, Path
) k ON k.LibraryPathId = l.LibraryPathId AND k.Path = l.Path
WHERE l.Id <> k.KeeperId
""");
// media files recorded against a duplicate folder follow the keeper (MediaFile.LibraryFolderId
// is Restrict, so the delete below would fail otherwise)
migrationBuilder.Sql(
"""
UPDATE MediaFile
SET LibraryFolderId = (
SELECT KeeperId FROM __LibraryFolderDedupe WHERE LoserId = MediaFile.LibraryFolderId)
WHERE LibraryFolderId IN (SELECT LoserId FROM __LibraryFolderDedupe)
""");
// child folders parented on a duplicate follow the keeper (ParentId is Restrict as well)
migrationBuilder.Sql(
"""
UPDATE LibraryFolder
SET ParentId = (
SELECT KeeperId FROM __LibraryFolderDedupe WHERE LoserId = LibraryFolder.ParentId)
WHERE ParentId IN (SELECT LoserId FROM __LibraryFolderDedupe)
""");
// A folder parented on its OWN duplicate would become its own parent above. Unreachable from
// any code path today, but this is a tree the scanner walks, so remove the cycle class rather
// than reason about it.
migrationBuilder.Sql("UPDATE LibraryFolder SET ParentId = NULL WHERE ParentId = Id");
// Clear the survivor's etag. Which duplicate the scanner was actually writing to was
// arbitrary, so MIN(Id)'s etag may describe a stale view of the folder and would suppress the
// next rescan. A null etag costs exactly one rescan and cannot be wrong.
migrationBuilder.Sql(
"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN (SELECT KeeperId FROM __LibraryFolderDedupe)");
// ImageFolderDuration is 1:1 with LibraryFolder (unique index on LibraryFolderId), so the
// duplicates' rows cannot all be repointed. Keep the keeper's own setting when it has one;
// otherwise promote exactly one loser's (lowest Id) and drop the rest.
migrationBuilder.Sql("DROP TABLE IF EXISTS __LibraryFolderDedupeIfd");
migrationBuilder.Sql(
"""
CREATE TABLE __LibraryFolderDedupeIfd (
KeeperId INTEGER NOT NULL PRIMARY KEY,
IfdId INTEGER NOT NULL
)
""");
migrationBuilder.Sql(
"""
INSERT INTO __LibraryFolderDedupeIfd (KeeperId, IfdId)
SELECT d.KeeperId, MIN(i.Id)
FROM __LibraryFolderDedupe d
INNER JOIN ImageFolderDuration i ON i.LibraryFolderId = d.LoserId
WHERE NOT EXISTS (
SELECT 1 FROM ImageFolderDuration ki WHERE ki.LibraryFolderId = d.KeeperId)
GROUP BY d.KeeperId
""");
migrationBuilder.Sql(
"""
DELETE FROM ImageFolderDuration
WHERE LibraryFolderId IN (SELECT LoserId FROM __LibraryFolderDedupe)
AND Id NOT IN (SELECT IfdId FROM __LibraryFolderDedupeIfd)
""");
migrationBuilder.Sql(
"""
UPDATE ImageFolderDuration
SET LibraryFolderId = (
SELECT KeeperId FROM __LibraryFolderDedupeIfd WHERE IfdId = ImageFolderDuration.Id)
WHERE Id IN (SELECT IfdId FROM __LibraryFolderDedupeIfd)
""");
migrationBuilder.Sql(
"DELETE FROM LibraryFolder WHERE Id IN (SELECT LoserId FROM __LibraryFolderDedupe)");
migrationBuilder.Sql("DROP TABLE __LibraryFolderDedupeIfd");
migrationBuilder.Sql("DROP TABLE __LibraryFolderDedupe");
migrationBuilder.AddColumn<string>(
name: "PathHash",
table: "LibraryFolder",
type: "TEXT",
maxLength: 64,
nullable: true);
// Existing rows keep a null hash on purpose: a unique index treats nulls as distinct, so the
// index applies cleanly to any database, and LibraryRepository.GetOrAddFolder heals each row
// (SHA-256 of Path) the first time a scan touches it. Those rows are deduplicated above and are
// still found by the Path lookup, so no insert can race them in the meantime.
//
// Create-then-drop rather than EF's scaffolded drop-then-create, matching the MySql copy, where
// InnoDB refuses to drop the foreign key's only backing index.
migrationBuilder.CreateIndex(
name: "IX_LibraryFolder_LibraryPathId_PathHash",
table: "LibraryFolder",
columns: new[] { "LibraryPathId", "PathHash" },
unique: true);
migrationBuilder.DropIndex(
name: "IX_LibraryFolder_LibraryPathId",
table: "LibraryFolder");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_LibraryFolder_LibraryPathId",
table: "LibraryFolder",
column: "LibraryPathId");
migrationBuilder.DropIndex(
name: "IX_LibraryFolder_LibraryPathId_PathHash",
table: "LibraryFolder");
migrationBuilder.DropColumn(
name: "PathHash",
table: "LibraryFolder");
}
}
}