Skip to content

Commit 3008886

Browse files
feat(redrive): DLQ redrive tooling — safe replay (ADR-0026) (#3)
The Node mirror of babelqueue-go/redrive.go. Since the core is codec-only, redrive(io, dlq, opts) takes a small RedriveIO (pop/publish) over the caller's transport, and resetForRedrive(env) is the pure core: strip dead_letter, attempts->0, preserve job/trace_id/data/meta. Options toQueue (sandbox), max, dryRun, select. Drains-then-processes; restores skipped/dry-run/undecodable bodies; restores + re-throws on a publish failure. A normal root export (no dependency). Envelope frozen (GR-1); Replay-Bypass header is a documented phase two.
1 parent 6ce84d8 commit 3008886

4 files changed

Lines changed: 339 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"build": "tsup",
6060
"typecheck": "tsc --noEmit",
6161
"lint": "eslint src test",
62-
"test": "node --import tsx --test test/codec.test.ts test/dead-letter.test.ts test/conformance.test.ts test/overhead.test.ts test/idempotency.test.ts test/schema.test.ts test/otel.test.ts",
62+
"test": "node --import tsx --test test/codec.test.ts test/dead-letter.test.ts test/conformance.test.ts test/overhead.test.ts test/idempotency.test.ts test/schema.test.ts test/otel.test.ts test/redrive.test.ts",
6363
"coverage": "c8 --check-coverage --lines 90 --functions 90 --branches 85 --reporter=text npm test",
6464
"prepublishOnly": "npm run build"
6565
},

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,12 @@ export type { Handler, Store } from "./idempotency.js";
3838

3939
export * as schema from "./schema.js";
4040
export type { SchemaProvider, SchemaNode } from "./schema.js";
41+
42+
export { redrive, resetForRedrive } from "./redrive.js";
43+
export type {
44+
RedriveIO,
45+
RedriveItem,
46+
RedriveMessage,
47+
RedriveOptions,
48+
RedriveResult,
49+
} from "./redrive.js";

src/redrive.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* DLQ redrive tooling — safe replay off the dead-letter queue (ADR-0026).
3+
*
4+
* The Node mirror of the Go reference `babelqueue-go/redrive.go`. Because the Node core is
5+
* codec-only (no runtime / no transport), the orchestration takes a small {@link RedriveIO}
6+
* the caller implements over their transport — the same shape the optional `otel.publish`
7+
* helper used. {@link resetForRedrive} is the pure, transport-free core of it.
8+
*
9+
* A redriven message is **reset for reprocessing**: its `dead_letter` block is removed and
10+
* `attempts` reset to 0, while `job`, `trace_id`, `data` and `meta` are preserved verbatim, so
11+
* the replay is still fully traceable (same `trace_id`). The wire envelope is untouched (GR-1).
12+
*
13+
* Replay safety here is `dryRun` + `select` + redrive-to-`toQueue` (a sandbox). The
14+
* **Replay-Bypass** guard — a `bq-replay-bypass` transport header surfaced to handlers so a
15+
* replay can skip external side-effects — is a documented phase-two follow-up that touches the
16+
* runtime + every transport, like ADR-0025's `traceparent` follow-up.
17+
*/
18+
19+
import { EnvelopeCodec, type Envelope } from "./codec.js";
20+
21+
/** A message reserved from a queue, plus a way to acknowledge (remove) it. */
22+
export interface RedriveMessage {
23+
body: string;
24+
ack(): Promise<void>;
25+
}
26+
27+
/** The minimal transport surface {@link redrive} needs: reserve the next message, and publish. */
28+
export interface RedriveIO {
29+
/** Reserve the next message from queue, or `null` when it is empty. */
30+
pop(queue: string): Promise<RedriveMessage | null>;
31+
/** Append an already-encoded body to queue. */
32+
publish(queue: string, body: string): Promise<void>;
33+
}
34+
35+
/** Options for {@link redrive}. */
36+
export interface RedriveOptions {
37+
/** Override where messages are re-published; default is each message's `dead_letter.original_queue`. Set a sandbox queue to replay safely. */
38+
toQueue?: string;
39+
/** Cap how many messages are pulled from the DLQ (0 / omitted = all currently available). */
40+
max?: number;
41+
/** Inspect without redriving: every message is read, reported, and returned to the DLQ unchanged. */
42+
dryRun?: boolean;
43+
/** Pick which messages to redrive (e.g. by reason or URN). Unselected messages are returned unchanged. */
44+
select?: (envelope: Envelope) => boolean;
45+
}
46+
47+
/** What happened to one message during a {@link redrive} run. */
48+
export interface RedriveItem {
49+
messageId: string;
50+
traceId: string;
51+
urn: string;
52+
reason: string;
53+
from: string;
54+
/** Target queue (the plan, even on a dry run; "" when skipped or undecodable). */
55+
to: string;
56+
/** True only when actually re-published to `to`. */
57+
redriven: boolean;
58+
}
59+
60+
/** Summary of a {@link redrive} run. */
61+
export interface RedriveResult {
62+
redriven: number;
63+
skipped: number;
64+
items: RedriveItem[];
65+
}
66+
67+
/**
68+
* Returns a copy of `envelope` reset for reprocessing: no `dead_letter` block and `attempts`
69+
* at 0, with `job`, `trace_id`, `data` and `meta` preserved verbatim. Pure — the input is not
70+
* mutated.
71+
*/
72+
export function resetForRedrive(envelope: Envelope): Envelope {
73+
return {
74+
job: envelope.job,
75+
trace_id: envelope.trace_id,
76+
data: envelope.data,
77+
meta: envelope.meta,
78+
attempts: 0,
79+
};
80+
}
81+
82+
function sourceQueueOf(envelope: Envelope): string {
83+
return envelope.dead_letter?.original_queue || envelope.meta.queue;
84+
}
85+
86+
/**
87+
* Moves dead-lettered messages off the `dlq` queue and re-publishes each — via {@link resetForRedrive} —
88+
* to its `dead_letter.original_queue` or `opts.toQueue`.
89+
*
90+
* Messages are drained from the DLQ first and then processed, so restored messages (skipped,
91+
* dry-run, or undecodable) are never re-encountered in the same run. A message is acknowledged
92+
* only after its re-publish succeeds; an undecodable body is restored, not dropped. On a publish
93+
* failure the message is restored to the DLQ and the error is re-thrown.
94+
*/
95+
export async function redrive(
96+
io: RedriveIO,
97+
dlq: string,
98+
opts: RedriveOptions = {},
99+
): Promise<RedriveResult> {
100+
const max = opts.max ?? 0;
101+
102+
interface Pending {
103+
message: RedriveMessage;
104+
envelope: Envelope | null;
105+
}
106+
const batch: Pending[] = [];
107+
while (max === 0 || batch.length < max) {
108+
const message = await io.pop(dlq);
109+
if (!message) {
110+
break;
111+
}
112+
const decoded = EnvelopeCodec.decode(message.body);
113+
batch.push({ message, envelope: EnvelopeCodec.accepts(decoded) ? decoded : null });
114+
}
115+
116+
const result: RedriveResult = { redriven: 0, skipped: 0, items: [] };
117+
118+
for (const { message, envelope } of batch) {
119+
if (!envelope) {
120+
await io.publish(dlq, message.body); // restore the undecodable body; never drop it
121+
await message.ack();
122+
result.skipped++;
123+
result.items.push({ messageId: "", traceId: "", urn: "", reason: "", from: dlq, to: "", redriven: false });
124+
continue;
125+
}
126+
127+
const item: RedriveItem = {
128+
messageId: envelope.meta.id,
129+
traceId: envelope.trace_id,
130+
urn: EnvelopeCodec.urn(envelope),
131+
reason: envelope.dead_letter?.reason ?? "",
132+
from: dlq,
133+
to: "",
134+
redriven: false,
135+
};
136+
137+
if (opts.select && !opts.select(envelope)) {
138+
await io.publish(dlq, message.body); // not selected: restore unchanged
139+
await message.ack();
140+
result.skipped++;
141+
result.items.push(item);
142+
continue;
143+
}
144+
145+
const target = opts.toQueue ?? sourceQueueOf(envelope);
146+
item.to = target;
147+
148+
if (opts.dryRun) {
149+
await io.publish(dlq, message.body); // report the plan; restore unchanged
150+
await message.ack();
151+
result.skipped++;
152+
result.items.push(item);
153+
continue;
154+
}
155+
156+
try {
157+
await io.publish(target, EnvelopeCodec.encode(resetForRedrive(envelope)));
158+
} catch (err) {
159+
await io.publish(dlq, message.body); // restore on a publish failure
160+
await message.ack();
161+
throw err;
162+
}
163+
await message.ack();
164+
item.redriven = true;
165+
result.redriven++;
166+
result.items.push(item);
167+
}
168+
169+
return result;
170+
}

test/redrive.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import { EnvelopeCodec, type Envelope } from "../src/codec.js";
5+
import {
6+
redrive,
7+
resetForRedrive,
8+
type RedriveIO,
9+
type RedriveMessage,
10+
} from "../src/redrive.js";
11+
12+
// memoryIO is a tiny in-memory RedriveIO over a Map of queues, with a queue() accessor for assertions.
13+
function memoryIO(): RedriveIO & { queue(name: string): string[]; failOn?: string } {
14+
const queues = new Map<string, string[]>();
15+
const q = (name: string): string[] => {
16+
let arr = queues.get(name);
17+
if (!arr) {
18+
arr = [];
19+
queues.set(name, arr);
20+
}
21+
return arr;
22+
};
23+
const io: RedriveIO & { queue(name: string): string[]; failOn?: string } = {
24+
queue: q,
25+
async pop(queue: string): Promise<RedriveMessage | null> {
26+
const arr = q(queue);
27+
if (arr.length === 0) {
28+
return null;
29+
}
30+
const body = arr.shift() as string;
31+
return {
32+
body,
33+
async ack(): Promise<void> {},
34+
};
35+
},
36+
async publish(queue: string, body: string): Promise<void> {
37+
if (io.failOn && queue === io.failOn) {
38+
throw new Error(`publish refused for ${queue}`);
39+
}
40+
q(queue).push(body);
41+
},
42+
};
43+
return io;
44+
}
45+
46+
function deadLetteredBody(urn: string, originalQueue: string, data: Record<string, unknown> = {}): string {
47+
const base = EnvelopeCodec.make(urn, data, { queue: originalQueue });
48+
const dl: Envelope = {
49+
...base,
50+
attempts: 3,
51+
dead_letter: {
52+
reason: "failed",
53+
error: "boom",
54+
exception: "Error",
55+
failed_at: 1,
56+
original_queue: originalQueue,
57+
attempts: 3,
58+
lang: "node",
59+
},
60+
};
61+
return EnvelopeCodec.encode(dl);
62+
}
63+
64+
test("resetForRedrive strips dead_letter and resets attempts, without mutating the input", () => {
65+
const base = EnvelopeCodec.make("urn:babel:orders:created", { order_id: 1 }, { queue: "orders" });
66+
const input: Envelope = { ...base, attempts: 3, dead_letter: { reason: "failed", error: null, exception: null, failed_at: 1, original_queue: "orders", attempts: 3, lang: "node" } };
67+
68+
const out = resetForRedrive(input);
69+
assert.equal(out.dead_letter, undefined);
70+
assert.equal(out.attempts, 0);
71+
assert.equal(out.trace_id, input.trace_id);
72+
assert.equal(out.job, input.job);
73+
// input untouched
74+
assert.ok(input.dead_letter);
75+
assert.equal(input.attempts, 3);
76+
});
77+
78+
test("redrive sends a message back to its source queue, reset and traceable", async () => {
79+
const io = memoryIO();
80+
io.queue("orders.dlq").push(deadLetteredBody("urn:babel:orders:created", "orders", { order_id: 1 }));
81+
const traceId = EnvelopeCodec.decode(io.queue("orders.dlq")[0]).trace_id;
82+
83+
const res = await redrive(io, "orders.dlq");
84+
assert.deepEqual([res.redriven, res.skipped], [1, 0]);
85+
86+
assert.equal(io.queue("orders.dlq").length, 0, "DLQ should be drained");
87+
assert.equal(io.queue("orders").length, 1);
88+
const back = EnvelopeCodec.decode(io.queue("orders")[0]);
89+
assert.equal(back.dead_letter, undefined);
90+
assert.equal(back.attempts, 0);
91+
assert.equal(back.trace_id, traceId);
92+
});
93+
94+
test("redrive routes to a sandbox queue without touching the source", async () => {
95+
const io = memoryIO();
96+
io.queue("orders.dlq").push(deadLetteredBody("urn:babel:orders:created", "orders"));
97+
98+
const res = await redrive(io, "orders.dlq", { toQueue: "sandbox" });
99+
assert.equal(res.redriven, 1);
100+
assert.equal(io.queue("orders").length, 0);
101+
assert.equal(io.queue("sandbox").length, 1);
102+
});
103+
104+
test("dryRun reports the plan and leaves the DLQ unchanged", async () => {
105+
const io = memoryIO();
106+
io.queue("orders.dlq").push(deadLetteredBody("urn:babel:orders:created", "orders"));
107+
108+
const res = await redrive(io, "orders.dlq", { dryRun: true });
109+
assert.deepEqual([res.redriven, res.skipped], [0, 1]);
110+
assert.equal(res.items[0].to, "orders");
111+
assert.equal(res.items[0].redriven, false);
112+
assert.equal(io.queue("orders").length, 0, "source untouched");
113+
assert.equal(io.queue("orders.dlq").length, 1, "DLQ unchanged");
114+
assert.ok(EnvelopeCodec.decode(io.queue("orders.dlq")[0]).dead_letter, "dead_letter intact");
115+
});
116+
117+
test("select redrives only the matching messages, restoring the rest", async () => {
118+
const io = memoryIO();
119+
io.queue("dlq").push(deadLetteredBody("urn:babel:orders:created", "orders"));
120+
io.queue("dlq").push(deadLetteredBody("urn:babel:emails:welcome", "emails"));
121+
122+
const res = await redrive(io, "dlq", {
123+
select: (e) => EnvelopeCodec.urn(e) === "urn:babel:orders:created",
124+
});
125+
assert.deepEqual([res.redriven, res.skipped], [1, 1]);
126+
assert.equal(io.queue("orders").length, 1);
127+
assert.equal(io.queue("emails").length, 0);
128+
assert.equal(io.queue("dlq").length, 1, "unselected restored to the DLQ");
129+
});
130+
131+
test("max caps how many messages are pulled", async () => {
132+
const io = memoryIO();
133+
for (let i = 0; i < 3; i++) {
134+
io.queue("dlq").push(deadLetteredBody("urn:babel:orders:created", "orders"));
135+
}
136+
const res = await redrive(io, "dlq", { max: 2 });
137+
assert.equal(res.redriven, 2);
138+
assert.equal(io.queue("dlq").length, 1);
139+
});
140+
141+
test("a publish failure restores the message to the DLQ and re-throws", async () => {
142+
const io = memoryIO();
143+
io.queue("dlq").push(deadLetteredBody("urn:babel:orders:created", "orders"));
144+
io.failOn = "orders";
145+
146+
await assert.rejects(redrive(io, "dlq"), /publish refused/);
147+
assert.equal(io.queue("dlq").length, 1, "message restored to the DLQ");
148+
assert.equal(io.queue("orders").length, 0);
149+
});
150+
151+
test("an undecodable body is restored, not lost", async () => {
152+
const io = memoryIO();
153+
io.queue("dlq").push("not-json{{{");
154+
155+
const res = await redrive(io, "dlq");
156+
assert.deepEqual([res.redriven, res.skipped], [0, 1]);
157+
assert.equal(io.queue("dlq").length, 1);
158+
assert.equal(io.queue("dlq")[0], "not-json{{{");
159+
});

0 commit comments

Comments
 (0)