From c72bf68fd29997d83fe5998946ceb1fc5db875c1 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Mon, 12 Oct 2026 22:28:50 +0200 Subject: [PATCH] Par2 merge: a set held by two merged releases; one failed set doesn't stop a round A par2 index and its repost both hold a set: moving both rows to the target broke par2_sets' primary key, and the round failed on it every minute from 20:28 UTC (no merges at all). Duplicates among the others go first; a set that fails is logged and the round goes on. Co-Authored-By: Claude Opus 5.5 --- app/services/par2_merger.ts | 16 +++++++++++++--- tests/functional/par2_merger.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/services/par2_merger.ts b/app/services/par2_merger.ts index 192c2b8..9822280 100644 --- a/app/services/par2_merger.ts +++ b/app/services/par2_merger.ts @@ -72,7 +72,11 @@ export class Par2Merger { const round: MergeRound = { sets: rows.length, merges: 0, releases: 0 } for (const { set_id: setId } of rows) { if (this.stopped) return round - const merged = await this.mergeSet(setId.trim()) + // One set that fails doesn't hold up the others (nor the next round, which would start from the same point). + const merged = await this.mergeSet(setId.trim()).catch((error) => { + logger.error(`Par2 merge of set ${setId.trim()} failed: ${error.message}`) + return 0 + }) if (merged > 0) { round.merges++ round.releases += merged + 1 @@ -129,8 +133,14 @@ export class Par2Merger { for (const table of ['release_chunks', 'probe_candidates', 'release_inspections', 'file_hashes']) { await trx.rawQuery(`UPDATE ${table} SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others]) } - // A set held by the target already isn't held twice. - await trx.rawQuery(`DELETE FROM par2_sets WHERE release_id = ANY(?) AND set_id IN (SELECT set_id FROM par2_sets WHERE release_id = ?)`, [others, target.id]) + // A set held by the target already, or by two of the others (a par2 and its repost), isn't held twice (every + // round failed on the primary key from 20:28 UTC on 2026-10-12; LOG.md 2026-10-12 (30)). + await trx.rawQuery( + `DELETE FROM par2_sets p WHERE p.release_id = ANY(?) + AND (EXISTS (SELECT 1 FROM par2_sets t WHERE t.set_id = p.set_id AND t.release_id = ?) + OR EXISTS (SELECT 1 FROM par2_sets o WHERE o.set_id = p.set_id AND o.release_id = ANY(?) AND o.release_id < p.release_id))`, + [others, target.id, others] + ) await trx.rawQuery(`UPDATE par2_sets SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others]) await trx.rawQuery( `UPDATE releases t SET diff --git a/tests/functional/par2_merger.spec.ts b/tests/functional/par2_merger.spec.ts index 8ec420d..4c1db80 100644 --- a/tests/functional/par2_merger.spec.ts +++ b/tests/functional/par2_merger.spec.ts @@ -63,6 +63,29 @@ test.group('Par2Merger', (group) => { assert.deepEqual(await new Par2Merger().round().then((r) => r.merges), 0) }) + test('a set held by two of the releases merged is held once by the target', async ({ assert }) => { + const at = new Date('2022-07-08T03:46:00Z') + const ids: number[] = [] + for (const [key, files] of [['bb000001', 1], ['bb000002', 1], ['bb000003', 3]] as const) { + const [row] = await db + .table('releases') + .insert({ release_key: key.padEnd(40, '0'), identity_key: key.padEnd(40, '0'), name: key, source: 'stem', groups: '[]', group_id: 1, files, posted_at: at, readable: false }) + .returning('id') + ids.push(Number(row.id)) + } + const setId = hash('7') + // The par2 index and its repost each hold the set; the third release holds the data file. + await db.table('par2_sets').multiInsert([ + { set_id: setId, release_id: ids[0], files: 1 }, + { set_id: setId, release_id: ids[1], files: 1 }, + ]) + await db.table('par2_files').insert({ set_id: setId, file_id: hash('6'), hash16k: hash('5'), length: 1000, name: 'x.rar' }) + await db.table('file_hashes').insert({ candidate_id: 3001, release_id: ids[2], hash16k: hash('5'), size: 1000 }) + assert.equal(await new Par2Merger().mergeSet(setId), 2) + const held = await db.from('par2_sets').where('set_id', setId).select('release_id') + assert.deepEqual(held.map((r) => Number(r.release_id)), [ids[2]]) + }) + test('a set file that matches several releases nearby is ambiguous: not merged', async ({ assert }) => { const at = new Date('2022-07-08T03:46:00Z') const ids: number[] = []