@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.cjs CHANGED
@@ -21,14 +21,20 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  FRAME_KEY: () => FRAME_KEY,
24
+ LUA: () => LUA,
24
25
  RedisConsumer: () => RedisConsumer,
25
26
  RedisPublisher: () => RedisPublisher,
27
+ RedisQueue: () => RedisQueue,
28
+ ZSET_KEY_PREFIX: () => ZSET_KEY_PREFIX,
26
29
  frameValue: () => frameValue,
27
30
  headersOf: () => headersOf,
31
+ newReservationToken: () => newReservationToken,
28
32
  redisValue: () => redisValue,
29
- unframe: () => unframe
33
+ unframe: () => unframe,
34
+ zsetKeys: () => zsetKeys
30
35
  });
31
36
  module.exports = __toCommonJS(index_exports);
37
+ var import_node_crypto = require("crypto");
32
38
  var import_core = require("@babelqueue/core");
33
39
 
34
40
  // src/headers.ts
@@ -43,6 +49,109 @@ function sanitizeHeaders(headers) {
43
49
  return out;
44
50
  }
45
51
 
52
+ // src/poison-record.ts
53
+ var JSON_SPACE = /^[ \t\r\n]+|[ \t\r\n]+$/g;
54
+ function strictUtf8(bytes) {
55
+ try {
56
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ function assertJsonObject(deadLetter) {
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(deadLetter);
65
+ } catch {
66
+ throw new TypeError("deadLetter must be the JSON text of a single object");
67
+ }
68
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ throw new TypeError("deadLetter must be the JSON text of a single object");
70
+ }
71
+ }
72
+ function buildPoisonRecord(body, deadLetter) {
73
+ assertJsonObject(deadLetter);
74
+ const text = strictUtf8(body);
75
+ if (text !== null && isSpliceableObject(text)) {
76
+ const trimmed = text.replace(JSON_SPACE, "");
77
+ const inner = trimmed.slice(1, -1).replace(JSON_SPACE, "");
78
+ return inner === "" ? `{"dead_letter":${deadLetter}}` : `${trimmed.slice(0, -1)},"dead_letter":${deadLetter}}`;
79
+ }
80
+ const raw = text !== null ? `"raw":${JSON.stringify(text)}` : `"raw":${JSON.stringify(Buffer.from(body).toString("base64"))},"raw_encoding":"base64"`;
81
+ return `{${raw},"dead_letter":${deadLetter}}`;
82
+ }
83
+ function isSpliceableObject(text) {
84
+ try {
85
+ JSON.parse(text);
86
+ } catch {
87
+ return false;
88
+ }
89
+ let i = 0;
90
+ while (i < text.length && isJsonSpace(text.charCodeAt(i))) i++;
91
+ if (text[i] !== "{") return false;
92
+ let depth = 0;
93
+ let expectKey = false;
94
+ while (i < text.length) {
95
+ const c = text[i];
96
+ if (c === "{" || c === "[") {
97
+ depth++;
98
+ expectKey = c === "{" && depth === 1;
99
+ i++;
100
+ } else if (c === "}" || c === "]") {
101
+ depth--;
102
+ i++;
103
+ } else if (c === ",") {
104
+ expectKey = depth === 1;
105
+ i++;
106
+ } else if (c === '"') {
107
+ const end = scanJsonString(text, i);
108
+ if (end < 0) return false;
109
+ if (depth === 1 && expectKey) {
110
+ if (JSON.parse(text.slice(i, end)) === "dead_letter") return false;
111
+ expectKey = false;
112
+ }
113
+ i = end;
114
+ } else {
115
+ i++;
116
+ }
117
+ }
118
+ return true;
119
+ }
120
+ function isJsonSpace(c) {
121
+ return c === 32 || c === 9 || c === 13 || c === 10;
122
+ }
123
+ function scanJsonString(text, start) {
124
+ let i = start + 1;
125
+ while (i < text.length) {
126
+ const c = text[i];
127
+ if (c === '"') return i + 1;
128
+ if (c !== "\\") {
129
+ i++;
130
+ continue;
131
+ }
132
+ if (text[i + 1] !== "u") {
133
+ i += 2;
134
+ continue;
135
+ }
136
+ const cp = hex4(text, i + 2);
137
+ if (cp < 0) return -1;
138
+ i += 6;
139
+ if (cp >= 55296 && cp <= 56319) {
140
+ if (text[i] !== "\\" || text[i + 1] !== "u") return -1;
141
+ const lo = hex4(text, i + 2);
142
+ if (lo < 56320 || lo > 57343) return -1;
143
+ i += 6;
144
+ } else if (cp >= 56320 && cp <= 57343) {
145
+ return -1;
146
+ }
147
+ }
148
+ return -1;
149
+ }
150
+ function hex4(text, at) {
151
+ const digits = text.slice(at, at + 4);
152
+ return /^[0-9a-fA-F]{4}$/.test(digits) ? parseInt(digits, 16) : -1;
153
+ }
154
+
46
155
  // src/index.ts
47
156
  function redisValue(envelope) {
48
157
  return import_core.EnvelopeCodec.encode(envelope);
@@ -74,56 +183,540 @@ function unframe(value) {
74
183
  function headersOf(value) {
75
184
  return unframe(value)[1];
76
185
  }
77
- var RedisPublisher = class _RedisPublisher {
78
- constructor(client, queue) {
186
+ var ZSET_KEY_PREFIX = "queues:";
187
+ function zsetKeys(queue, prefix = ZSET_KEY_PREFIX) {
188
+ const ready = `${prefix}${queue}`;
189
+ return { ready, delayed: `${ready}:delayed`, reserved: `${ready}:reserved`, notify: `${ready}:notify` };
190
+ }
191
+ var LUA = {
192
+ /** KEYS=[q, q:processing, q:processing:deadlines] ARGV=[deadline, token]. Returns the raw body or nil. */
193
+ legacyReserve: `
194
+ local raw = redis.call('LMOVE', KEYS[1], KEYS[2], 'LEFT', 'RIGHT')
195
+ if not raw then return false end
196
+ redis.call('ZADD', KEYS[3], ARGV[1], ARGV[2] .. ':' .. raw)
197
+ return raw
198
+ `,
199
+ /** KEYS=[q, q:processing, q:processing:deadlines] ARGV=[floor(now), batch]. Returns the count moved. */
200
+ legacySweep: `
201
+ local batch = tonumber(ARGV[2])
202
+ local members
203
+ if batch > 0 then
204
+ members = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', ARGV[1], 'LIMIT', 0, batch)
205
+ else
206
+ members = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', ARGV[1])
207
+ end
208
+ local moved = 0
209
+ for _, m in ipairs(members) do
210
+ if #m >= 34 and string.sub(m, 33, 33) == ':' then
211
+ local raw = string.sub(m, 34)
212
+ if redis.call('LREM', KEYS[2], 1, raw) == 1 then
213
+ redis.call('RPUSH', KEYS[1], raw)
214
+ moved = moved + 1
215
+ end
216
+ end
217
+ redis.call('ZREM', KEYS[3], m)
218
+ end
219
+ return moved
220
+ `,
221
+ /** KEYS=[q:processing, q:processing:deadlines] ARGV=[token, raw]. 1 if the reservation was live. */
222
+ legacyAck: `
223
+ if redis.call('ZREM', KEYS[2], ARGV[1] .. ':' .. ARGV[2]) == 1 then
224
+ redis.call('LREM', KEYS[1], 1, ARGV[2])
225
+ return 1
226
+ end
227
+ return 0
228
+ `,
229
+ /** KEYS=[q, q:processing, q:processing:deadlines] ARGV=[token, raw, body]. */
230
+ legacyRelease: `
231
+ if redis.call('ZREM', KEYS[3], ARGV[1] .. ':' .. ARGV[2]) == 1 then
232
+ redis.call('LREM', KEYS[2], 1, ARGV[2])
233
+ redis.call('RPUSH', KEYS[1], ARGV[3])
234
+ return 1
235
+ end
236
+ return 0
237
+ `,
238
+ /** KEYS=[q:processing, q:processing:deadlines, dlq] ARGV=[token, raw, body]. */
239
+ legacyDeadLetter: `
240
+ if redis.call('ZREM', KEYS[2], ARGV[1] .. ':' .. ARGV[2]) == 1 then
241
+ redis.call('LREM', KEYS[1], 1, ARGV[2])
242
+ redis.call('RPUSH', KEYS[3], ARGV[3])
243
+ return 1
244
+ end
245
+ return 0
246
+ `,
247
+ /**
248
+ * KEYS=[q, q:reserved, q:notify] ARGV=[expiry]. Returns `{job, reserved, flag}` or nil; flag `'1'`
249
+ * is poison (`reserved == job`), `'0'` a delivered body. Nothing after the LPOP may raise, so the
250
+ * re-encode is guarded too: lua-cjson decodes `1e999` / `NaN` / `Infinity` but refuses to encode
251
+ * them, and a bare `cjson.encode` here would lose the already-popped message (broker-bindings
252
+ * section 1.1, ADR-0033 A1). An unencodable body takes the poison path.
253
+ */
254
+ zsetReserve: `
255
+ local job = redis.call('LPOP', KEYS[1])
256
+ if not job then return false end
257
+ local ok, decoded = pcall(cjson.decode, job)
258
+ if ok and type(decoded) == 'table' and type(decoded['attempts']) == 'number' then
259
+ decoded['attempts'] = decoded['attempts'] + 1
260
+ local eok, reserved = pcall(cjson.encode, decoded)
261
+ if eok and type(reserved) == 'string' then
262
+ redis.call('ZADD', KEYS[2], ARGV[1], reserved)
263
+ redis.call('LPOP', KEYS[3])
264
+ return {job, reserved, '0'}
265
+ end
266
+ end
267
+ redis.call('ZADD', KEYS[2], ARGV[1], job)
268
+ return {job, job, '1'}
269
+ `,
270
+ /** KEYS=[source zset, q, q:notify] ARGV=[floor(now), batch]. Returns the count moved. */
271
+ zsetMigrate: `
272
+ local batch = tonumber(ARGV[2])
273
+ local val
274
+ if batch > 0 then
275
+ val = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, batch)
276
+ else
277
+ val = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
278
+ end
279
+ if #val > 0 then
280
+ redis.call('ZREMRANGEBYRANK', KEYS[1], 0, #val - 1)
281
+ for i = 1, #val, 100 do
282
+ redis.call('RPUSH', KEYS[2], unpack(val, i, math.min(i + 99, #val)))
283
+ end
284
+ for _ = 1, #val do
285
+ redis.call('RPUSH', KEYS[3], '1')
286
+ end
287
+ end
288
+ return #val
289
+ `,
290
+ /** KEYS=[q, q:notify] ARGV=[body]. */
291
+ zsetPush: `
292
+ redis.call('RPUSH', KEYS[1], ARGV[1])
293
+ redis.call('RPUSH', KEYS[2], '1')
294
+ return 1
295
+ `,
296
+ /** KEYS=[q:reserved] ARGV=[member]. */
297
+ zsetAck: `
298
+ return redis.call('ZREM', KEYS[1], ARGV[1])
299
+ `,
300
+ /** KEYS=[q:delayed, q:reserved] ARGV=[member, score, body]. The ZADD is unconditional. */
301
+ zsetRelease: `
302
+ redis.call('ZREM', KEYS[2], ARGV[1])
303
+ redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
304
+ return 1
305
+ `,
306
+ /**
307
+ * KEYS=[q:reserved, dlq] ARGV=[member, body]. Terminal-failure dead-letter, gated on the ZREM like
308
+ * the legacy scripts: a late dead-letter whose reservation was already recovered (back on `<q>`)
309
+ * writes nothing, so the body is never both ready and dead-lettered. A poison reservation uses
310
+ * `zsetDeadLetterPoison` instead (unconditional, as the contract requires).
311
+ */
312
+ zsetDeadLetter: `
313
+ if redis.call('ZREM', KEYS[1], ARGV[1]) == 1 then
314
+ redis.call('RPUSH', KEYS[2], ARGV[2])
315
+ return 1
316
+ end
317
+ return 0
318
+ `,
319
+ /**
320
+ * KEYS=[q:reserved, dlq] ARGV=[member, body]. The poison dead-letter (broker-bindings section 1.1):
321
+ * the SDK MUST write the message to the DLQ and `ZREM` it, unconditionally. Two byte-equal poison
322
+ * bodies reserved together share one `:reserved` member, so a `ZREM` gate would drop the second
323
+ * copy without ever writing it.
324
+ */
325
+ zsetDeadLetterPoison: `
326
+ redis.call('RPUSH', KEYS[2], ARGV[2])
327
+ redis.call('ZREM', KEYS[1], ARGV[1])
328
+ return 1
329
+ `
330
+ };
331
+ function readValue(value) {
332
+ if (!Buffer.isBuffer(value)) return [String(value), void 0];
333
+ const text = value.toString("utf8");
334
+ return [text, Buffer.from(text, "utf8").equals(value) ? void 0 : value];
335
+ }
336
+ function storedValue(reservation) {
337
+ return reservation.rawBytes ?? reservation.raw;
338
+ }
339
+ function zsetMember(reservation) {
340
+ return reservation.reservedBytes ?? reservation.reserved;
341
+ }
342
+ function newReservationToken() {
343
+ return (0, import_node_crypto.randomBytes)(16).toString("hex");
344
+ }
345
+ function validateRetryAfter(value) {
346
+ if (value === void 0) return 90;
347
+ if (value === null) return null;
348
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
349
+ throw new import_core.BabelQueueError(`Invalid retryAfter ${String(value)}: must be a non-negative number of seconds, or null.`);
350
+ }
351
+ return value;
352
+ }
353
+ var RedisQueue = class {
354
+ constructor(client, queue, options = {}) {
79
355
  this.client = client;
80
356
  this.queue = queue;
357
+ this.layout = options.layout ?? "legacy";
358
+ if (this.layout !== "legacy" && this.layout !== "zset") {
359
+ throw new import_core.BabelQueueError(`Unknown Redis layout ${String(this.layout)}: expected "legacy" or "zset".`);
360
+ }
361
+ this.retryAfter = validateRetryAfter(options.retryAfter);
362
+ const batch = options.migrationBatchSize ?? -1;
363
+ this.migrationBatchSize = Number.isFinite(batch) && batch > 0 ? Math.floor(batch) : -1;
364
+ this.clock = options.clock ?? (() => Date.now() / 1e3);
365
+ this.warn = options.onWarn ?? ((message) => console.warn(message));
366
+ this.deadLetterKey = options.deadLetterQueue === void 0 ? `${queue}.dlq` : options.deadLetterQueue;
367
+ this.keys = this.layout === "zset" ? zsetKeys(queue, options.keyPrefix) : { ready: queue, delayed: "", reserved: "", notify: "" };
368
+ this.processingKey = queue + (options.processingSuffix ?? ":processing");
369
+ this.deadlinesKey = `${this.processingKey}:deadlines`;
81
370
  }
82
371
  client;
83
372
  queue;
84
- /** A publisher over `client`, producing to the list named `queue`. */
85
- static create(client, queue) {
86
- return new _RedisPublisher(client, queue);
373
+ layout;
374
+ retryAfter;
375
+ migrationBatchSize;
376
+ deadLetterKey;
377
+ /** The zset keys (zset layout) — also usable to inspect the legacy ready list via `ready`. */
378
+ keys;
379
+ processingKey;
380
+ deadlinesKey;
381
+ clock;
382
+ warn;
383
+ /**
384
+ * Run a script. `binary` (or any `Buffer` argument) selects the client's `evalBuffer`, when it has one,
385
+ * so replies and arguments are exact bytes.
386
+ */
387
+ run(script, keys, args, binary = false) {
388
+ const useBuffer = (binary || args.some((a) => Buffer.isBuffer(a))) && this.client.evalBuffer !== void 0;
389
+ if (useBuffer) return this.client.evalBuffer(script, keys.length, ...keys, ...args);
390
+ return this.client.eval(script, keys.length, ...keys, ...args);
391
+ }
392
+ nowSeconds() {
393
+ return Math.floor(this.clock());
394
+ }
395
+ deadlineScore() {
396
+ return this.retryAfter === null ? "+inf" : String(this.nowSeconds() + this.retryAfter);
397
+ }
398
+ /** Append a body to the ready list (zset: also one `:notify` token). */
399
+ async push(body) {
400
+ if (this.layout === "zset") {
401
+ await this.run(LUA.zsetPush, [this.keys.ready, this.keys.notify], [body]);
402
+ } else {
403
+ await this.client.rpush(this.queue, body);
404
+ }
87
405
  }
88
406
  /**
89
- * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When
90
- * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value
91
- * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is
92
- * pushed byte-for-byte (GR-1, no regression).
407
+ * Schedule a body for `delay` seconds from now: `ZADD <q>:delayed floor(now)+ceil(delay)` with
408
+ * the body byte-for-byte and no `:notify` token. Zset layout only.
409
+ */
410
+ async pushDelayed(body, delay) {
411
+ if (this.layout !== "zset") {
412
+ throw new import_core.BabelQueueError('A delayed publish requires layout "zset"; the legacy layout has no delayed set.');
413
+ }
414
+ if (typeof delay !== "number" || !Number.isFinite(delay) || delay < 0) {
415
+ throw new import_core.BabelQueueError(`Invalid delay ${String(delay)}: must be a non-negative number of seconds.`);
416
+ }
417
+ await this.client.zadd(this.keys.delayed, this.nowSeconds() + Math.ceil(delay), body);
418
+ }
419
+ /** Zset: move due `:delayed` items, then due `:reserved` ones (skipped if `retryAfter` is null). */
420
+ async migrate() {
421
+ if (this.layout !== "zset") return 0;
422
+ let moved = await this.migrateFrom(this.keys.delayed);
423
+ if (this.retryAfter !== null) moved += await this.migrateFrom(this.keys.reserved);
424
+ return moved;
425
+ }
426
+ async migrateFrom(source) {
427
+ const moved = await this.run(
428
+ LUA.zsetMigrate,
429
+ [source, this.keys.ready, this.keys.notify],
430
+ [this.nowSeconds(), this.migrationBatchSize]
431
+ );
432
+ return Number(moved);
433
+ }
434
+ /** Legacy: move expired reservations back onto the ready list (body untouched). */
435
+ async sweep() {
436
+ if (this.layout !== "legacy") return 0;
437
+ const moved = await this.run(
438
+ LUA.legacySweep,
439
+ [this.queue, this.processingKey, this.deadlinesKey],
440
+ [this.nowSeconds(), this.migrationBatchSize]
441
+ );
442
+ return Number(moved);
443
+ }
444
+ /** Recovery and migration for the layout; runs before every reserve. */
445
+ async maintain() {
446
+ return this.layout === "zset" ? this.migrate() : this.sweep();
447
+ }
448
+ /** One non-blocking reserve, preceded by the layout's migrate / sweep. */
449
+ async reserve() {
450
+ if (this.client.evalBuffer === void 0) {
451
+ throw new import_core.BabelQueueError(
452
+ "The Redis client must provide evalBuffer (ioredis does) to consume: invalid UTF-8 is poison and its exact bytes must be read (broker-bindings.md section 1.1)."
453
+ );
454
+ }
455
+ await this.maintain();
456
+ return this.layout === "zset" ? this.reserveZset() : this.reserveLegacy();
457
+ }
458
+ async reserveLegacy() {
459
+ const token = newReservationToken();
460
+ const reply = await this.run(
461
+ LUA.legacyReserve,
462
+ [this.queue, this.processingKey, this.deadlinesKey],
463
+ [this.deadlineScore(), token],
464
+ true
465
+ );
466
+ if (typeof reply !== "string" && !Buffer.isBuffer(reply)) return { outcome: "empty" };
467
+ const [raw, rawBytes] = readValue(reply);
468
+ const reservation = { layout: "legacy", raw, token };
469
+ if (rawBytes) reservation.rawBytes = rawBytes;
470
+ return { outcome: "delivered", raw, reservation };
471
+ }
472
+ async reserveZset() {
473
+ const reply = await this.run(
474
+ LUA.zsetReserve,
475
+ [this.keys.ready, this.keys.reserved, this.keys.notify],
476
+ [this.deadlineScore()],
477
+ true
478
+ );
479
+ if (!Array.isArray(reply) || reply.length < 2) return { outcome: "empty" };
480
+ const [raw, rawBytes] = readValue(reply[0]);
481
+ const [reserved, reservedBytes] = readValue(reply[1]);
482
+ const reservation = { layout: "zset", raw, reserved };
483
+ if (rawBytes) reservation.rawBytes = rawBytes;
484
+ if (reservedBytes) reservation.reservedBytes = reservedBytes;
485
+ const poison = reply.length >= 3 ? readValue(reply[2])[0] === "1" : raw === reserved;
486
+ if (poison) return { outcome: "poison", raw, reservation: { ...reservation, poison: true } };
487
+ return { outcome: "delivered", raw, reservation, attempts: attemptsOf(raw) + 1 };
488
+ }
489
+ /** Acknowledge. Legacy: false (plus a warning) if the reservation had already expired. */
490
+ async ack(reservation) {
491
+ if (reservation.layout === "zset") {
492
+ return Number(await this.run(LUA.zsetAck, [this.keys.reserved], [zsetMember(reservation)])) === 1;
493
+ }
494
+ const live = await this.run(LUA.legacyAck, [this.processingKey, this.deadlinesKey], [reservation.token, storedValue(reservation)]);
495
+ return this.liveOrWarn(live, reservation);
496
+ }
497
+ /**
498
+ * Give a reservation back. Legacy: the body (default: `raw`) goes to the ready list, gated on the
499
+ * deadline member; `delay` is ignored. Zset: one script `ZREM`s the `:reserved` member and `ZADD`s
500
+ * `<q>:delayed` at `floor(now)+ceil(delay)` with the body (default: `raw` re-encoded with
501
+ * `attempts + 1`, every other key preserved).
502
+ */
503
+ async release(reservation, delay = 0, body) {
504
+ if (reservation.layout === "zset") {
505
+ if (typeof delay !== "number" || !Number.isFinite(delay) || delay < 0) {
506
+ throw new import_core.BabelQueueError(`Invalid delay ${String(delay)}: must be a non-negative number of seconds.`);
507
+ }
508
+ const bumped = body ?? bumpAttempts(reservation.raw);
509
+ await this.run(
510
+ LUA.zsetRelease,
511
+ [this.keys.delayed, this.keys.reserved],
512
+ [zsetMember(reservation), this.nowSeconds() + Math.ceil(delay), bumped]
513
+ );
514
+ return true;
515
+ }
516
+ const live = await this.run(
517
+ LUA.legacyRelease,
518
+ [this.queue, this.processingKey, this.deadlinesKey],
519
+ [reservation.token, storedValue(reservation), body ?? storedValue(reservation)]
520
+ );
521
+ return this.liveOrWarn(live, reservation);
522
+ }
523
+ /** Write `body` (default: `raw`) to the dead-letter list and settle the reservation. */
524
+ async deadLetter(reservation, body) {
525
+ const payload = body ?? reservation.raw;
526
+ if (this.deadLetterKey === null) return this.ack(reservation);
527
+ if (reservation.layout === "zset" && reservation.poison === true) {
528
+ await this.run(
529
+ LUA.zsetDeadLetterPoison,
530
+ [this.keys.reserved, this.deadLetterKey],
531
+ [zsetMember(reservation), payload]
532
+ );
533
+ return true;
534
+ }
535
+ if (reservation.layout === "zset") {
536
+ const settled = await this.run(
537
+ LUA.zsetDeadLetter,
538
+ [this.keys.reserved, this.deadLetterKey],
539
+ [zsetMember(reservation), payload]
540
+ );
541
+ if (Number(settled) === 1) return true;
542
+ this.warn(
543
+ `reservation expired: queue "${this.queue}" reservation was already recovered or settled; nothing dead-lettered`
544
+ );
545
+ return false;
546
+ }
547
+ const live = await this.run(
548
+ LUA.legacyDeadLetter,
549
+ [this.processingKey, this.deadlinesKey, this.deadLetterKey],
550
+ [reservation.token, storedValue(reservation), payload]
551
+ );
552
+ return this.liveOrWarn(live, reservation);
553
+ }
554
+ liveOrWarn(reply, reservation) {
555
+ if (Number(reply) === 1) return true;
556
+ this.warn(
557
+ `reservation expired: queue "${this.queue}" token ${reservation.token} was already swept or settled; left the queue untouched`
558
+ );
559
+ return false;
560
+ }
561
+ };
562
+ function attemptsOf(raw) {
563
+ try {
564
+ const decoded = JSON.parse(raw);
565
+ const attempts = decoded?.attempts;
566
+ return typeof attempts === "number" ? attempts : 0;
567
+ } catch {
568
+ return 0;
569
+ }
570
+ }
571
+ function bumpAttempts(raw) {
572
+ let decoded;
573
+ try {
574
+ decoded = JSON.parse(raw);
575
+ } catch {
576
+ return raw;
577
+ }
578
+ if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) return raw;
579
+ const scan = scanTopLevel(raw);
580
+ const spans = scan.members.filter((member) => member.key === "attempts");
581
+ const last = spans[spans.length - 1];
582
+ const current = last === void 0 ? null : raw.slice(last.start, last.end);
583
+ const next = current !== null && NUMBER_LITERAL.test(current) ? incrementNumberLiteral(current) : "1";
584
+ if (last === void 0) {
585
+ const member = `"attempts":${next}`;
586
+ return `${raw.slice(0, scan.close)}${scan.members.length > 0 ? "," : ""}${member}${raw.slice(scan.close)}`;
587
+ }
588
+ let result = raw;
589
+ for (const span of [...spans].reverse()) result = `${result.slice(0, span.start)}${next}${result.slice(span.end)}`;
590
+ return result;
591
+ }
592
+ var NUMBER_LITERAL = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
593
+ function incrementNumberLiteral(literal) {
594
+ if (/^-?\d+$/.test(literal)) return (BigInt(literal) + 1n).toString();
595
+ return String(Number(literal) + 1);
596
+ }
597
+ function scanTopLevel(text) {
598
+ const members = [];
599
+ const skipSpace = (from) => {
600
+ let i2 = from;
601
+ while (i2 < text.length && " \n\r".includes(text.charAt(i2))) i2++;
602
+ return i2;
603
+ };
604
+ const stringEnd = (from) => {
605
+ let i2 = from + 1;
606
+ while (text.charAt(i2) !== '"') i2 += text.charAt(i2) === "\\" ? 2 : 1;
607
+ return i2 + 1;
608
+ };
609
+ let i = skipSpace(text.indexOf("{") + 1);
610
+ while (text.charAt(i) !== "}") {
611
+ if (text.charAt(i) === ",") i = skipSpace(i + 1);
612
+ const keyEnd = stringEnd(i);
613
+ const key = JSON.parse(text.slice(i, keyEnd));
614
+ i = skipSpace(skipSpace(keyEnd) + 1);
615
+ const start = i;
616
+ let depth = 0;
617
+ while (i < text.length) {
618
+ const c = text.charAt(i);
619
+ if (c === '"') {
620
+ i = stringEnd(i);
621
+ continue;
622
+ }
623
+ if (c === "{" || c === "[") depth++;
624
+ else if (c === "}" || c === "]") {
625
+ if (depth === 0) break;
626
+ depth--;
627
+ } else if (c === "," && depth === 0) break;
628
+ i++;
629
+ }
630
+ let end = i;
631
+ while (end > start && " \n\r".includes(text.charAt(end - 1))) end--;
632
+ members.push({ key, start, end });
633
+ i = skipSpace(i);
634
+ }
635
+ return { members, close: i };
636
+ }
637
+ var RedisPublisher = class _RedisPublisher {
638
+ constructor(client, queue, options) {
639
+ this.queue = queue;
640
+ this.transport = new RedisQueue(client, queue, options);
641
+ }
642
+ queue;
643
+ transport;
644
+ /** A publisher over `client`, producing to the queue named `queue`. */
645
+ static create(client, queue, options = {}) {
646
+ return new _RedisPublisher(client, queue, options);
647
+ }
648
+ /**
649
+ * Build + push the canonical envelope; returns the message id (`meta.id`). Legacy layout: a
650
+ * non-empty `options.headers` pushes a transport-owned frame (ADR-0028), otherwise the bare
651
+ * envelope byte-for-byte. Zset layout: always the bare envelope (A3); with `options.delay > 0`
652
+ * it goes to `<q>:delayed` instead of the ready list.
93
653
  */
94
654
  async publish(urn, data, options = {}) {
655
+ const delay = options.delay ?? 0;
656
+ if (typeof delay !== "number" || !Number.isFinite(delay) || delay < 0) {
657
+ throw new import_core.BabelQueueError(`Invalid delay ${String(delay)}: must be a non-negative number of seconds.`);
658
+ }
659
+ if (delay > 0 && this.transport.layout !== "zset") {
660
+ throw new import_core.BabelQueueError('A delayed publish requires layout "zset"; the legacy layout has no delayed set.');
661
+ }
95
662
  const envelope = import_core.EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });
96
- await this.client.rpush(this.queue, frameValue(redisValue(envelope), options.headers));
663
+ const body = redisValue(envelope);
664
+ if (this.transport.layout === "zset") {
665
+ if (delay > 0) await this.transport.pushDelayed(body, delay);
666
+ else await this.transport.push(body);
667
+ } else {
668
+ await this.transport.push(frameValue(body, options.headers));
669
+ }
97
670
  return envelope.meta.id;
98
671
  }
99
672
  };
673
+ var SLEEP_START_MS = 100;
674
+ var SLEEP_CAP_MS = 1e3;
675
+ function sleep(ms) {
676
+ return new Promise((resolve) => setTimeout(resolve, ms));
677
+ }
100
678
  var RedisConsumer = class {
101
679
  constructor(client, queue, handlers, options = {}) {
102
680
  this.client = client;
103
681
  this.queue = queue;
104
682
  this.handlers = handlers;
105
683
  this.options = options;
106
- this.processing = queue + (options.processingSuffix ?? ":processing");
684
+ this.transport = new RedisQueue(client, queue, options);
107
685
  this.blockTimeout = options.blockTimeout ?? 5;
108
686
  this.maxTries = options.maxTries ?? 3;
109
- this.dlq = options.deadLetterQueue === void 0 ? `${queue}.dlq` : options.deadLetterQueue;
110
687
  this.unknownUrn = options.unknownUrn ?? import_core.UnknownUrnStrategy.FAIL;
111
688
  }
112
689
  client;
113
690
  queue;
114
691
  handlers;
115
692
  options;
116
- processing;
693
+ transport;
117
694
  blockTimeout;
118
695
  maxTries;
119
- dlq;
120
696
  unknownUrn;
697
+ /** The underlying transport (for diagnostics and tests). */
698
+ get redisQueue() {
699
+ return this.transport;
700
+ }
121
701
  /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
122
702
  async poll() {
123
- const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);
124
- if (raw == null) return false;
125
- await this.handle(raw);
126
- return true;
703
+ const startedAt = Date.now();
704
+ let wait = SLEEP_START_MS;
705
+ for (; ; ) {
706
+ const result = await this.transport.reserve();
707
+ if (result.outcome !== "empty") {
708
+ await this.dispatch(result);
709
+ return true;
710
+ }
711
+ const remainingMs = this.blockTimeout * 1e3 - (Date.now() - startedAt);
712
+ if (remainingMs <= 0) return false;
713
+ if (this.transport.layout === "zset") {
714
+ await this.client.blpop(this.transport.keys.notify, Math.min(1, remainingMs / 1e3));
715
+ } else {
716
+ await sleep(Math.min(wait, remainingMs));
717
+ wait = Math.min(wait * 2, SLEEP_CAP_MS);
718
+ }
719
+ }
127
720
  }
128
721
  /** Poll while `shouldContinue` returns true. */
129
722
  async run(shouldContinue) {
@@ -131,87 +724,131 @@ var RedisConsumer = class {
131
724
  await this.poll();
132
725
  }
133
726
  }
727
+ async dispatch(result) {
728
+ const reservation = result.reservation;
729
+ if (result.outcome === "poison" || reservation.rawBytes !== void 0) {
730
+ const error = new import_core.BabelQueueError("Rejected an undecodable BabelQueue message from Redis.");
731
+ this.report(error, null, reservation.raw);
732
+ await this.transport.deadLetter(reservation, this.poisonRecord(reservation, error));
733
+ return;
734
+ }
735
+ await this.handle(reservation);
736
+ }
134
737
  /**
135
- * Route + settle one reserved element. Exposed for testing.
738
+ * Route + settle one reserved message. Exposed for testing.
136
739
  *
137
- * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header
138
- * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim
139
- * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced
140
- * to the handler. Settling/dead-lettering still operate on the original stored value.
740
+ * Legacy layout: `reservation.raw` may be a transport-owned header frame or a bare envelope; it is
741
+ * unframed first so the envelope is decoded from the verbatim wire body while the carried headers
742
+ * (e.g. a `traceparent`, ADR-0028) reach the handler. The zset layout is never framed (A3).
141
743
  */
142
- async handle(raw) {
143
- const [body, headers] = unframe(raw);
744
+ async handle(reservation) {
745
+ const raw = reservation.raw;
746
+ if (reservation.rawBytes !== void 0) {
747
+ const error = new import_core.BabelQueueError("Rejected a non-UTF-8 BabelQueue message from Redis.");
748
+ this.report(error, null, raw);
749
+ await this.transport.deadLetter(reservation, this.poisonRecord(reservation, error));
750
+ return;
751
+ }
752
+ const [body, headers] = reservation.layout === "legacy" ? unframe(raw) : [raw, {}];
144
753
  const decoded = import_core.EnvelopeCodec.decode(body);
145
754
  if (!import_core.EnvelopeCodec.accepts(decoded)) {
146
- this.report(new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from Redis."), decoded, raw);
147
- await this.deadLetterRaw(raw);
148
- await this.settle(raw);
755
+ const error = new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from Redis.");
756
+ this.report(error, decoded, raw);
757
+ await this.transport.deadLetter(reservation, this.poisonRecord(reservation, error));
149
758
  return;
150
759
  }
151
760
  const envelope = decoded;
152
761
  const urn = import_core.EnvelopeCodec.urn(envelope);
153
762
  const handler = this.handlers[urn];
154
763
  if (!handler) {
155
- await this.onUnknownUrn(envelope, urn, raw);
764
+ await this.onUnknownUrn(envelope, urn, reservation);
156
765
  return;
157
766
  }
158
767
  try {
159
768
  await handler(envelope, raw, headers);
160
- await this.settle(raw);
161
769
  } catch (error) {
162
770
  this.report(error, envelope, raw);
163
- await this.retryOrDeadLetter(envelope, raw, error);
771
+ await this.retryOrDeadLetter(envelope, reservation, error);
772
+ return;
164
773
  }
774
+ await this.transport.ack(reservation);
165
775
  }
166
- async onUnknownUrn(envelope, urn, raw) {
776
+ async onUnknownUrn(envelope, urn, reservation) {
167
777
  switch (this.unknownUrn) {
168
778
  case import_core.UnknownUrnStrategy.DELETE:
169
- await this.settle(raw);
779
+ await this.transport.ack(reservation);
170
780
  return;
171
781
  case import_core.UnknownUrnStrategy.DEAD_LETTER:
172
- await this.deadLetter(envelope, "unknown_urn");
173
- await this.settle(raw);
782
+ await this.deadLetter(envelope, reservation, "unknown_urn");
174
783
  return;
175
784
  case import_core.UnknownUrnStrategy.RELEASE:
176
- await this.requeue(envelope);
177
- await this.settle(raw);
785
+ await this.requeue(envelope, reservation);
178
786
  return;
179
787
  default: {
180
788
  const error = new import_core.UnknownUrnError(urn);
181
- this.report(error, envelope, raw);
182
- throw error;
789
+ this.report(error, envelope, reservation.raw);
790
+ await this.retryOrDeadLetter(envelope, reservation, error);
791
+ return;
183
792
  }
184
793
  }
185
794
  }
186
- async retryOrDeadLetter(envelope, raw, error) {
187
- if ((envelope.attempts ?? 0) + 1 < this.maxTries) {
188
- await this.requeue(envelope);
795
+ async retryOrDeadLetter(envelope, reservation, error) {
796
+ const counted = (envelope.attempts ?? 0) + 1;
797
+ if (counted < this.maxTries) {
798
+ await this.requeue(envelope, reservation);
189
799
  } else {
190
- await this.deadLetter(envelope, "failed", error);
800
+ await this.deadLetter(
801
+ envelope,
802
+ reservation,
803
+ error instanceof import_core.UnknownUrnError ? "unknown_urn" : "failed",
804
+ error,
805
+ counted
806
+ );
191
807
  }
192
- await this.settle(raw);
193
808
  }
194
- /** Republish with `attempts + 1` onto the work list (at-least-once retry). */
195
- async requeue(envelope) {
809
+ /** Give the message back with `attempts + 1` (at-least-once retry). */
810
+ async requeue(envelope, reservation) {
811
+ if (reservation.layout === "zset") {
812
+ await this.transport.release(reservation, 0);
813
+ return;
814
+ }
196
815
  const bumped = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };
197
- await this.client.rpush(this.queue, redisValue(bumped));
816
+ await this.transport.release(reservation, 0, redisValue(bumped));
198
817
  }
199
- async deadLetter(envelope, reason, error) {
200
- if (!this.dlq) return;
201
- const annotated = (0, import_core.annotate)(envelope, reason, envelope.meta?.queue ?? this.queue, {
202
- attempts: envelope.attempts ?? 0,
818
+ /**
819
+ * Write the annotated envelope to the DLQ. `counted` is given on the exhaustion path (retries used up): the
820
+ * delivered attempts plus one for the current delivery, written to both `dead_letter.attempts` and the
821
+ * record's top-level `attempts`, like Go and Python. A direct dead-letter that makes no retry decision
822
+ * (`unknownUrn: dead_letter`) omits it and records the delivered value, which is informational.
823
+ */
824
+ async deadLetter(envelope, reservation, reason, error, counted) {
825
+ const attempts = counted ?? envelope.attempts ?? 0;
826
+ const annotated = (0, import_core.annotate)({ ...envelope, attempts }, reason, envelope.meta?.queue ?? this.queue, {
827
+ attempts,
203
828
  error: error instanceof Error ? error.message : null,
204
829
  exception: error instanceof Error ? error.name : null
205
830
  });
206
- await this.client.rpush(this.dlq, redisValue(annotated));
207
- }
208
- async deadLetterRaw(raw) {
209
- if (!this.dlq) return;
210
- await this.client.rpush(this.dlq, raw);
831
+ await this.transport.deadLetter(reservation, redisValue(annotated));
211
832
  }
212
- /** Remove the element from the reservation list (acknowledge). */
213
- async settle(raw) {
214
- await this.client.lrem(this.processing, 1, raw);
833
+ /**
834
+ * The poison DLQ record of a reservation (ADR-0035, broker-bindings section 1.1): the delivered body (a legacy
835
+ * header frame is unwrapped first) with a `dead_letter` block of reason `poison`. A strict JSON object is
836
+ * spliced, anything else is wrapped as `{"raw", "dead_letter"}`.
837
+ *
838
+ * A body that is not valid UTF-8 is read as exact bytes (`evalBuffer`, see {@link RedisClient.evalBuffer}) and
839
+ * kept as stored (no frame is peeled off it), so it lands in the base64 wrapper, never as U+FFFD text.
840
+ */
841
+ poisonRecord(reservation, cause) {
842
+ const exact = reservation.rawBytes;
843
+ const [body] = exact !== void 0 || reservation.layout !== "legacy" ? [reservation.raw] : unframe(reservation.raw);
844
+ const counted = attemptsOf(body);
845
+ const attempts = Number.isSafeInteger(counted) && counted >= 0 ? counted : 0;
846
+ const deadLetter = (0, import_core.annotate)({ attempts }, "poison", this.queue, {
847
+ attempts,
848
+ error: cause.message,
849
+ exception: cause.name
850
+ }).dead_letter;
851
+ return buildPoisonRecord(exact ?? Buffer.from(body, "utf8"), JSON.stringify(deadLetter));
215
852
  }
216
853
  report(error, envelope, raw) {
217
854
  this.options.onError?.(error, envelope, raw);
@@ -220,11 +857,16 @@ var RedisConsumer = class {
220
857
  // Annotate the CommonJS export names for ESM import in node:
221
858
  0 && (module.exports = {
222
859
  FRAME_KEY,
860
+ LUA,
223
861
  RedisConsumer,
224
862
  RedisPublisher,
863
+ RedisQueue,
864
+ ZSET_KEY_PREFIX,
225
865
  frameValue,
226
866
  headersOf,
867
+ newReservationToken,
227
868
  redisValue,
228
- unframe
869
+ unframe,
870
+ zsetKeys
229
871
  });
230
872
  //# sourceMappingURL=index.cjs.map