@babelqueue/redis 1.1.0 → 1.3.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
@@ -3,48 +3,64 @@ import { Envelope, HeaderCarrier, IncomingEnvelope } from '@babelqueue/core';
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`.
17
23
  *
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).
24
+ * `ioredis` is an optional peer — you provide the client (an `ioredis` instance satisfies the
25
+ * adapter structurally).
20
26
  *
21
- * **Out-of-band headers (ADR-0028).** A Redis list element *is* the canonical envelope JSON (the
22
- * `LREM` ack handle is that exact value), so — unlike AMQP headers or SQS `MessageAttributes` —
23
- * there is no native per-message metadata channel. To carry a {@link HeaderCarrier} (e.g. a W3C
24
- * `traceparent` for cross-hop span linkage) the adapter owns a tiny JSON *frame* distinct from the
25
- * wire envelope:
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:
26
31
  *
27
32
  * {"__bq_frame":1,"headers":{"traceparent":"00-…"},"body":"<raw wire envelope>"}
28
33
  *
29
- * `RPUSH` stores the frame, so the `LREM` ack handle stays byte-for-byte what was pushed and the
30
- * reliable-queue semantics (`RPUSH`/`BRPOPLPUSH`/`LREM`) are untouched. Framing is **opt-in and
31
- * backward compatible**: only `publish` with a non-empty `headers` carrier writes a frame; a plain
32
- * publish stores the **bare** envelope byte-for-byte, exactly as before. The consumer detects
33
- * frame-vs-bare by the reserved `__bq_frame` sentinel (a frozen envelope can never carry it), so a
34
- * bare value consumes with empty headers and cross-version queues interoperate. This mirrors the
35
- * Go `/redis` and PHP `RedisTransport` framing byte-for-byte. GR-1: the wire envelope is never
36
- * touched.
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.
37
42
  */
38
43
 
39
44
  /** The subset of an ioredis client this adapter calls. */
40
45
  interface RedisClient {
41
46
  /** Append a value to the tail of a list; returns the new length. */
42
47
  rpush(key: string, value: string): Promise<number>;
43
- /** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up
44
- * to `timeout` seconds; resolves to the value, or `null` on timeout. */
45
- brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;
46
- /** Remove `count` occurrences of `value` from a list; returns how many were removed. */
47
- 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>;
48
64
  }
49
65
  /** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */
50
66
  declare function redisValue(envelope: Envelope): string;
@@ -67,95 +83,296 @@ declare function frameValue(body: string, headers: HeaderCarrier | null | undefi
67
83
  declare function unframe(value: string): [string, HeaderCarrier];
68
84
  /** The out-of-band headers carried by a stored list value (empty for a bare value). */
69
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
+ }
70
268
  /** Options for {@link RedisPublisher.publish}. */
71
269
  interface PublishOptions {
72
270
  traceId?: string;
73
271
  /**
74
272
  * Out-of-band transport headers to carry beside the frozen envelope (ADR-0028) — e.g. a W3C
75
- * `traceparent` written by `@babelqueue/core/otel`'s `publish`. A non-empty carrier RPUSHes a
76
- * transport-owned frame; an empty/omitted carrier stays a byte-identical bare publish.
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.
77
276
  */
78
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;
79
283
  }
80
- /** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */
284
+ /** Sends canonical-envelope messages to one Redis queue. */
81
285
  declare class RedisPublisher {
82
- private readonly client;
83
286
  private readonly queue;
287
+ private readonly transport;
84
288
  private constructor();
85
- /** A publisher over `client`, producing to the list named `queue`. */
86
- static create(client: RedisClient, queue: string): RedisPublisher;
289
+ /** A publisher over `client`, producing to the queue named `queue`. */
290
+ static create(client: RedisClient, queue: string, options?: RedisQueueOptions): RedisPublisher;
87
291
  /**
88
- * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When
89
- * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value
90
- * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is
91
- * pushed byte-for-byte (GR-1, no regression).
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.
92
296
  */
93
297
  publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
94
298
  }
95
299
  /**
96
300
  * A URN handler. Receives the validated envelope, the raw stored list element, and the out-of-band
97
- * {@link HeaderCarrier} carried beside it (empty for a bare value). Pass `headers` to
98
- * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer
99
- * span (ADR-0028).
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).
100
304
  */
101
305
  type BabelHandler = (envelope: Envelope, raw: string, headers: HeaderCarrier) => unknown | Promise<unknown>;
102
306
  /** A map of URN → handler. */
103
307
  type BabelHandlers = Record<string, BabelHandler>;
104
308
  /** Options for {@link RedisConsumer}. */
105
- interface RedisConsumerOptions {
106
- /** Suffix for the per-queue reservation list (default `":processing"`). */
107
- processingSuffix?: string;
108
- /** 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
+ */
109
315
  blockTimeout?: number;
110
316
  /** Attempts before terminal dead-lettering (default 3). */
111
317
  maxTries?: number;
112
- /** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */
113
- deadLetterQueue?: string | null;
114
318
  /** Strategy for a URN with no handler (default `fail`). */
115
319
  unknownUrn?: string;
116
320
  /** Called for a non-conformant message, an unmapped URN, or a throwing handler. */
117
321
  onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;
118
322
  }
119
323
  /**
120
- * Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +
121
- * validate, route to the handler for its URN, then `LREM` it from the reservation list on success.
122
- * A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,
123
- * then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in
124
- * 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.
125
329
  */
126
330
  declare class RedisConsumer {
127
331
  private readonly client;
128
332
  private readonly queue;
129
333
  private readonly handlers;
130
334
  private readonly options;
131
- private readonly processing;
335
+ private readonly transport;
132
336
  private readonly blockTimeout;
133
337
  private readonly maxTries;
134
- private readonly dlq;
135
338
  private readonly unknownUrn;
136
339
  constructor(client: RedisClient, queue: string, handlers: BabelHandlers, options?: RedisConsumerOptions);
340
+ /** The underlying transport (for diagnostics and tests). */
341
+ get redisQueue(): RedisQueue;
137
342
  /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
138
343
  poll(): Promise<boolean>;
139
344
  /** Poll while `shouldContinue` returns true. */
140
345
  run(shouldContinue: () => boolean): Promise<void>;
346
+ private dispatch;
141
347
  /**
142
- * Route + settle one reserved element. Exposed for testing.
348
+ * Route + settle one reserved message. Exposed for testing.
143
349
  *
144
- * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header
145
- * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim
146
- * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced
147
- * to the handler. Settling/dead-lettering still operate on the original stored value.
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).
148
353
  */
149
- handle(raw: string): Promise<void>;
354
+ handle(reservation: Reservation): Promise<void>;
150
355
  private onUnknownUrn;
151
356
  private retryOrDeadLetter;
152
- /** Republish with `attempts + 1` onto the work list (at-least-once retry). */
357
+ /** Give the message back with `attempts + 1` (at-least-once retry). */
153
358
  private requeue;
359
+ /**
360
+ * Write the annotated envelope to the DLQ. `counted` is given on the exhaustion path (retries used up): the
361
+ * delivered attempts plus one for the current delivery, written to both `dead_letter.attempts` and the
362
+ * record's top-level `attempts`, like Go and Python. A direct dead-letter that makes no retry decision
363
+ * (`unknownUrn: dead_letter`) omits it and records the delivered value, which is informational.
364
+ */
154
365
  private deadLetter;
155
- private deadLetterRaw;
156
- /** Remove the element from the reservation list (acknowledge). */
157
- private settle;
366
+ /**
367
+ * The poison DLQ record of a reservation (ADR-0035, broker-bindings section 1.1): the delivered body (a legacy
368
+ * header frame is unwrapped first) with a `dead_letter` block of reason `poison`. A strict JSON object is
369
+ * spliced, anything else is wrapped as `{"raw", "dead_letter"}`.
370
+ *
371
+ * A body that is not valid UTF-8 is read as exact bytes (`evalBuffer`, see {@link RedisClient.evalBuffer}) and
372
+ * kept as stored (no frame is peeled off it), so it lands in the base64 wrapper, never as U+FFFD text.
373
+ */
374
+ private poisonRecord;
158
375
  private report;
159
376
  }
160
377
 
161
- export { type BabelHandler, type BabelHandlers, FRAME_KEY, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, frameValue, headersOf, redisValue, unframe };
378
+ 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 };