@babelqueue/redis 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -14
- package/dist/index.cjs +742 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +313 -43
- package/dist/index.d.ts +313 -43
- package/dist/index.js +732 -51
- package/dist/index.js.map +1 -1
- package/package.json +18 -6
package/dist/index.cjs
CHANGED
|
@@ -20,60 +20,703 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
FRAME_KEY: () => FRAME_KEY,
|
|
24
|
+
LUA: () => LUA,
|
|
23
25
|
RedisConsumer: () => RedisConsumer,
|
|
24
26
|
RedisPublisher: () => RedisPublisher,
|
|
25
|
-
|
|
27
|
+
RedisQueue: () => RedisQueue,
|
|
28
|
+
ZSET_KEY_PREFIX: () => ZSET_KEY_PREFIX,
|
|
29
|
+
frameValue: () => frameValue,
|
|
30
|
+
headersOf: () => headersOf,
|
|
31
|
+
newReservationToken: () => newReservationToken,
|
|
32
|
+
redisValue: () => redisValue,
|
|
33
|
+
unframe: () => unframe,
|
|
34
|
+
zsetKeys: () => zsetKeys
|
|
26
35
|
});
|
|
27
36
|
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
var import_node_crypto = require("crypto");
|
|
28
38
|
var import_core = require("@babelqueue/core");
|
|
39
|
+
|
|
40
|
+
// src/headers.ts
|
|
41
|
+
function sanitizeHeaders(headers) {
|
|
42
|
+
const out = {};
|
|
43
|
+
if (!headers) return out;
|
|
44
|
+
for (const key of Object.keys(headers)) {
|
|
45
|
+
const value = headers[key];
|
|
46
|
+
if (key === "" || value == null || value === "") continue;
|
|
47
|
+
out[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
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
|
+
|
|
155
|
+
// src/index.ts
|
|
29
156
|
function redisValue(envelope) {
|
|
30
157
|
return import_core.EnvelopeCodec.encode(envelope);
|
|
31
158
|
}
|
|
32
|
-
var
|
|
33
|
-
|
|
159
|
+
var FRAME_KEY = "__bq_frame";
|
|
160
|
+
var FRAME_VERSION = 1;
|
|
161
|
+
function frameValue(body, headers) {
|
|
162
|
+
const clean = sanitizeHeaders(headers);
|
|
163
|
+
if (Object.keys(clean).length === 0) return body;
|
|
164
|
+
const frame = { [FRAME_KEY]: FRAME_VERSION, headers: clean, body };
|
|
165
|
+
return JSON.stringify(frame);
|
|
166
|
+
}
|
|
167
|
+
function unframe(value) {
|
|
168
|
+
if (value === "" || value[0] !== "{" || !value.includes(`"${FRAME_KEY}"`)) {
|
|
169
|
+
return [value, {}];
|
|
170
|
+
}
|
|
171
|
+
let decoded;
|
|
172
|
+
try {
|
|
173
|
+
decoded = JSON.parse(value);
|
|
174
|
+
} catch {
|
|
175
|
+
return [value, {}];
|
|
176
|
+
}
|
|
177
|
+
if (decoded == null || typeof decoded !== "object" || !(FRAME_KEY in decoded) || !decoded[FRAME_KEY] || typeof decoded.body !== "string") {
|
|
178
|
+
return [value, {}];
|
|
179
|
+
}
|
|
180
|
+
const frame = decoded;
|
|
181
|
+
return [frame.body, sanitizeHeaders(frame.headers)];
|
|
182
|
+
}
|
|
183
|
+
function headersOf(value) {
|
|
184
|
+
return unframe(value)[1];
|
|
185
|
+
}
|
|
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 = {}) {
|
|
34
355
|
this.client = client;
|
|
35
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`;
|
|
36
370
|
}
|
|
37
371
|
client;
|
|
38
372
|
queue;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
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);
|
|
42
647
|
}
|
|
43
|
-
/**
|
|
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.
|
|
653
|
+
*/
|
|
44
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
|
+
}
|
|
45
662
|
const envelope = import_core.EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });
|
|
46
|
-
|
|
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
|
+
}
|
|
47
670
|
return envelope.meta.id;
|
|
48
671
|
}
|
|
49
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
|
+
}
|
|
50
678
|
var RedisConsumer = class {
|
|
51
679
|
constructor(client, queue, handlers, options = {}) {
|
|
52
680
|
this.client = client;
|
|
53
681
|
this.queue = queue;
|
|
54
682
|
this.handlers = handlers;
|
|
55
683
|
this.options = options;
|
|
56
|
-
this.
|
|
684
|
+
this.transport = new RedisQueue(client, queue, options);
|
|
57
685
|
this.blockTimeout = options.blockTimeout ?? 5;
|
|
58
686
|
this.maxTries = options.maxTries ?? 3;
|
|
59
|
-
this.dlq = options.deadLetterQueue === void 0 ? `${queue}.dlq` : options.deadLetterQueue;
|
|
60
687
|
this.unknownUrn = options.unknownUrn ?? import_core.UnknownUrnStrategy.FAIL;
|
|
61
688
|
}
|
|
62
689
|
client;
|
|
63
690
|
queue;
|
|
64
691
|
handlers;
|
|
65
692
|
options;
|
|
66
|
-
|
|
693
|
+
transport;
|
|
67
694
|
blockTimeout;
|
|
68
695
|
maxTries;
|
|
69
|
-
dlq;
|
|
70
696
|
unknownUrn;
|
|
697
|
+
/** The underlying transport (for diagnostics and tests). */
|
|
698
|
+
get redisQueue() {
|
|
699
|
+
return this.transport;
|
|
700
|
+
}
|
|
71
701
|
/** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
|
|
72
702
|
async poll() {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|
|
77
720
|
}
|
|
78
721
|
/** Poll while `shouldContinue` returns true. */
|
|
79
722
|
async run(shouldContinue) {
|
|
@@ -81,79 +724,117 @@ var RedisConsumer = class {
|
|
|
81
724
|
await this.poll();
|
|
82
725
|
}
|
|
83
726
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Route + settle one reserved message. Exposed for testing.
|
|
739
|
+
*
|
|
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).
|
|
743
|
+
*/
|
|
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, {}];
|
|
753
|
+
const decoded = import_core.EnvelopeCodec.decode(body);
|
|
87
754
|
if (!import_core.EnvelopeCodec.accepts(decoded)) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
await this.
|
|
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));
|
|
91
758
|
return;
|
|
92
759
|
}
|
|
93
760
|
const envelope = decoded;
|
|
94
761
|
const urn = import_core.EnvelopeCodec.urn(envelope);
|
|
95
762
|
const handler = this.handlers[urn];
|
|
96
763
|
if (!handler) {
|
|
97
|
-
await this.onUnknownUrn(envelope, urn,
|
|
764
|
+
await this.onUnknownUrn(envelope, urn, reservation);
|
|
98
765
|
return;
|
|
99
766
|
}
|
|
100
767
|
try {
|
|
101
|
-
await handler(envelope, raw);
|
|
102
|
-
await this.settle(raw);
|
|
768
|
+
await handler(envelope, raw, headers);
|
|
103
769
|
} catch (error) {
|
|
104
770
|
this.report(error, envelope, raw);
|
|
105
|
-
await this.retryOrDeadLetter(envelope,
|
|
771
|
+
await this.retryOrDeadLetter(envelope, reservation, error);
|
|
772
|
+
return;
|
|
106
773
|
}
|
|
774
|
+
await this.transport.ack(reservation);
|
|
107
775
|
}
|
|
108
|
-
async onUnknownUrn(envelope, urn,
|
|
776
|
+
async onUnknownUrn(envelope, urn, reservation) {
|
|
109
777
|
switch (this.unknownUrn) {
|
|
110
778
|
case import_core.UnknownUrnStrategy.DELETE:
|
|
111
|
-
await this.
|
|
779
|
+
await this.transport.ack(reservation);
|
|
112
780
|
return;
|
|
113
781
|
case import_core.UnknownUrnStrategy.DEAD_LETTER:
|
|
114
|
-
await this.deadLetter(envelope, "unknown_urn");
|
|
115
|
-
await this.settle(raw);
|
|
782
|
+
await this.deadLetter(envelope, reservation, "unknown_urn");
|
|
116
783
|
return;
|
|
117
784
|
case import_core.UnknownUrnStrategy.RELEASE:
|
|
118
|
-
await this.requeue(envelope);
|
|
119
|
-
await this.settle(raw);
|
|
785
|
+
await this.requeue(envelope, reservation);
|
|
120
786
|
return;
|
|
121
787
|
default: {
|
|
122
788
|
const error = new import_core.UnknownUrnError(urn);
|
|
123
|
-
this.report(error, envelope, raw);
|
|
124
|
-
|
|
789
|
+
this.report(error, envelope, reservation.raw);
|
|
790
|
+
await this.retryOrDeadLetter(envelope, reservation, error);
|
|
791
|
+
return;
|
|
125
792
|
}
|
|
126
793
|
}
|
|
127
794
|
}
|
|
128
|
-
async retryOrDeadLetter(envelope,
|
|
795
|
+
async retryOrDeadLetter(envelope, reservation, error) {
|
|
129
796
|
if ((envelope.attempts ?? 0) + 1 < this.maxTries) {
|
|
130
|
-
await this.requeue(envelope);
|
|
797
|
+
await this.requeue(envelope, reservation);
|
|
131
798
|
} else {
|
|
132
|
-
await this.deadLetter(envelope, "failed", error);
|
|
799
|
+
await this.deadLetter(envelope, reservation, error instanceof import_core.UnknownUrnError ? "unknown_urn" : "failed", error);
|
|
133
800
|
}
|
|
134
|
-
await this.settle(raw);
|
|
135
801
|
}
|
|
136
|
-
/**
|
|
137
|
-
async requeue(envelope) {
|
|
802
|
+
/** Give the message back with `attempts + 1` (at-least-once retry). */
|
|
803
|
+
async requeue(envelope, reservation) {
|
|
804
|
+
if (reservation.layout === "zset") {
|
|
805
|
+
await this.transport.release(reservation, 0);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
138
808
|
const bumped = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };
|
|
139
|
-
await this.
|
|
809
|
+
await this.transport.release(reservation, 0, redisValue(bumped));
|
|
140
810
|
}
|
|
141
|
-
async deadLetter(envelope, reason, error) {
|
|
142
|
-
if (!this.dlq) return;
|
|
811
|
+
async deadLetter(envelope, reservation, reason, error) {
|
|
143
812
|
const annotated = (0, import_core.annotate)(envelope, reason, envelope.meta?.queue ?? this.queue, {
|
|
144
813
|
attempts: envelope.attempts ?? 0,
|
|
145
814
|
error: error instanceof Error ? error.message : null,
|
|
146
815
|
exception: error instanceof Error ? error.name : null
|
|
147
816
|
});
|
|
148
|
-
await this.
|
|
149
|
-
}
|
|
150
|
-
async deadLetterRaw(raw) {
|
|
151
|
-
if (!this.dlq) return;
|
|
152
|
-
await this.client.rpush(this.dlq, raw);
|
|
817
|
+
await this.transport.deadLetter(reservation, redisValue(annotated));
|
|
153
818
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
819
|
+
/**
|
|
820
|
+
* The poison DLQ record of a reservation (ADR-0035, broker-bindings section 1.1): the delivered body (a legacy
|
|
821
|
+
* header frame is unwrapped first) with a `dead_letter` block of reason `poison`. A strict JSON object is
|
|
822
|
+
* spliced, anything else is wrapped as `{"raw", "dead_letter"}`.
|
|
823
|
+
*
|
|
824
|
+
* A body that is not valid UTF-8 is read as exact bytes (`evalBuffer`, see {@link RedisClient.evalBuffer}) and
|
|
825
|
+
* kept as stored (no frame is peeled off it), so it lands in the base64 wrapper, never as U+FFFD text.
|
|
826
|
+
*/
|
|
827
|
+
poisonRecord(reservation, cause) {
|
|
828
|
+
const exact = reservation.rawBytes;
|
|
829
|
+
const [body] = exact !== void 0 || reservation.layout !== "legacy" ? [reservation.raw] : unframe(reservation.raw);
|
|
830
|
+
const counted = attemptsOf(body);
|
|
831
|
+
const attempts = Number.isSafeInteger(counted) && counted >= 0 ? counted : 0;
|
|
832
|
+
const deadLetter = (0, import_core.annotate)({ attempts }, "poison", this.queue, {
|
|
833
|
+
attempts,
|
|
834
|
+
error: cause.message,
|
|
835
|
+
exception: cause.name
|
|
836
|
+
}).dead_letter;
|
|
837
|
+
return buildPoisonRecord(exact ?? Buffer.from(body, "utf8"), JSON.stringify(deadLetter));
|
|
157
838
|
}
|
|
158
839
|
report(error, envelope, raw) {
|
|
159
840
|
this.options.onError?.(error, envelope, raw);
|
|
@@ -161,8 +842,17 @@ var RedisConsumer = class {
|
|
|
161
842
|
};
|
|
162
843
|
// Annotate the CommonJS export names for ESM import in node:
|
|
163
844
|
0 && (module.exports = {
|
|
845
|
+
FRAME_KEY,
|
|
846
|
+
LUA,
|
|
164
847
|
RedisConsumer,
|
|
165
848
|
RedisPublisher,
|
|
166
|
-
|
|
849
|
+
RedisQueue,
|
|
850
|
+
ZSET_KEY_PREFIX,
|
|
851
|
+
frameValue,
|
|
852
|
+
headersOf,
|
|
853
|
+
newReservationToken,
|
|
854
|
+
redisValue,
|
|
855
|
+
unframe,
|
|
856
|
+
zsetKeys
|
|
167
857
|
});
|
|
168
858
|
//# sourceMappingURL=index.cjs.map
|