catalog-backend: make MySQL FK swap idempotent with retry loop

Replace the non-atomic MySQL transaction wrapper with a retry loop that
checks information_schema before dropping the FK and retries the add if
new orphan rows appear during the window between DROP and ADD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
Fredrik Adelöw
2026-03-11 14:56:45 +01:00
parent 09c7e49f82
commit 1b69ff9c82
@@ -109,22 +109,41 @@ exports.up = async function up(knex) {
// Batch-delete orphaned rows before DDL to reduce lock time.
await batchDeleteOrphansMysql(knex, 'final_entities');
// Perform the FK changes. Note that in MySQL/InnoDB, ALTER TABLE
// statements cause implicit commits, so wrapping in a transaction does
// not provide full atomicity. However the table is already cleaned of
// orphans and validation remains fast.
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await trx.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
});
});
// Swap the FK with retry logic. MySQL DDL causes implicit commits so
// DROP and ADD are never truly atomic. If new orphan rows sneak in
// between the DROP and ADD, the ADD will fail — we clean up and retry.
// The information_schema check makes the DROP idempotent so that
// re-runs after a partial failure don't crash.
const MAX_ATTEMPTS = 5;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const [fks] = await knex.raw(`
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'search'
AND CONSTRAINT_NAME = 'search_entity_id_foreign'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
`);
if (fks.length > 0) {
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
}
await batchDeleteOrphansMysql(knex, 'final_entities');
try {
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
});
break;
} catch (e) {
if (attempt === MAX_ATTEMPTS) throw e;
}
}
} else {
// SQLite: wrap in an explicit transaction since the global transaction
// wrapper is disabled for this migration.
@@ -172,18 +191,36 @@ exports.down = async function down(knex) {
} else if (client.includes('mysql')) {
await batchDeleteOrphansMysql(knex, 'refresh_state');
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await trx.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
});
});
const MAX_ATTEMPTS = 5;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const [fks] = await knex.raw(`
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'search'
AND CONSTRAINT_NAME = 'search_entity_id_foreign'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
`);
if (fks.length > 0) {
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
}
await batchDeleteOrphansMysql(knex, 'refresh_state');
try {
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
});
break;
} catch (e) {
if (attempt === MAX_ATTEMPTS) throw e;
}
}
} else {
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {