@babelqueue/redis 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,102 +1,372 @@
1
- import { Envelope, IncomingEnvelope } from '@babelqueue/core';
1
+ import { Envelope, HeaderCarrier, IncomingEnvelope } from '@babelqueue/core';
2
2
 
3
3
  /**
4
4
  * Redis adapter for BabelQueue.
5
5
  *
6
- * A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue
7
- * pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so
8
- * an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service
9
- * speaks the same wire contract as the PHP, Python and Go SDKs.
6
+ * A canonical-envelope **publisher** and a URN-routed **consumer** over two Redis layouts
7
+ * (broker-bindings §1, ADR-0033):
8
+ *
9
+ * - **`legacy`** (default) — the reliable-queue pattern: `RPUSH` to produce, `LMOVE` the head of
10
+ * `<queue>` to `<queue>:processing` to reserve (a Lua script that also writes a recovery deadline
11
+ * to `<queue>:processing:deadlines`), and a deadline-gated `LREM` to acknowledge, so an in-flight
12
+ * message survives a crash. A **recovery sweep** runs before every reserve and moves a reservation
13
+ * whose deadline (`retryAfter`) passed back onto `<queue>`, body untouched. The pop end changed
14
+ * from the tail (`BRPOPLPUSH`) to the head (`LMOVE LEFT RIGHT`) so the queue is FIFO, like the Go,
15
+ * Python, Java and .NET SDKs.
16
+ * - **`zset`** — the Laravel-compatible layout (`queues:<q>`, `:delayed`, `:reserved`, `:notify`):
17
+ * delayed publish, `retryAfter` recovery through the reserved sorted set, a Lua reserve that
18
+ * increments `attempts`, and blocking pop on the `:notify` list. Opt-in via `layout: "zset"`.
10
19
  *
11
20
  * Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,
12
21
  * byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job
13
- * layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable
14
- * queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a
15
- * separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns
16
- * end-to-end.
22
+ * layout). Routing and tracing read the body's `job` / `trace_id`.
23
+ *
24
+ * `ioredis` is an optional peer — you provide the client (an `ioredis` instance satisfies the
25
+ * adapter structurally).
26
+ *
27
+ * **Out-of-band headers (ADR-0028) — legacy layout only.** A Redis list element *is* the canonical
28
+ * envelope JSON, so there is no native per-message metadata channel. To carry a
29
+ * {@link HeaderCarrier} (e.g. a W3C `traceparent`) the legacy layout owns a tiny JSON *frame*
30
+ * distinct from the wire envelope:
31
+ *
32
+ * {"__bq_frame":1,"headers":{"traceparent":"00-…"},"body":"<raw wire envelope>"}
17
33
  *
18
- * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide
19
- * the client (an `ioredis` instance satisfies the adapter structurally).
34
+ * Framing is **opt-in and backward compatible**: only `publish` with a non-empty `headers` carrier
35
+ * writes a frame; a plain publish stores the **bare** envelope byte-for-byte. The consumer detects
36
+ * frame-vs-bare by the reserved `__bq_frame` sentinel (a frozen envelope can never carry it).
37
+ *
38
+ * **The zset layout never writes a frame (ADR-0033 A3).** A Laravel worker on the same queue would
39
+ * read the frame as a job, so a header-carrying publish drops the headers and stores the bare
40
+ * envelope; a `traceparent` is therefore not propagated in this layout (`trace_id` stays in the
41
+ * envelope, GR-4). GR-1: the wire envelope is never touched.
20
42
  */
21
43
 
22
44
  /** The subset of an ioredis client this adapter calls. */
23
45
  interface RedisClient {
24
46
  /** Append a value to the tail of a list; returns the new length. */
25
47
  rpush(key: string, value: string): Promise<number>;
26
- /** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up
27
- * to `timeout` seconds; resolves to the value, or `null` on timeout. */
28
- brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;
29
- /** Remove `count` occurrences of `value` from a list; returns how many were removed. */
30
- lrem(key: string, count: number, value: string): Promise<number>;
48
+ /** Add `member` to a sorted set with `score`. */
49
+ zadd(key: string, score: number | string, member: string): Promise<number | string>;
50
+ /** Blocking pop from the head of a list for up to `timeout` seconds (fractions need Redis 6+);
51
+ * resolves to `[key, value]`, or `null` on timeout. */
52
+ blpop(key: string, timeout: number): Promise<[string, string] | null>;
53
+ /** Run a Lua script with `numKeys` keys followed by the arguments. */
54
+ eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;
55
+ /**
56
+ * The binary-safe twin of {@link RedisClient.eval} (ioredis `evalBuffer`): string replies come back as
57
+ * `Buffer`s and `Buffer` arguments are sent byte for byte. Optional for publishing, but required to consume
58
+ * ({@link RedisQueue.reserve} / {@link RedisConsumer} throw without it): the reserve scripts and the settle
59
+ * scripts of a body that is not valid UTF-8 use it so the exact stored bytes survive. Invalid UTF-8 is poison
60
+ * and a poison record never carries a U+FFFD substitution (broker-bindings.md section 1.1, ADR-0035 section 1),
61
+ * which a string-only `eval` cannot honour.
62
+ */
63
+ evalBuffer?(script: string, numKeys: number, ...args: Array<string | number | Buffer>): Promise<unknown>;
31
64
  }
32
65
  /** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */
33
66
  declare function redisValue(envelope: Envelope): string;
67
+ /** The reserved discriminator key + current version of the transport-owned header frame. */
68
+ declare const FRAME_KEY = "__bq_frame";
69
+ /**
70
+ * The pure produce-side decision: the exact string to `RPUSH` for `body` + `headers`. With no
71
+ * usable headers it returns `body` verbatim (the **bare** form, so a plain publish and a
72
+ * header-less publish store byte-identical values); otherwise it returns the transport-owned frame
73
+ * JSON. Kept pure so the framing decision is unit-testable without a broker.
74
+ */
75
+ declare function frameValue(body: string, headers: HeaderCarrier | null | undefined): string;
76
+ /**
77
+ * Interpret a stored Redis list value: `[wire-envelope-body, headers]`. A value is a header frame
78
+ * iff it is a JSON object carrying the reserved `__bq_frame` sentinel (a frozen wire envelope never
79
+ * has it); then it yields the unframed body plus the carried headers. Any other value — a bare
80
+ * envelope, non-JSON, or JSON without the sentinel — is returned verbatim as the body with empty
81
+ * headers, so older / cross-version queue values consume exactly as before.
82
+ */
83
+ declare function unframe(value: string): [string, HeaderCarrier];
84
+ /** The out-of-band headers carried by a stored list value (empty for a bare value). */
85
+ declare function headersOf(value: string): HeaderCarrier;
86
+ /** The Redis layout a queue uses. `legacy` is the default `:processing` reliable queue. */
87
+ type RedisLayout = "legacy" | "zset";
88
+ /** The four keys of the Laravel-compatible zset layout. */
89
+ interface ZsetKeys {
90
+ ready: string;
91
+ delayed: string;
92
+ reserved: string;
93
+ notify: string;
94
+ }
95
+ /** The default key prefix of the zset layout (Laravel's `queues:`). */
96
+ declare const ZSET_KEY_PREFIX = "queues:";
97
+ /**
98
+ * The zset-layout keys for `queue`: `queues:<q>`, `:delayed`, `:reserved`, `:notify`. In a Redis
99
+ * Cluster, hash tags (`{orders}`) are the caller's responsibility (put them in `queue`).
100
+ */
101
+ declare function zsetKeys(queue: string, prefix?: string): ZsetKeys;
102
+ /**
103
+ * The Lua scripts, exported so a test double can dispatch `eval` on script identity. Every script
104
+ * is atomic; none of them rewrites a body except the zset reserve (Laravel's `cjson` increment).
105
+ */
106
+ declare const LUA: {
107
+ /** KEYS=[q, q:processing, q:processing:deadlines] ARGV=[deadline, token]. Returns the raw body or nil. */
108
+ readonly legacyReserve: "\nlocal raw = redis.call('LMOVE', KEYS[1], KEYS[2], 'LEFT', 'RIGHT')\nif not raw then return false end\nredis.call('ZADD', KEYS[3], ARGV[1], ARGV[2] .. ':' .. raw)\nreturn raw\n";
109
+ /** KEYS=[q, q:processing, q:processing:deadlines] ARGV=[floor(now), batch]. Returns the count moved. */
110
+ readonly legacySweep: "\nlocal batch = tonumber(ARGV[2])\nlocal members\nif batch > 0 then\n members = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', ARGV[1], 'LIMIT', 0, batch)\nelse\n members = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', ARGV[1])\nend\nlocal moved = 0\nfor _, m in ipairs(members) do\n if #m >= 34 and string.sub(m, 33, 33) == ':' then\n local raw = string.sub(m, 34)\n if redis.call('LREM', KEYS[2], 1, raw) == 1 then\n redis.call('RPUSH', KEYS[1], raw)\n moved = moved + 1\n end\n end\n redis.call('ZREM', KEYS[3], m)\nend\nreturn moved\n";
111
+ /** KEYS=[q:processing, q:processing:deadlines] ARGV=[token, raw]. 1 if the reservation was live. */
112
+ readonly legacyAck: "\nif redis.call('ZREM', KEYS[2], ARGV[1] .. ':' .. ARGV[2]) == 1 then\n redis.call('LREM', KEYS[1], 1, ARGV[2])\n return 1\nend\nreturn 0\n";
113
+ /** KEYS=[q, q:processing, q:processing:deadlines] ARGV=[token, raw, body]. */
114
+ readonly legacyRelease: "\nif redis.call('ZREM', KEYS[3], ARGV[1] .. ':' .. ARGV[2]) == 1 then\n redis.call('LREM', KEYS[2], 1, ARGV[2])\n redis.call('RPUSH', KEYS[1], ARGV[3])\n return 1\nend\nreturn 0\n";
115
+ /** KEYS=[q:processing, q:processing:deadlines, dlq] ARGV=[token, raw, body]. */
116
+ readonly legacyDeadLetter: "\nif redis.call('ZREM', KEYS[2], ARGV[1] .. ':' .. ARGV[2]) == 1 then\n redis.call('LREM', KEYS[1], 1, ARGV[2])\n redis.call('RPUSH', KEYS[3], ARGV[3])\n return 1\nend\nreturn 0\n";
117
+ /**
118
+ * KEYS=[q, q:reserved, q:notify] ARGV=[expiry]. Returns `{job, reserved, flag}` or nil; flag `'1'`
119
+ * is poison (`reserved == job`), `'0'` a delivered body. Nothing after the LPOP may raise, so the
120
+ * re-encode is guarded too: lua-cjson decodes `1e999` / `NaN` / `Infinity` but refuses to encode
121
+ * them, and a bare `cjson.encode` here would lose the already-popped message (broker-bindings
122
+ * section 1.1, ADR-0033 A1). An unencodable body takes the poison path.
123
+ */
124
+ readonly zsetReserve: "\nlocal job = redis.call('LPOP', KEYS[1])\nif not job then return false end\nlocal ok, decoded = pcall(cjson.decode, job)\nif ok and type(decoded) == 'table' and type(decoded['attempts']) == 'number' then\n decoded['attempts'] = decoded['attempts'] + 1\n local eok, reserved = pcall(cjson.encode, decoded)\n if eok and type(reserved) == 'string' then\n redis.call('ZADD', KEYS[2], ARGV[1], reserved)\n redis.call('LPOP', KEYS[3])\n return {job, reserved, '0'}\n end\nend\nredis.call('ZADD', KEYS[2], ARGV[1], job)\nreturn {job, job, '1'}\n";
125
+ /** KEYS=[source zset, q, q:notify] ARGV=[floor(now), batch]. Returns the count moved. */
126
+ readonly zsetMigrate: "\nlocal batch = tonumber(ARGV[2])\nlocal val\nif batch > 0 then\n val = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, batch)\nelse\n val = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])\nend\nif #val > 0 then\n redis.call('ZREMRANGEBYRANK', KEYS[1], 0, #val - 1)\n for i = 1, #val, 100 do\n redis.call('RPUSH', KEYS[2], unpack(val, i, math.min(i + 99, #val)))\n end\n for _ = 1, #val do\n redis.call('RPUSH', KEYS[3], '1')\n end\nend\nreturn #val\n";
127
+ /** KEYS=[q, q:notify] ARGV=[body]. */
128
+ readonly zsetPush: "\nredis.call('RPUSH', KEYS[1], ARGV[1])\nredis.call('RPUSH', KEYS[2], '1')\nreturn 1\n";
129
+ /** KEYS=[q:reserved] ARGV=[member]. */
130
+ readonly zsetAck: "\nreturn redis.call('ZREM', KEYS[1], ARGV[1])\n";
131
+ /** KEYS=[q:delayed, q:reserved] ARGV=[member, score, body]. The ZADD is unconditional. */
132
+ readonly zsetRelease: "\nredis.call('ZREM', KEYS[2], ARGV[1])\nredis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])\nreturn 1\n";
133
+ /**
134
+ * KEYS=[q:reserved, dlq] ARGV=[member, body]. Terminal-failure dead-letter, gated on the ZREM like
135
+ * the legacy scripts: a late dead-letter whose reservation was already recovered (back on `<q>`)
136
+ * writes nothing, so the body is never both ready and dead-lettered. A poison reservation uses
137
+ * `zsetDeadLetterPoison` instead (unconditional, as the contract requires).
138
+ */
139
+ readonly zsetDeadLetter: "\nif redis.call('ZREM', KEYS[1], ARGV[1]) == 1 then\n redis.call('RPUSH', KEYS[2], ARGV[2])\n return 1\nend\nreturn 0\n";
140
+ /**
141
+ * KEYS=[q:reserved, dlq] ARGV=[member, body]. The poison dead-letter (broker-bindings section 1.1):
142
+ * the SDK MUST write the message to the DLQ and `ZREM` it, unconditionally. Two byte-equal poison
143
+ * bodies reserved together share one `:reserved` member, so a `ZREM` gate would drop the second
144
+ * copy without ever writing it.
145
+ */
146
+ readonly zsetDeadLetterPoison: "\nredis.call('RPUSH', KEYS[2], ARGV[2])\nredis.call('ZREM', KEYS[1], ARGV[1])\nreturn 1\n";
147
+ };
148
+ /** Options for {@link RedisQueue} (and, through it, {@link RedisPublisher} / {@link RedisConsumer}). */
149
+ interface RedisQueueOptions {
150
+ /** `"legacy"` (default, the `:processing` reliable queue) or `"zset"` (Laravel-compatible). */
151
+ layout?: RedisLayout;
152
+ /**
153
+ * Seconds before an unacknowledged reservation is recovered (default 90). `null` disables
154
+ * recovery (the legacy deadline member is written with score `+inf`; the zset `:reserved`
155
+ * migrate is skipped). A negative or non-finite value is a configuration error. In the zset
156
+ * layout a recovery counts as an attempt. The handler timeout SHOULD be shorter than this.
157
+ */
158
+ retryAfter?: number | null;
159
+ /** Max items one migrate/sweep moves; `-1` (default) or `0` means no limit. */
160
+ migrationBatchSize?: number;
161
+ /** Epoch-seconds clock (fractions allowed); injectable for tests. Default `Date.now() / 1000`. */
162
+ clock?: () => number;
163
+ /** Zset key prefix (default `"queues:"`). Ignored by the legacy layout. */
164
+ keyPrefix?: string;
165
+ /** Suffix for the legacy reservation list (default `":processing"`). */
166
+ processingSuffix?: string;
167
+ /** The dead-letter list (default `<queue>.dlq`); `null` drops on terminal failure. */
168
+ deadLetterQueue?: string | null;
169
+ /** Receives the "reservation expired" warning (default `console.warn`). */
170
+ onWarn?: (message: string) => void;
171
+ }
172
+ /** A legacy reservation: the raw body plus the 32-hex token of its deadline member. */
173
+ interface LegacyReservation {
174
+ layout: "legacy";
175
+ raw: string;
176
+ token: string;
177
+ /**
178
+ * The exact stored bytes, set only when they are not valid UTF-8 (so `raw` carries U+FFFD substitutions).
179
+ * The settle scripts and the poison record use these, never the lossy `raw`.
180
+ */
181
+ rawBytes?: Buffer;
182
+ }
183
+ /** A zset reservation: the raw body (what the handler sees) and the exact `:reserved` member. */
184
+ interface ZsetReservation {
185
+ layout: "zset";
186
+ raw: string;
187
+ reserved: string;
188
+ /** The exact stored bytes of the job / `:reserved` member, set only when they are not valid UTF-8. */
189
+ rawBytes?: Buffer;
190
+ reservedBytes?: Buffer;
191
+ /**
192
+ * Set when the reserve script flagged the body as poison (undecodable, or `attempts` not a
193
+ * number): its dead-letter is then unconditional, not gated on the `ZREM`.
194
+ */
195
+ poison?: boolean;
196
+ }
197
+ /** Whatever a reserve handed out; pass it back to ack / release / deadLetter. */
198
+ type Reservation = LegacyReservation | ZsetReservation;
199
+ /** The outcome of one non-blocking {@link RedisQueue.reserve}. */
200
+ interface ReserveResult {
201
+ outcome: "delivered" | "empty" | "poison";
202
+ /** The raw body the handler sees (absent on `empty`). */
203
+ raw?: string;
204
+ /** The reservation handle (absent on `empty`). */
205
+ reservation?: Reservation;
206
+ /** Zset only: the delivery count the handler is told (`job.attempts + 1`); absent on poison. */
207
+ attempts?: number;
208
+ }
209
+ /** A fresh 128-bit reservation token: 32 lowercase hex characters from the OS CSPRNG. */
210
+ declare function newReservationToken(): string;
211
+ /**
212
+ * The Redis operations behind the publisher and the consumer, one set per layout. The methods are
213
+ * non-blocking and take their time from the injectable clock, so they can be driven step by step
214
+ * (the conformance suite does exactly that).
215
+ */
216
+ declare class RedisQueue {
217
+ private readonly client;
218
+ readonly queue: string;
219
+ readonly layout: RedisLayout;
220
+ readonly retryAfter: number | null;
221
+ readonly migrationBatchSize: number;
222
+ readonly deadLetterKey: string | null;
223
+ /** The zset keys (zset layout) — also usable to inspect the legacy ready list via `ready`. */
224
+ readonly keys: ZsetKeys;
225
+ readonly processingKey: string;
226
+ readonly deadlinesKey: string;
227
+ private readonly clock;
228
+ private readonly warn;
229
+ constructor(client: RedisClient, queue: string, options?: RedisQueueOptions);
230
+ /**
231
+ * Run a script. `binary` (or any `Buffer` argument) selects the client's `evalBuffer`, when it has one,
232
+ * so replies and arguments are exact bytes.
233
+ */
234
+ private run;
235
+ private nowSeconds;
236
+ private deadlineScore;
237
+ /** Append a body to the ready list (zset: also one `:notify` token). */
238
+ push(body: string): Promise<void>;
239
+ /**
240
+ * Schedule a body for `delay` seconds from now: `ZADD <q>:delayed floor(now)+ceil(delay)` with
241
+ * the body byte-for-byte and no `:notify` token. Zset layout only.
242
+ */
243
+ pushDelayed(body: string, delay: number): Promise<void>;
244
+ /** Zset: move due `:delayed` items, then due `:reserved` ones (skipped if `retryAfter` is null). */
245
+ migrate(): Promise<number>;
246
+ private migrateFrom;
247
+ /** Legacy: move expired reservations back onto the ready list (body untouched). */
248
+ sweep(): Promise<number>;
249
+ /** Recovery and migration for the layout; runs before every reserve. */
250
+ maintain(): Promise<number>;
251
+ /** One non-blocking reserve, preceded by the layout's migrate / sweep. */
252
+ reserve(): Promise<ReserveResult>;
253
+ private reserveLegacy;
254
+ private reserveZset;
255
+ /** Acknowledge. Legacy: false (plus a warning) if the reservation had already expired. */
256
+ ack(reservation: Reservation): Promise<boolean>;
257
+ /**
258
+ * Give a reservation back. Legacy: the body (default: `raw`) goes to the ready list, gated on the
259
+ * deadline member; `delay` is ignored. Zset: one script `ZREM`s the `:reserved` member and `ZADD`s
260
+ * `<q>:delayed` at `floor(now)+ceil(delay)` with the body (default: `raw` re-encoded with
261
+ * `attempts + 1`, every other key preserved).
262
+ */
263
+ release(reservation: Reservation, delay?: number, body?: string): Promise<boolean>;
264
+ /** Write `body` (default: `raw`) to the dead-letter list and settle the reservation. */
265
+ deadLetter(reservation: Reservation, body?: string): Promise<boolean>;
266
+ private liveOrWarn;
267
+ }
34
268
  /** Options for {@link RedisPublisher.publish}. */
35
269
  interface PublishOptions {
36
270
  traceId?: string;
271
+ /**
272
+ * Out-of-band transport headers to carry beside the frozen envelope (ADR-0028) — e.g. a W3C
273
+ * `traceparent`. Legacy layout: a non-empty carrier RPUSHes a transport-owned frame; an
274
+ * empty/omitted carrier stays a byte-identical bare publish. **Zset layout: dropped** (ADR-0033
275
+ * A3) — the bare envelope is written and the `traceparent` is not propagated.
276
+ */
277
+ headers?: HeaderCarrier;
278
+ /**
279
+ * Deliver after `delay` seconds (`ZADD <q>:delayed floor(now)+ceil(delay)`, no `:notify` token).
280
+ * Requires `layout: "zset"`; the legacy layout throws {@link BabelQueueError}.
281
+ */
282
+ delay?: number;
37
283
  }
38
- /** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */
284
+ /** Sends canonical-envelope messages to one Redis queue. */
39
285
  declare class RedisPublisher {
40
- private readonly client;
41
286
  private readonly queue;
287
+ private readonly transport;
42
288
  private constructor();
43
- /** A publisher over `client`, producing to the list named `queue`. */
44
- static create(client: RedisClient, queue: string): RedisPublisher;
45
- /** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
289
+ /** A publisher over `client`, producing to the queue named `queue`. */
290
+ static create(client: RedisClient, queue: string, options?: RedisQueueOptions): RedisPublisher;
291
+ /**
292
+ * Build + push the canonical envelope; returns the message id (`meta.id`). Legacy layout: a
293
+ * non-empty `options.headers` pushes a transport-owned frame (ADR-0028), otherwise the bare
294
+ * envelope byte-for-byte. Zset layout: always the bare envelope (A3); with `options.delay > 0`
295
+ * it goes to `<q>:delayed` instead of the ready list.
296
+ */
46
297
  publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
47
298
  }
48
- /** A URN handler. Receives the validated envelope and the raw list element. */
49
- type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;
299
+ /**
300
+ * A URN handler. Receives the validated envelope, the raw stored list element, and the out-of-band
301
+ * {@link HeaderCarrier} carried beside it (empty for a bare value and always empty in the zset
302
+ * layout). Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a
303
+ * child of the producer span (ADR-0028).
304
+ */
305
+ type BabelHandler = (envelope: Envelope, raw: string, headers: HeaderCarrier) => unknown | Promise<unknown>;
50
306
  /** A map of URN → handler. */
51
307
  type BabelHandlers = Record<string, BabelHandler>;
52
308
  /** Options for {@link RedisConsumer}. */
53
- interface RedisConsumerOptions {
54
- /** Suffix for the per-queue reservation list (default `":processing"`). */
55
- processingSuffix?: string;
56
- /** Block timeout (seconds) for a single reserve (default 5). */
309
+ interface RedisConsumerOptions extends RedisQueueOptions {
310
+ /**
311
+ * How long one {@link RedisConsumer.poll} waits for a message, in seconds (default 5). Legacy:
312
+ * a sleep-poll (100 ms, doubling, capped at 1 s). Zset: `BLPOP` on `:notify` in slices of at most
313
+ * 1 s, migrating between slices. `0` or less means one non-blocking attempt.
314
+ */
57
315
  blockTimeout?: number;
58
316
  /** Attempts before terminal dead-lettering (default 3). */
59
317
  maxTries?: number;
60
- /** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */
61
- deadLetterQueue?: string | null;
62
318
  /** Strategy for a URN with no handler (default `fail`). */
63
319
  unknownUrn?: string;
64
320
  /** Called for a non-conformant message, an unmapped URN, or a throwing handler. */
65
321
  onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;
66
322
  }
67
323
  /**
68
- * Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +
69
- * validate, route to the handler for its URN, then `LREM` it from the reservation list on success.
70
- * A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,
71
- * then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in
72
- * the body and the runtime owns retry.
324
+ * Consumes a Redis queue: reserve one message (legacy: `LMOVE` into `<queue>:processing` with a
325
+ * deadline; zset: the Laravel pop), decode + validate, route to the handler for its URN, then
326
+ * acknowledge. A throwing handler gives the message back with `attempts + 1` (at-least-once) up to
327
+ * `maxTries`, then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts`
328
+ * lives in the body and the runtime owns retry.
73
329
  */
74
330
  declare class RedisConsumer {
75
331
  private readonly client;
76
332
  private readonly queue;
77
333
  private readonly handlers;
78
334
  private readonly options;
79
- private readonly processing;
335
+ private readonly transport;
80
336
  private readonly blockTimeout;
81
337
  private readonly maxTries;
82
- private readonly dlq;
83
338
  private readonly unknownUrn;
84
339
  constructor(client: RedisClient, queue: string, handlers: BabelHandlers, options?: RedisConsumerOptions);
340
+ /** The underlying transport (for diagnostics and tests). */
341
+ get redisQueue(): RedisQueue;
85
342
  /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
86
343
  poll(): Promise<boolean>;
87
344
  /** Poll while `shouldContinue` returns true. */
88
345
  run(shouldContinue: () => boolean): Promise<void>;
89
- /** Route + settle one reserved element. Exposed for testing. */
90
- handle(raw: string): Promise<void>;
346
+ private dispatch;
347
+ /**
348
+ * Route + settle one reserved message. Exposed for testing.
349
+ *
350
+ * Legacy layout: `reservation.raw` may be a transport-owned header frame or a bare envelope; it is
351
+ * unframed first so the envelope is decoded from the verbatim wire body while the carried headers
352
+ * (e.g. a `traceparent`, ADR-0028) reach the handler. The zset layout is never framed (A3).
353
+ */
354
+ handle(reservation: Reservation): Promise<void>;
91
355
  private onUnknownUrn;
92
356
  private retryOrDeadLetter;
93
- /** Republish with `attempts + 1` onto the work list (at-least-once retry). */
357
+ /** Give the message back with `attempts + 1` (at-least-once retry). */
94
358
  private requeue;
95
359
  private deadLetter;
96
- private deadLetterRaw;
97
- /** Remove the element from the reservation list (acknowledge). */
98
- private settle;
360
+ /**
361
+ * The poison DLQ record of a reservation (ADR-0035, broker-bindings section 1.1): the delivered body (a legacy
362
+ * header frame is unwrapped first) with a `dead_letter` block of reason `poison`. A strict JSON object is
363
+ * spliced, anything else is wrapped as `{"raw", "dead_letter"}`.
364
+ *
365
+ * A body that is not valid UTF-8 is read as exact bytes (`evalBuffer`, see {@link RedisClient.evalBuffer}) and
366
+ * kept as stored (no frame is peeled off it), so it lands in the base64 wrapper, never as U+FFFD text.
367
+ */
368
+ private poisonRecord;
99
369
  private report;
100
370
  }
101
371
 
102
- export { type BabelHandler, type BabelHandlers, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, redisValue };
372
+ export { type BabelHandler, type BabelHandlers, FRAME_KEY, LUA, type LegacyReservation, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, type RedisLayout, RedisPublisher, RedisQueue, type RedisQueueOptions, type Reservation, type ReserveResult, ZSET_KEY_PREFIX, type ZsetKeys, type ZsetReservation, frameValue, headersOf, newReservationToken, redisValue, unframe, zsetKeys };