Skip to content

Commit 406be28

Browse files
committed
feat(replay): replay-bypass side-effect guard — Node core (ADR-0027)
A deliberate DLQ replay can tell its handler to skip external side-effects that already fired (don't re-charge/re-email) while the idempotent core still runs — mirroring the Go reference. New src/replay.ts (HEADER_REPLAY_BYPASS + isReplay + bypassExternalEffects) + redrive bypass wiring. Rides the out-of-band HeaderCarrier seam beside the frozen envelope (GR-1), trace_id preserved (GR-4). Opt-in. v1.5.0.
1 parent 3839a1e commit 406be28

9 files changed

Lines changed: 447 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,26 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
## [1.5.0] - 2026-06-21
13+
1214
### Added
15+
- **Replay-bypass — an out-of-band side-effect guard for DLQ replay (ADR-0027).** A
16+
deliberate `redrive` re-runs the handler and re-fires its external side-effects (a second
17+
charge, a duplicate email); idempotency stops an *accidental* duplicate, not the *intended*
18+
reprocess. This closes that gap.
19+
- New `redrive(io, dlq, { bypass: true })` option stamps a `bq-replay-bypass` **transport
20+
header** on each replayed message; the per-item result gains a `bypassed` flag. It takes
21+
effect only when the `RedriveIO` implements the new optional `publishWithHeaders(queue,
22+
body, headers)` capability — otherwise `bypass` is a no-op and the message is still
23+
redriven (`bypassed: false`), exactly like the Go reference.
24+
- New `isReplay(headers)` + `bypassExternalEffects(headers, effect)` consume-side guard
25+
(plus the `HEADER_REPLAY_BYPASS` constant): a handler wraps its external, non-idempotent
26+
side so a replay skips it while the idempotent core still runs.
27+
- The marker rides **beside** the frozen envelope on the `HeaderCarrier` seam, never inside
28+
it (`schema_version` stays **1**, GR-1; `trace_id` preserved, GR-4) — the same out-of-band
29+
channel as the `traceparent` header. Fully opt-in and backward compatible: a header-less
30+
message behaves exactly as before. Per-adapter transport wiring (the
31+
`babelqueue-node-adapters` repo) is the documented follow-up, like ADR-0028's.
1332
- **OpenTelemetry v0.2 — W3C `traceparent` cross-hop span linkage (ADR-0028).** The
1433
`@babelqueue/core/otel` module now propagates the active span as a W3C `traceparent`
1534
(and `tracestate`) **transport header** so a consumer span is a true **child** of the

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,34 @@ const dlq = annotate(env, "failed", "orders", { attempts: 3, error: "boom" });
101101
`annotate` returns a copy — the original envelope is preserved unchanged inside
102102
the dead-lettered message, so any-language consumers can still read it.
103103

104+
### Replay-bypass (optional)
105+
106+
A deliberate replay off the DLQ ([`redrive`](src/redrive.ts)) re-runs the handler,
107+
re-firing its external side-effects — a second charge, a duplicate email. With
108+
`bypass`, `redrive` stamps a `bq-replay-bypass` **transport header** on each replayed
109+
message; a handler reads the delivered headers and skips the effects that already ran,
110+
while the idempotent core still runs (ADR-0027).
111+
112+
```ts
113+
import { redrive, isReplay, bypassExternalEffects } from "@babelqueue/core";
114+
115+
// PRODUCER: redrive with bypass (the IO must carry headers — publishWithHeaders).
116+
await redrive(io, "orders.dlq", { bypass: true });
117+
118+
// CONSUMER: the adapter surfaces the delivered message's out-of-band headers.
119+
async function onMessage(env, headers) {
120+
saveOrder(env); // idempotent core — always runs
121+
await bypassExternalEffects(headers, async () => { // skipped when isReplay(headers)
122+
await sendConfirmationEmail(env);
123+
});
124+
}
125+
```
126+
127+
The marker rides **beside** the frozen envelope, never inside it (`schema_version`
128+
stays **1**, GR-1) — the same out-of-band `HeaderCarrier` seam as the `traceparent`
129+
header. It takes effect only when the `RedriveIO` implements `publishWithHeaders`;
130+
otherwise `bypass` is a no-op (`bypassed: false`) and the message is still redriven.
131+
104132
## What this core is (and isn't)
105133

106134
It enforces the **contract**: the envelope shape, URN identity, trace propagation,

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@babelqueue/core",
3-
"version": "1.4.0",
3+
"version": "1.5.0",
44
"description": "Polyglot Queues, Simplified — the Node/TypeScript core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.",
55
"keywords": [
66
"queue",
@@ -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 test/redrive.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 test/replay.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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,5 @@ export type {
4747
RedriveOptions,
4848
RedriveResult,
4949
} from "./redrive.js";
50+
51+
export { HEADER_REPLAY_BYPASS, isReplay, bypassExternalEffects } from "./replay.js";

src/redrive.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@
1010
* `attempts` reset to 0, while `job`, `trace_id`, `data` and `meta` are preserved verbatim, so
1111
* the replay is still fully traceable (same `trace_id`). The wire envelope is untouched (GR-1).
1212
*
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.
13+
* Replay safety here is `dryRun` + `select` + redrive-to-`toQueue` (a sandbox). On top of those,
14+
* the **Replay-Bypass** guard (ADR-0027): with `opts.bypass`, a redriven message is stamped with
15+
* the `bq-replay-bypass` transport header — surfaced to the handler via {@link isReplay} /
16+
* {@link bypassExternalEffects} (see `./replay.js`) so a replay can skip external side-effects that
17+
* already fired. It rides the out-of-band {@link HeaderCarrier}, never the frozen envelope (GR-1),
18+
* and takes effect only when the {@link RedriveIO} can carry headers (its optional
19+
* {@link RedriveIO.publishWithHeaders}); otherwise it is a no-op and the per-item `bypassed` flag
20+
* stays `false`, exactly like the Go reference and the per-transport rollout of ADR-0028.
1721
*/
1822

1923
import { EnvelopeCodec, type Envelope } from "./codec.js";
24+
import type { HeaderCarrier } from "./contracts.js";
25+
import { HEADER_REPLAY_BYPASS } from "./replay.js";
2026

2127
/** A message reserved from a queue, plus a way to acknowledge (remove) it. */
2228
export interface RedriveMessage {
@@ -30,6 +36,15 @@ export interface RedriveIO {
3036
pop(queue: string): Promise<RedriveMessage | null>;
3137
/** Append an already-encoded body to queue. */
3238
publish(queue: string, body: string): Promise<void>;
39+
/**
40+
* Optional capability — publish a body together with out-of-band transport `headers` (e.g. the
41+
* `bq-replay-bypass` marker), for transports that carry per-message metadata on a native channel
42+
* (AMQP headers, SQS `MessageAttributes`, a Redis transport-owned frame). The Node counterpart of
43+
* Go's optional `HeaderPublisher`. A {@link RedriveIO} that omits it simply does not propagate
44+
* headers — {@link redrive} falls back to plain {@link RedriveIO.publish} and `bypass` is a no-op
45+
* (ADR-0027).
46+
*/
47+
publishWithHeaders?(queue: string, body: string, headers: HeaderCarrier): Promise<void>;
3348
}
3449

3550
/** Options for {@link redrive}. */
@@ -42,6 +57,13 @@ export interface RedriveOptions {
4257
dryRun?: boolean;
4358
/** Pick which messages to redrive (e.g. by reason or URN). Unselected messages are returned unchanged. */
4459
select?: (envelope: Envelope) => boolean;
60+
/**
61+
* Stamp the `bq-replay-bypass` transport header on each redriven message, so a handler can skip
62+
* external side-effects that already ran (see `./replay.js`). Takes effect only when the
63+
* {@link RedriveIO} implements {@link RedriveIO.publishWithHeaders}; otherwise it is a no-op and
64+
* the per-item `bypassed` flag stays `false` (ADR-0027).
65+
*/
66+
bypass?: boolean;
4567
}
4668

4769
/** What happened to one message during a {@link redrive} run. */
@@ -55,6 +77,8 @@ export interface RedriveItem {
5577
to: string;
5678
/** True only when actually re-published to `to`. */
5779
redriven: boolean;
80+
/** True when the `bq-replay-bypass` header was stamped on the redriven message (ADR-0027). */
81+
bypassed: boolean;
5882
}
5983

6084
/** Summary of a {@link redrive} run. */
@@ -120,7 +144,7 @@ export async function redrive(
120144
await io.publish(dlq, message.body); // restore the undecodable body; never drop it
121145
await message.ack();
122146
result.skipped++;
123-
result.items.push({ messageId: "", traceId: "", urn: "", reason: "", from: dlq, to: "", redriven: false });
147+
result.items.push({ messageId: "", traceId: "", urn: "", reason: "", from: dlq, to: "", redriven: false, bypassed: false });
124148
continue;
125149
}
126150

@@ -132,6 +156,7 @@ export async function redrive(
132156
from: dlq,
133157
to: "",
134158
redriven: false,
159+
bypassed: false,
135160
};
136161

137162
if (opts.select && !opts.select(envelope)) {
@@ -153,18 +178,35 @@ export async function redrive(
153178
continue;
154179
}
155180

181+
const body = EnvelopeCodec.encode(resetForRedrive(envelope));
182+
let bypassed: boolean;
156183
try {
157-
await io.publish(target, EnvelopeCodec.encode(resetForRedrive(envelope)));
184+
bypassed = await publishRedriven(io, target, body, opts.bypass ?? false);
158185
} catch (err) {
159186
await io.publish(dlq, message.body); // restore on a publish failure
160187
await message.ack();
161188
throw err;
162189
}
163190
await message.ack();
164191
item.redriven = true;
192+
item.bypassed = bypassed;
165193
result.redriven++;
166194
result.items.push(item);
167195
}
168196

169197
return result;
170198
}
199+
200+
/**
201+
* Re-publishes a reset `body` to `queue`. When `bypass` is set and the {@link RedriveIO} can carry
202+
* headers ({@link RedriveIO.publishWithHeaders}), it stamps the `bq-replay-bypass` header and
203+
* reports `true`; otherwise it publishes plainly and reports `false` (ADR-0027).
204+
*/
205+
async function publishRedriven(io: RedriveIO, queue: string, body: string, bypass: boolean): Promise<boolean> {
206+
if (bypass && io.publishWithHeaders) {
207+
await io.publishWithHeaders(queue, body, { [HEADER_REPLAY_BYPASS]: "1" });
208+
return true;
209+
}
210+
await io.publish(queue, body);
211+
return false;
212+
}

src/replay.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Replay-Bypass — a side-effect guard for DLQ replay (ADR-0027).
3+
*
4+
* A deliberate replay off the dead-letter queue ({@link redrive}) re-runs the handler, and the
5+
* handler's external side-effects re-fire: a second charge, a duplicate email. {@link Wrap}
6+
* (idempotency, ADR-0022) stops an *accidental* duplicate; it does not stop the *intended*
7+
* reprocess from re-firing effects that already happened. This closes that gap.
8+
*
9+
* The marker that says "this is a replay, skip the external effects" rides **out of band** as the
10+
* {@link HEADER_REPLAY_BYPASS} transport header on the {@link HeaderCarrier} — never in the frozen
11+
* envelope (GR-1, `schema_version` stays 1). {@link redrive} (with `bypass`) stamps it when the
12+
* transport can carry headers; a consume adapter, having reserved the message with its headers,
13+
* passes them to the guard so the handler can skip effects that already fired:
14+
*
15+
* ```ts
16+
* import { isReplay, bypassExternalEffects } from "@babelqueue/core";
17+
*
18+
* // CONSUMER: the adapter surfaces the delivered message's out-of-band headers.
19+
* async function onMessage(env: Envelope, headers: HeaderCarrier): Promise<void> {
20+
* saveOrder(env); // idempotent core — always runs
21+
* await bypassExternalEffects(headers, async () => { // external effect — skipped on replay
22+
* await sendConfirmationEmail(env);
23+
* });
24+
* }
25+
* ```
26+
*
27+
* The Node core is codec-only (no runtime / no transport), mirroring the Go reference
28+
* `babelqueue-go/replay.go` and the Java `com.babelqueue.Replay`. Go/Java thread the flag through
29+
* an ambient scope (a `context.Context` / a `ThreadLocal`); Node has no ambient request scope, so
30+
* the delivered {@link HeaderCarrier} is passed explicitly — the same seam `otel.wrapHandler`
31+
* already uses for delivered headers. A concrete broker transport carries the header once it
32+
* implements the {@link RedriveHeaderIO} capability (the per-adapter rollout, like ADR-0028).
33+
*/
34+
35+
import type { HeaderCarrier } from "./contracts.js";
36+
37+
/**
38+
* The out-of-band transport header {@link redrive} (with `bypass`) stamps on a replayed message,
39+
* and that a consume adapter surfaces to the handler via {@link isReplay} /
40+
* {@link bypassExternalEffects}. It rides on the {@link HeaderCarrier} beside the frozen envelope,
41+
* never inside it (GR-1) — the same seam as the `traceparent` header (ADR-0028).
42+
*/
43+
export const HEADER_REPLAY_BYPASS = "bq-replay-bypass";
44+
45+
/**
46+
* Reports whether a delivered message was redriven with the replay-bypass marker — i.e. a
47+
* deliberate replay whose external side-effects should be skipped. Reads the presence of
48+
* {@link HEADER_REPLAY_BYPASS} on the message's out-of-band `headers` (nil/empty-safe: a
49+
* header-less delivery is never a replay).
50+
*
51+
* @param headers the delivered message's out-of-band transport headers (may be null/undefined)
52+
*/
53+
export function isReplay(headers: HeaderCarrier | null | undefined): boolean {
54+
return !!headers && headers[HEADER_REPLAY_BYPASS] !== undefined && headers[HEADER_REPLAY_BYPASS] !== "";
55+
}
56+
57+
/**
58+
* Runs `effect` unless the delivered message is a {@link isReplay replay}, in which case it is
59+
* skipped and the returned promise resolves to `undefined`. Wrap the external, non-idempotent side
60+
* of a handler — sending an email, charging a card, calling a third party — so a replay re-runs the
61+
* idempotent core but does not re-fire effects that already happened.
62+
*
63+
* @param headers the delivered message's out-of-band transport headers (may be null/undefined)
64+
* @param effect the external side-effect to run only when this is not a replay
65+
*/
66+
export async function bypassExternalEffects<T>(
67+
headers: HeaderCarrier | null | undefined,
68+
effect: () => T | Promise<T>,
69+
): Promise<T | undefined> {
70+
if (isReplay(headers)) {
71+
return undefined;
72+
}
73+
return effect();
74+
}

test/conformance/manifest.json

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,80 @@
6868
"reason": "no 'job' or 'urn' — the message has no identity"
6969
}
7070
],
71+
"idempotency": {
72+
"description": "Consumer idempotency conformance (ADR-0022). Broker-free: locks the seen-set dedupe contract every SDK's optional idempotency helper (Go idempotency.Wrap, PHP Idempotent::wrap, Python idempotency.wrap, Node Wrap, Java Idempotent.wrap) MUST honour over the frozen schema_version-1 envelope, independent of any transport. The helper does NOT touch the wire envelope — the cases above stay byte-identical; this block governs the consume-side decision (run the handler, or skip-and-ack) keyed on meta.id.",
73+
"dedup_key": {
74+
"description": "The dedupe key is meta.id verbatim — the canonical per-message identity (distinct from trace_id, which spans a causal chain). A consumer MUST key its seen-set on exactly fixtures/order-created.json's meta.id; two deliveries with the same meta.id are the SAME message (a redelivery), even byte-for-byte identical, and dedupe to one effect. trace_id is NOT the key: messages sharing a trace_id but with distinct meta.id are distinct effects.",
75+
"envelope_file": "fixtures/order-created.json",
76+
"key_field": "meta.id",
77+
"expected_key": "f1e2d3c4-b5a6-4789-90ab-cdef01234567"
78+
},
79+
"sequences": {
80+
"description": "Each sequence drives ONE handler wrapped by the SDK's idempotency helper, backed by ONE seen-set store, through an ordered list of deliveries. For each delivery the SDK MUST derive the effect: 'run' = the wrapped handler is invoked (the side-effect fires) and, on its outcome 'ok', the id is remembered; 'skip' = an already-remembered id is recognised, the handler is NOT invoked, and the delivery is acked (the runtime stops redelivering). 'outcome' is the handler's result for a 'run' delivery: 'ok' = returns/acks (remembered), 'throw' = raises/errors (the id is left UNMARKED so a later redelivery runs it again — retry/DLQ still apply). After replaying the whole sequence, total 'run'+'ok' invocations MUST equal expected_effects. This is at-least-once delivery collapsed to an exactly-once EFFECT by the consumer; it is seen-set post-success dedupe, NOT exactly-once delivery and NOT an in-flight concurrency lock.",
81+
"cases": [
82+
{
83+
"name": "duplicate-delivery-runs-once",
84+
"description": "The same meta.id delivered twice (a textbook at-least-once redelivery): first runs the effect, second is skipped. One effect.",
85+
"deliveries": [
86+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "ok" },
87+
{ "meta_id": "id-A", "expect_effect": "skip" }
88+
],
89+
"expected_effects": 1
90+
},
91+
{
92+
"name": "at-least-once-redelivery-is-noop",
93+
"description": "Three redeliveries of one id (a flaky broker / worker-crash-before-ack storm): the effect fires once; every subsequent redelivery is a no-op skip.",
94+
"deliveries": [
95+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "ok" },
96+
{ "meta_id": "id-A", "expect_effect": "skip" },
97+
{ "meta_id": "id-A", "expect_effect": "skip" },
98+
{ "meta_id": "id-A", "expect_effect": "skip" }
99+
],
100+
"expected_effects": 1
101+
},
102+
{
103+
"name": "distinct-ids-each-run",
104+
"description": "Different meta.ids are different messages — each runs once. Dedupe is per-id, never collapses unrelated messages.",
105+
"deliveries": [
106+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "ok" },
107+
{ "meta_id": "id-B", "expect_effect": "run", "outcome": "ok" },
108+
{ "meta_id": "id-C", "expect_effect": "run", "outcome": "ok" }
109+
],
110+
"expected_effects": 3
111+
},
112+
{
113+
"name": "throw-leaves-id-unmarked",
114+
"description": "A handler that throws does NOT remember the id, so the next redelivery runs it again (retry semantics survive the guard). The third delivery succeeds and is then deduped on a fourth.",
115+
"deliveries": [
116+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "throw" },
117+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "throw" },
118+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "ok" },
119+
{ "meta_id": "id-A", "expect_effect": "skip" }
120+
],
121+
"expected_effects": 3
122+
},
123+
{
124+
"name": "missing-id-fails-open",
125+
"description": "A message with no usable meta.id (empty/absent) CANNOT be deduped, so the helper fails open: every delivery runs the handler. The guard never silently drops work it cannot identify.",
126+
"deliveries": [
127+
{ "meta_id": "", "expect_effect": "run", "outcome": "ok" },
128+
{ "meta_id": "", "expect_effect": "run", "outcome": "ok" }
129+
],
130+
"expected_effects": 2
131+
},
132+
{
133+
"name": "forget-allows-rerun",
134+
"description": "forget(id) evicts an id from the seen-set, so a later delivery of that id runs again (manual replay / TTL expiry). Locks the third Store method.",
135+
"deliveries": [
136+
{ "meta_id": "id-A", "expect_effect": "run", "outcome": "ok" },
137+
{ "meta_id": "id-A", "expect_effect": "skip" },
138+
{ "meta_id": "id-A", "forget_before": true, "expect_effect": "run", "outcome": "ok" }
139+
],
140+
"expected_effects": 2
141+
}
142+
]
143+
}
144+
},
71145
"sqs": {
72146
"description": "Amazon SQS binding conformance (broker-bindings.md §3). Every SDK that ships an SQS transport must satisfy these. The envelope body stays byte-identical (the 'cases' above); these lock the native projection + reconciliation the binding adds. Per-message values reuse fixtures/order-created.json so the expected attributes are deterministic.",
73147
"attribute_projection": {

0 commit comments

Comments
 (0)