fix: make the genre dedup migration collision-proof (0038)
All checks were successful
deploy / test-backend (push) Successful in 38m34s
deploy / test-frontend (push) Successful in 10m58s
deploy / build-and-push (push) Successful in 11m46s
deploy / deploy (push) Successful in 24s

Review caught a boot-crash risk: 0038's repoint-UPDATE could set two
non-canonical variant links of one manga to the same canonical id in a single
statement (NOT EXISTS sees the pre-statement snapshot) -> manga_genres PK
violation -> migration rollback -> startup failure. Replace with INSERT ... SELECT
DISTINCT ... ON CONFLICT DO NOTHING (collision-proof) then delete variants. Amended
in place (0038 is unpushed). Tested with the exact collision scenario.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-14 19:12:52 +02:00
parent 08a9819d76
commit 33cc41bacd
5 changed files with 113 additions and 27 deletions

View File

@@ -674,6 +674,93 @@ async fn arbitrary_genres_from_source_get_inserted(pool: PgPool) {
assert_eq!(webtoons_count.0, 1, "case-insensitive lookup reuses the existing row");
}
#[sqlx::test(migrations = "./migrations")]
async fn genre_dedup_survives_a_manga_linked_to_two_variants(pool: PgPool) {
// Regression for a migration-0038 collision: a manga linked to TWO
// non-canonical case-variants of one genre. A repoint-UPDATE would set both
// rows to the canonical id in one statement -> manga_genres PK violation ->
// migration rollback -> boot failure. #[sqlx::test] migrates a clean DB, so
// recreate the dirty pre-index state and replay 0038's healing statements;
// they must complete and leave exactly the one canonical link. (Mirrors the
// healing SQL in migrations/0038_genres_name_lower_unique.sql.)
let manga = Uuid::new_v4();
sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'T')")
.bind(manga)
.execute(&pool)
.await
.unwrap();
// Drop the live guard so we can plant case-variant genres.
sqlx::query("DROP INDEX genres_name_lower_uniq").execute(&pool).await.unwrap();
let keep = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let dup2 = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
let dup3 = Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();
for (id, name) in [(keep, "Zzz"), (dup2, "zzz"), (dup3, "ZZZ")] {
sqlx::query("INSERT INTO genres (id, name) VALUES ($1, $2)")
.bind(id)
.bind(name)
.execute(&pool)
.await
.unwrap();
}
// The manga links to the two NON-canonical variants, not the canonical one.
for gid in [dup2, dup3] {
sqlx::query("INSERT INTO manga_genres (manga_id, genre_id) VALUES ($1, $2)")
.bind(manga)
.bind(gid)
.execute(&pool)
.await
.unwrap();
}
// Step 1 — collision-proof canonical-link backfill (the crux of the fix).
sqlx::query(
"INSERT INTO manga_genres (manga_id, genre_id) \
SELECT DISTINCT mg.manga_id, k.keep_id \
FROM manga_genres mg JOIN genres g ON mg.genre_id = g.id \
JOIN (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id \
FROM genres GROUP BY lower(name)) k ON lower(g.name) = k.lname \
WHERE g.id <> k.keep_id \
ON CONFLICT (manga_id, genre_id) DO NOTHING",
)
.execute(&pool)
.await
.expect("collision-proof INSERT must not raise a manga_genres PK violation");
// Step 2 — drop the non-canonical links.
sqlx::query(
"DELETE FROM manga_genres mg USING genres g, \
(SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id \
FROM genres GROUP BY lower(name)) k \
WHERE mg.genre_id = g.id AND lower(g.name) = k.lname AND g.id <> k.keep_id",
)
.execute(&pool)
.await
.unwrap();
// Step 3 — remove orphaned duplicate genres.
sqlx::query(
"DELETE FROM genres g USING \
(SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id \
FROM genres GROUP BY lower(name)) k \
WHERE lower(g.name) = k.lname AND g.id <> k.keep_id",
)
.execute(&pool)
.await
.unwrap();
// Exactly the canonical link remains, and the guard re-creates cleanly.
let links: Vec<Uuid> =
sqlx::query_scalar("SELECT genre_id FROM manga_genres WHERE manga_id = $1")
.bind(manga)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(links, vec![keep], "manga keeps exactly the canonical genre link");
sqlx::query("CREATE UNIQUE INDEX genres_name_lower_uniq ON genres (lower(name))")
.execute(&pool)
.await
.expect("no residual case-variant duplicates remain");
}
#[sqlx::test(migrations = "./migrations")]
async fn genres_reject_case_variant_duplicates_at_the_db(pool: PgPool) {
// The sequential pre-check in sync_genres dedups the common case, but two