From d7171a5ca40427f8341fb7b5d2e94217b3c8fea1 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Wed, 4 Nov 2026 00:32:09 +0100 Subject: [PATCH] Pending ladder: the sweep and the record lock their rows in key order discardKeys updated with `release_key = ANY(subquery)`, which locks rows in the plan's scan order, and deadlocked against a batch upsert that takes its rows by key (LOG.md (137)). It now updates from a subquery that selects the rows ORDER BY release_key FOR UPDATE, so the locks are taken sorted (the plan: LockRows above Sort). record() runs the same SELECT ... FOR UPDATE over its taken rows before its UPDATE ... FROM jsonb_to_recordset, whose join order the planner chooses. Co-Authored-By: Claude Fable 5.1 --- REBUILD.md | 9 ++++++--- app/services/pending_ladder.ts | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/REBUILD.md b/REBUILD.md index 4bc685e..282f607 100644 --- a/REBUILD.md +++ b/REBUILD.md @@ -950,9 +950,12 @@ f. **Nothing enters the catalog without a usable name** (amended climb, each by an index (`pending_releases_signature_idx` for a poster's rows: tested on the rows, a signature was a scan of the whole table; LOG.md (133), (134)). The batch store, the record, the claim - and the sweep lock pending rows in one order (`lockOrder`), and a - flush that deadlocks anyway keeps its outcomes for the next (LOG.md - (134)). The namer + and the sweep lock pending rows in one order, by key and the counters + after (`lockOrder` for the multi-row statements; the record and the + sweep lock their rows with `SELECT … ORDER BY release_key FOR UPDATE` + first, since an UPDATE's join or `= ANY(subquery)` locks in plan + order), and a flush that deadlocks anyway keeps its outcomes for the + next (LOG.md (134), (137)). The namer (`app/services/pending_namer.ts`, in the prober's process): a named release's article span read again from the corpus (the stored range's chunks) or by XOVER on a connection of its own, a chunk of articles diff --git a/app/services/pending_ladder.ts b/app/services/pending_ladder.ts index 3416c16..5ffa0b7 100644 --- a/app/services/pending_ladder.ts +++ b/app/services/pending_ladder.ts @@ -289,6 +289,12 @@ class PendingLadder { final: null, } }) + // The rows locked in key order first: the UPDATE's join below takes them in whatever order the plan does. + await trx.rawQuery( + `SELECT 1 FROM pending_releases WHERE release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k) + ORDER BY release_key FOR UPDATE`, + [rows.map((r) => r.release_key).sort()] + ) await trx.rawQuery( `UPDATE pending_releases p SET status = v.status, tries = v.tries, named = coalesce(v.named, p.named), inspections = CASE WHEN v.inspection IS NULL THEN p.inspections ELSE p.inspections || v.inspection END, @@ -470,9 +476,15 @@ class PendingLadder { .filter((key: string) => !explored(key, samplePercent)) .sort() if (keys.length === 0) return 0 + // Locked in key order (the record's lock order, LOG.md (137)): a plain `key = ANY(subquery)` locks in scan + // order and deadlocked against a batch upsert; ORDER BY sorts before FOR UPDATE locks. const { rows: gone } = await db.rawQuery( - `UPDATE pending_releases SET status = ? WHERE status = ? AND release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k) - RETURNING tool, group_id, klass`, + `UPDATE pending_releases p SET status = ? + FROM (SELECT release_key FROM pending_releases + WHERE status = ? AND release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k) + ORDER BY release_key FOR UPDATE) k + WHERE p.release_key = k.release_key + RETURNING p.tool, p.group_id, p.klass`, [PENDING.discarded, PENDING.waiting, keys] ) const counts = new Map()