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
158 lines
7.2 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|