Skip to content

Commit 3223fa7

Browse files
ralyodioclaude
andauthored
Do not retry a write that cannot succeed, on a queue with one writer (#145)
Within an hour of the Redis write queue going live it retried two jobs that had no chance: write-failed UNIQUE constraint failed: authors.slug attempts=1,2,3 write-failed aborted due to timeout id=30 21:29:27, 21:29:58, 21:30:29 Worker concurrency is 1 and cannot be raised -- SQLite has one writer -- so a retry is not merely wasted effort. The failing job holds **the cluster's only writer** for each of its attempts while every other write queues behind it. Job 30 held it for over a minute. A constraint or syntax error is deterministic: the same statements against the same data fail identically for ever, so all three attempts were certain to fail and the second and third bought nothing but blocking. Those now raise BullMQ's `UnrecoverableError`, which stops the retries -- the caller learns at once and the writer is released. Transport failures and timeouts still retry. Those genuinely can go differently on the next attempt, and retrying them is most of why the queue was wanted. The classification is `isStatementError`, already used by the folder to decide when splitting a combined transaction can isolate one bad caller. The job body moves into an exported `runWriteJob` so the retry decision can be tested against a fake client, without a broker. The BullMQ plumbing is not the part with judgement in it. Not addressed here: three attempts at a thirty-second timeout still costs the writer ninety seconds. That is a real cost and a defensible policy -- shortening it trades durability for throughput -- so it wants deciding rather than smuggling into this change. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0dbe4ef commit 3223fa7

2 files changed

Lines changed: 142 additions & 9 deletions

File tree

packages/db/src/writeQueue.js

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Queue, QueueEvents, Worker } from 'bullmq';
1+
import { Queue, QueueEvents, UnrecoverableError, Worker } from 'bullmq';
22

3-
import { createWriteFolder } from './writeFolder.js';
3+
import { createWriteFolder, isStatementError } from './writeFolder.js';
44

55
/**
66
* The write path, moved out of the process and into Redis.
@@ -346,19 +346,55 @@ async function releaseQueue(url, prefix) {
346346
* @param {{ url: string, prefix?: string, onEvent?: ((event: object) => void)|null }} opts
347347
* @returns {import('bullmq').Worker}
348348
*/
349+
/**
350+
* Run one write job, and decide whether failing it is worth another attempt.
351+
*
352+
* Separated from the `Worker` so it can be tested without a broker: the retry
353+
* decision is the part with the judgement in it, and the BullMQ plumbing is not.
354+
*
355+
* **A retry is only worth a slot if the next attempt could go differently.** At
356+
* concurrency 1 this is not merely wasted effort, it is head-of-line blocking:
357+
* a failing job holds the *cluster's only writer* for each of its attempts, and
358+
* every other write waits behind it.
359+
*
360+
* Both halves of that were seen in production within an hour of this queue being
361+
* switched on. A `UNIQUE constraint failed: authors.slug` job retried three
362+
* times and could never have succeeded -- the same statements against the same
363+
* data fail identically for ever. Separately, a timing-out job ran from
364+
* 21:29:27 to 21:30:29, three attempts at thirty seconds, with the writer held
365+
* throughout.
366+
*
367+
* So a constraint or syntax error is answered with `UnrecoverableError`, which
368+
* tells BullMQ not to retry: the caller learns at once and the writer is
369+
* released. Transport failures and timeouts still retry, because those can
370+
* genuinely go differently on the next attempt -- that is the whole reason the
371+
* queue was wanted.
372+
*
373+
* @param {import('@libsql/client').Client} client
374+
* @param {Array<{ sql: string, args?: unknown[] }>} statements
375+
* @returns {Promise<unknown[]>}
376+
*/
377+
export async function runWriteJob(client, statements) {
378+
if (!statements || statements.length === 0) return [];
379+
380+
try {
381+
const results = await client.batch(statements, 'write');
382+
return Array.from(results ?? []).map(encodeResult);
383+
} catch (err) {
384+
if (isStatementError(err)) {
385+
throw new UnrecoverableError(err instanceof Error ? err.message : String(err));
386+
}
387+
throw err;
388+
}
389+
}
390+
349391
export function createWriteWorker(client, opts) {
350392
const connection = connectionFor(opts.url);
351393
const prefix = opts.prefix ?? '{rssamplifier}';
352394

353395
return new Worker(
354396
WRITE_QUEUE,
355-
async (job) => {
356-
const statements = (job.data.statements ?? []).map(decodeStatement);
357-
if (statements.length === 0) return [];
358-
359-
const results = await client.batch(statements, 'write');
360-
return Array.from(results ?? []).map(encodeResult);
361-
},
397+
(job) => runWriteJob(client, (job.data.statements ?? []).map(decodeStatement)),
362398
{ connection, prefix, concurrency: 1 },
363399
);
364400
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
4+
import { UnrecoverableError } from 'bullmq';
5+
6+
import { runWriteJob } from '../src/writeQueue.js';
7+
8+
/**
9+
* Which write failures are worth another attempt.
10+
*
11+
* At worker concurrency 1 — which is not tunable, because SQLite has one writer
12+
* — a retry does not merely waste effort. The failing job holds the cluster's
13+
* only writer for every attempt while every other write queues behind it.
14+
*
15+
* Both failure modes below were seen in production within an hour of the queue
16+
* being switched on: a constraint violation retried three times to no possible
17+
* effect, and a timing-out job that held the writer for over a minute.
18+
*/
19+
20+
/** @param {(statements: unknown[]) => Promise<unknown>} batch */
21+
const clientWith = (batch) => ({ batch: (statements) => batch(statements) });
22+
23+
test('a constraint violation is not retried', async () => {
24+
// Deterministic: the same statements against the same data fail the same way
25+
// for ever. This is the exact error that retried three times in production.
26+
const client = clientWith(async () => {
27+
throw new Error('SQLITE_CONSTRAINT: SQLite error: UNIQUE constraint failed: authors.slug');
28+
});
29+
30+
await assert.rejects(
31+
() => runWriteJob(client, [{ sql: 'insert into authors values (1)' }]),
32+
(err) => {
33+
assert.ok(err instanceof UnrecoverableError, 'must tell BullMQ to stop retrying');
34+
assert.match(err.message, /authors\.slug/, 'the reason must survive');
35+
return true;
36+
},
37+
);
38+
});
39+
40+
test('a syntax error is not retried either', async () => {
41+
const client = clientWith(async () => {
42+
throw new Error('SQLITE_ERROR: no such column: nope');
43+
});
44+
45+
await assert.rejects(
46+
() => runWriteJob(client, [{ sql: 'select nope' }]),
47+
(err) => err instanceof UnrecoverableError,
48+
);
49+
});
50+
51+
test('a timeout IS retried, because the next attempt can differ', async () => {
52+
// The distinction that matters. Refusing to retry these would throw away the
53+
// main thing the queue was wanted for.
54+
const client = clientWith(async () => {
55+
throw new Error('The operation was aborted due to timeout');
56+
});
57+
58+
await assert.rejects(
59+
() => runWriteJob(client, [{ sql: 'update feeds set x = 1' }]),
60+
(err) => {
61+
assert.ok(!(err instanceof UnrecoverableError), 'a timeout must stay retryable');
62+
assert.match(err.message, /timeout/);
63+
return true;
64+
},
65+
);
66+
});
67+
68+
test('a transport failure is retried', async () => {
69+
const client = clientWith(async () => {
70+
throw new Error('fetch failed');
71+
});
72+
73+
await assert.rejects(
74+
() => runWriteJob(client, [{ sql: 'update feeds set x = 1' }]),
75+
(err) => !(err instanceof UnrecoverableError),
76+
);
77+
});
78+
79+
test('an empty job does no work and touches no client', async () => {
80+
let called = false;
81+
const client = clientWith(async () => {
82+
called = true;
83+
return [];
84+
});
85+
86+
assert.deepEqual(await runWriteJob(client, []), []);
87+
assert.equal(called, false, 'an empty job must not take the writer at all');
88+
});
89+
90+
test('a successful job returns one encoded result per statement', async () => {
91+
const client = clientWith(async (statements) =>
92+
statements.map(() => ({ rows: [], rowsAffected: 1, columns: [], columnTypes: [] })),
93+
);
94+
95+
const results = await runWriteJob(client, [{ sql: 'a' }, { sql: 'b' }]);
96+
assert.equal(results.length, 2);
97+
});

0 commit comments

Comments
 (0)