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