Skip to content

Commit ae2472b

Browse files
committed
fix(selfhost): re-check dead status on dead-letter revive UPDATE
The AI review on this PR flagged a real defect: reviveEligibleDeadJobs selects candidate dead jobs, then updates each one back to 'pending' by id alone. That SELECT is a stale snapshot -- an overlapping reviver (a second self-host instance sharing the same Postgres database, or a slow prior revive tick still running when the next one fires) can move a row out of 'dead' between the SELECT and this row's own UPDATE. With no re-check, the UPDATE blindly flips whatever the row's CURRENT status is back to 'pending', including a row already claimed into 'processing' -- letting the job run a second time concurrently. Both backends now add "AND status='dead'" to the revive UPDATE, exactly mirroring the same defensive predicate already used elsewhere in both files for the same class of stale-snapshot sweep (reclaimExpiredProcessingJobs / deferPendingJobsForRateLimit). The revived count now reflects rows the UPDATE actually matched (pg's rowCount / sqlite's changes), not the raw SELECT count, so a row another reviver already claimed is correctly excluded rather than double-counted. Added a regression test per backend: Postgres via a mock pool with per-call configurable UPDATE rowCounts (proving a 0-rowCount UPDATE isn't counted as revived); SQLite via a driver.query spy that injects the competing status change at the exact point the real UPDATE would otherwise race against it (proving the row is left untouched, not reverted to pending).
1 parent 18f9735 commit ae2472b

4 files changed

Lines changed: 93 additions & 8 deletions

File tree

src/selfhost/pg-queue.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,16 @@ export function createPgQueue(
229229
const maxJitter = queueRecoveryJitterMs();
230230
for (const row of res.rows as Array<{ id: string; payload: string; job_key?: string | null }>) {
231231
const runAfter = now + deterministicJitterMs(`revive:${row.job_key ?? ""}:${row.id}:${row.payload}`, maxJitter);
232-
await pool.query(`UPDATE ${TABLE} SET status='pending', run_after=$1, last_error=NULL WHERE id=$2`, [
233-
runAfter,
234-
row.id,
235-
]);
236-
revived += 1;
232+
// AND status='dead' re-checks the row is STILL dead at UPDATE time (mirrors reclaimExpiredProcessingJobs /
233+
// deferPendingJobsForRateLimit above) — the SELECT above is a stale snapshot, and without this predicate an
234+
// overlapping reviver (another self-host instance, or a slow prior revive tick still running when the next
235+
// one fires) could flip a row that's already been claimed into 'processing' back to 'pending', letting it
236+
// run a second time concurrently. rowCount is 0 (not counted as revived) when another reviver won the race.
237+
const update = await pool.query(
238+
`UPDATE ${TABLE} SET status='pending', run_after=$1, last_error=NULL WHERE id=$2 AND status='dead'`,
239+
[runAfter, row.id],
240+
);
241+
revived += update.rowCount ?? 0;
237242
}
238243
return revived;
239244
}

src/selfhost/sqlite-queue.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -686,11 +686,16 @@ function reviveEligibleDeadJobs(driver: SqliteDriver, maxRetries: number): numbe
686686
const maxJitter = queueRecoveryJitterMs();
687687
for (const row of rows as Array<{ id: number; payload: string; job_key?: string | null }>) {
688688
const runAfter = now + deterministicJitterMs(`revive:${row.job_key ?? ""}:${row.id}:${row.payload}`, maxJitter);
689-
driver.query(
690-
`UPDATE ${TABLE} SET status='pending', run_after=?, last_error=NULL WHERE id=?`,
689+
// AND status='dead' re-checks the row is STILL dead at UPDATE time (mirrors deferPendingJobsForRateLimit /
690+
// the processing-lease reclaim below) — the SELECT above is a stale snapshot, and without this predicate an
691+
// overlapping revive (a slow prior revive tick still running when the next one fires) could flip a row
692+
// that's already been claimed into 'processing' back to 'pending', letting it run a second time concurrently.
693+
// `changes` is 0 (not counted as revived) when the row already moved out of 'dead'.
694+
const { changes } = driver.query(
695+
`UPDATE ${TABLE} SET status='pending', run_after=?, last_error=NULL WHERE id=? AND status='dead'`,
691696
[runAfter, row.id],
692697
);
693-
revived += 1;
698+
revived += changes;
694699
}
695700
return revived;
696701
}

test/unit/selfhost-pg-queue.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,17 @@ interface MockPool {
5858
/** Pre-load a job to be returned by the next RETURNING claim query. */
5959
enqueueJob(id: string, payload: object, attempts?: number, jobKey?: string | null): void;
6060
setDeferUpdateRowCount(rowCount: number): void;
61+
/** Queues per-call rowCounts for the "AND status='dead'" revive UPDATE, one entry consumed per call in order
62+
* (default 1 when the queue is empty) — lets a test simulate an overlapping reviver already winning the race
63+
* on a specific row (rowCount 0) while another succeeds (rowCount 1). */
64+
setReviveUpdateRowCounts(rowCounts: number[]): void;
6165
setRateLimitRows(rows: Array<{ admission_key?: string | null; repo_full_name?: string | null; remaining: number | string | null; reset_at: string | null; observed_at?: string | null }>): void;
6266
}
6367

6468
function makePool(): MockPool {
6569
const results: Partial<QueryResult>[] = [];
6670
let deferUpdateRowCount = 1;
71+
const reviveUpdateRowCounts: number[] = [];
6772
let rateLimitRows: Array<{ admission_key?: string | null; repo_full_name?: string | null; remaining: number | string | null; reset_at: string | null; observed_at?: string | null }> = [];
6873
const fn = vi.fn().mockImplementation(async (sql: unknown, params?: unknown[]) => {
6974
const q = String(sql);
@@ -84,6 +89,10 @@ function makePool(): MockPool {
8489
if (q.includes("SET status='pending', run_after=GREATEST")) {
8590
return { rows: [], rowCount: deferUpdateRowCount };
8691
}
92+
if (q.includes("SET status='pending', run_after=$1, last_error=NULL")) {
93+
const rowCount = reviveUpdateRowCounts.length > 0 ? (reviveUpdateRowCounts.shift() ?? 1) : 1;
94+
return { rows: [], rowCount };
95+
}
8796
// Claim queries use RETURNING — pop from queue; fall through to empty default otherwise.
8897
if (q.includes("RETURNING")) {
8998
const next = results.shift();
@@ -105,6 +114,10 @@ function makePool(): MockPool {
105114
setDeferUpdateRowCount(rowCount) {
106115
deferUpdateRowCount = rowCount;
107116
},
117+
setReviveUpdateRowCounts(rowCounts) {
118+
reviveUpdateRowCounts.length = 0;
119+
reviveUpdateRowCounts.push(...rowCounts);
120+
},
108121
setRateLimitRows(rows) {
109122
rateLimitRows = rows;
110123
},
@@ -959,6 +972,40 @@ describe("createPgQueue (durable #977)", () => {
959972
expect(revived).toBe(0);
960973
expect(await renderMetrics()).not.toContain("gittensory_jobs_dead_letter_revived_total");
961974
});
975+
976+
// REGRESSION (#2581 review defect): the SELECT is a stale snapshot. Without an "AND status='dead'" re-check on
977+
// the UPDATE, an overlapping reviver (another self-host instance, or a slow prior tick still running when the
978+
// next one fires) that already moved a row out of 'dead' -- e.g. into 'processing' via a normal claim -- would
979+
// get silently flipped back to 'pending' by this stale UPDATE, letting the job run a second time concurrently.
980+
it("does NOT count a row as revived when another reviver already moved it out of 'dead' (rowCount 0) -- only the row that actually changed status counts", async () => {
981+
const m = makePool();
982+
m.fn.mockResolvedValueOnce({
983+
rows: [
984+
{ id: "1", payload: JSON.stringify({ type: "t" }), job_key: null },
985+
{ id: "2", payload: JSON.stringify({ type: "t" }), job_key: "k" },
986+
],
987+
rowCount: 2,
988+
}); // SELECT status='dead' AND attempts<ceiling -- a stale snapshot of both rows
989+
// Row "1" lost the race (another reviver/claim already moved it out of 'dead' -- UPDATE affects 0 rows);
990+
// row "2" is still genuinely dead and gets revived.
991+
m.setReviveUpdateRowCounts([0, 1]);
992+
const q = createPgQueue(m.pool, async () => undefined, { maxRetries: 1 });
993+
994+
const revived = await q.reviveDeadLetterJobs();
995+
996+
// Only the ONE row whose UPDATE actually matched (still 'dead' at UPDATE time) counts -- not the raw SELECT
997+
// count of 2, which would have double-counted the row another reviver already claimed.
998+
expect(revived).toBe(1);
999+
expect(m.fn).toHaveBeenCalledWith(
1000+
expect.stringContaining("SET status='pending', run_after=$1, last_error=NULL WHERE id=$2 AND status='dead'"),
1001+
expect.arrayContaining(["1"]),
1002+
);
1003+
expect(m.fn).toHaveBeenCalledWith(
1004+
expect.stringContaining("SET status='pending', run_after=$1, last_error=NULL WHERE id=$2 AND status='dead'"),
1005+
expect.arrayContaining(["2"]),
1006+
);
1007+
expect(await renderMetrics()).toContain("gittensory_jobs_dead_letter_revived_total 1");
1008+
});
9621009
});
9631010

9641011
it("reschedules GitHub rate-limit failures without consuming the dead-letter budget", async () => {

test/unit/selfhost-sqlite-queue.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,34 @@ describe("createSqliteQueue (durable #980)", () => {
12571257
expect(q.reviveDeadLetterJobs()).toBe(0);
12581258
});
12591259

1260+
// REGRESSION (#2581 review defect, parity with the same fix in pg-queue.ts): the SELECT that finds eligible
1261+
// dead jobs is a stale snapshot. Without an "AND status='dead'" re-check on the UPDATE, a row that stops
1262+
// being 'dead' between the SELECT and this row's own UPDATE (e.g. claimed by an overlapping revive/process)
1263+
// would get silently flipped back to 'pending' regardless of its CURRENT status, letting it run a second
1264+
// time concurrently. Engineered here via a driver.query spy that injects the status change at the exact
1265+
// point the real revive UPDATE would otherwise race against it.
1266+
it("does not flip a row back to pending if it stops being 'dead' between the SELECT and its own UPDATE", async () => {
1267+
const driver = makeDriver();
1268+
const realQuery = driver.query.bind(driver);
1269+
const q = createSqliteQueue(driver, async () => { throw new Error("boom"); }, { maxRetries: 1, backoffMs: () => 0 });
1270+
await q.binding.send(msg("x"));
1271+
await q.drain(); // dies at attempts=1 (maxRetries=1)
1272+
expect(q.deadCount()).toBe(1);
1273+
1274+
vi.spyOn(driver, "query").mockImplementation((sql: string, params: unknown[]) => {
1275+
if (sql.includes("SET status='pending', run_after=?, last_error=NULL")) {
1276+
realQuery(`UPDATE _selfhost_jobs SET status='processing' WHERE id=?`, [params[1] as number]);
1277+
}
1278+
return realQuery(sql, params);
1279+
});
1280+
1281+
const revived = q.reviveDeadLetterJobs();
1282+
1283+
expect(revived).toBe(0); // the UPDATE's "AND status='dead'" matched zero rows -- not counted as revived
1284+
const { rows } = driver.query("SELECT status FROM _selfhost_jobs", []);
1285+
expect((rows[0] as { status: string }).status).toBe("processing"); // untouched, NOT reverted to pending
1286+
});
1287+
12601288
it("runs automatically on the configured revive interval while the queue is running", async () => {
12611289
process.env.QUEUE_DEAD_LETTER_REVIVE_INTERVAL_MS = "1000";
12621290
vi.useFakeTimers();

0 commit comments

Comments
 (0)