@babelqueue/kafka 1.1.3 → 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/README.md +8 -0
- package/dist/index.cjs +502 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -3
- package/dist/index.d.ts +131 -3
- package/dist/index.js +501 -20
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,266 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { annotate, BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, UnknownUrnStrategy } from "@babelqueue/core";
|
|
3
3
|
|
|
4
|
+
// src/envelope-shape.ts
|
|
5
|
+
var MAX_DEPTH = 1e4;
|
|
6
|
+
var INT64_MIN = -(2n ** 63n);
|
|
7
|
+
var INT64_MAX = 2n ** 63n - 1n;
|
|
8
|
+
var META = {
|
|
9
|
+
id: "string",
|
|
10
|
+
queue: "string",
|
|
11
|
+
lang: "string",
|
|
12
|
+
schema_version: "integer",
|
|
13
|
+
created_at: "integer"
|
|
14
|
+
};
|
|
15
|
+
var DEAD_LETTER = {
|
|
16
|
+
reason: "string",
|
|
17
|
+
error: "string",
|
|
18
|
+
exception: "string",
|
|
19
|
+
original_queue: "string",
|
|
20
|
+
lang: "string",
|
|
21
|
+
failed_at: "integer",
|
|
22
|
+
attempts: "integer"
|
|
23
|
+
};
|
|
24
|
+
var TOP = {
|
|
25
|
+
job: "string",
|
|
26
|
+
trace_id: "string",
|
|
27
|
+
attempts: "integer",
|
|
28
|
+
data: "data",
|
|
29
|
+
meta: "object",
|
|
30
|
+
dead_letter: "object"
|
|
31
|
+
};
|
|
32
|
+
var Reject = class extends Error {
|
|
33
|
+
};
|
|
34
|
+
function reject(message) {
|
|
35
|
+
throw new Reject(message);
|
|
36
|
+
}
|
|
37
|
+
var isWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
|
|
38
|
+
var isDigit = (c) => c !== void 0 && c >= "0" && c <= "9";
|
|
39
|
+
var Scanner = class {
|
|
40
|
+
constructor(s) {
|
|
41
|
+
this.s = s;
|
|
42
|
+
}
|
|
43
|
+
s;
|
|
44
|
+
i = 0;
|
|
45
|
+
/** Containers currently open around the cursor (Go counts nesting over the whole document, not per subtree). */
|
|
46
|
+
depth = 0;
|
|
47
|
+
/** Why the body is undecodable, or `null` when Go's typed decode accepts it. */
|
|
48
|
+
check() {
|
|
49
|
+
try {
|
|
50
|
+
this.ws();
|
|
51
|
+
if (this.s.startsWith("null", this.i)) {
|
|
52
|
+
this.i += 4;
|
|
53
|
+
} else if (this.s[this.i] === "{") {
|
|
54
|
+
this.members(TOP, "");
|
|
55
|
+
} else {
|
|
56
|
+
reject("expected a JSON object.");
|
|
57
|
+
}
|
|
58
|
+
this.ws();
|
|
59
|
+
if (this.i < this.s.length) reject("unexpected trailing data after the JSON value.");
|
|
60
|
+
return null;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof Reject) return error.message;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
ws() {
|
|
67
|
+
while (isWs(this.s[this.i])) this.i++;
|
|
68
|
+
}
|
|
69
|
+
/** Reads an object whose members are checked against `kinds`; the cursor sits on its `{`. */
|
|
70
|
+
members(kinds, path) {
|
|
71
|
+
this.i++;
|
|
72
|
+
this.depth++;
|
|
73
|
+
this.ws();
|
|
74
|
+
if (this.s[this.i] === "}") {
|
|
75
|
+
this.i++;
|
|
76
|
+
this.depth--;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (; ; ) {
|
|
80
|
+
this.ws();
|
|
81
|
+
if (this.s[this.i] !== '"') reject("expected an object key.");
|
|
82
|
+
const key = this.string();
|
|
83
|
+
this.ws();
|
|
84
|
+
if (this.s[this.i] !== ":") reject("expected ':' after an object key.");
|
|
85
|
+
this.i++;
|
|
86
|
+
this.ws();
|
|
87
|
+
const kind = Object.prototype.hasOwnProperty.call(kinds, key) ? kinds[key] : void 0;
|
|
88
|
+
if (kind === void 0) {
|
|
89
|
+
this.skip(false);
|
|
90
|
+
} else {
|
|
91
|
+
this.typed(kind, path + key, key);
|
|
92
|
+
}
|
|
93
|
+
this.ws();
|
|
94
|
+
const c = this.s[this.i++];
|
|
95
|
+
if (c === "}") {
|
|
96
|
+
this.depth--;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (c !== ",") reject("expected ',' or '}' in an object.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
typed(kind, name, lower) {
|
|
103
|
+
const c = this.s[this.i];
|
|
104
|
+
if (this.s.startsWith("null", this.i)) {
|
|
105
|
+
this.i += 4;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
switch (kind) {
|
|
109
|
+
case "string":
|
|
110
|
+
if (c !== '"') reject(`'${name}' must be a string.`);
|
|
111
|
+
this.string();
|
|
112
|
+
return;
|
|
113
|
+
case "integer": {
|
|
114
|
+
if (c !== "-" && !isDigit(c)) reject(`'${name}' must be an integer.`);
|
|
115
|
+
const token = this.number();
|
|
116
|
+
if (!/^-?[0-9]+$/.test(token)) reject(`'${name}' must be an integer.`);
|
|
117
|
+
const n = BigInt(token);
|
|
118
|
+
if (n < INT64_MIN || n > INT64_MAX) reject(`'${name}' does not fit an int64.`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
case "data":
|
|
122
|
+
if (c !== "{") reject(`'${name}' must be a JSON object.`);
|
|
123
|
+
this.skip(true);
|
|
124
|
+
return;
|
|
125
|
+
case "object":
|
|
126
|
+
if (c !== "{") reject(`'${name}' must be a JSON object.`);
|
|
127
|
+
this.members(lower === "meta" ? META : DEAD_LETTER, name + ".");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Reads a string literal and returns its decoded value; the cursor sits on the opening quote. */
|
|
132
|
+
string() {
|
|
133
|
+
const start = this.i++;
|
|
134
|
+
for (; ; ) {
|
|
135
|
+
const c = this.s[this.i];
|
|
136
|
+
if (c === void 0) reject("unterminated string.");
|
|
137
|
+
if (c === '"') break;
|
|
138
|
+
if (c < " ") reject("control character in a string.");
|
|
139
|
+
if (c === "\\") {
|
|
140
|
+
const e = this.s[this.i + 1];
|
|
141
|
+
if (e === "u") {
|
|
142
|
+
if (!/^[0-9a-fA-F]{4}$/.test(this.s.slice(this.i + 2, this.i + 6))) reject("invalid \\u escape.");
|
|
143
|
+
this.i += 6;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (e === void 0 || !'"\\/bfnrt'.includes(e)) reject("invalid escape in a string.");
|
|
147
|
+
this.i += 2;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
this.i++;
|
|
151
|
+
}
|
|
152
|
+
this.i++;
|
|
153
|
+
return JSON.parse(this.s.slice(start, this.i));
|
|
154
|
+
}
|
|
155
|
+
/** Reads a number literal per the JSON grammar and returns its text. */
|
|
156
|
+
number() {
|
|
157
|
+
const start = this.i;
|
|
158
|
+
if (this.s[this.i] === "-") this.i++;
|
|
159
|
+
if (this.s[this.i] === "0") {
|
|
160
|
+
this.i++;
|
|
161
|
+
} else if (isDigit(this.s[this.i])) {
|
|
162
|
+
while (isDigit(this.s[this.i])) this.i++;
|
|
163
|
+
} else {
|
|
164
|
+
reject("invalid number.");
|
|
165
|
+
}
|
|
166
|
+
if (this.s[this.i] === ".") {
|
|
167
|
+
this.i++;
|
|
168
|
+
if (!isDigit(this.s[this.i])) reject("invalid number.");
|
|
169
|
+
while (isDigit(this.s[this.i])) this.i++;
|
|
170
|
+
}
|
|
171
|
+
if (this.s[this.i] === "e" || this.s[this.i] === "E") {
|
|
172
|
+
this.i++;
|
|
173
|
+
if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
|
|
174
|
+
if (!isDigit(this.s[this.i])) reject("invalid number.");
|
|
175
|
+
while (isDigit(this.s[this.i])) this.i++;
|
|
176
|
+
}
|
|
177
|
+
return this.s.slice(start, this.i);
|
|
178
|
+
}
|
|
179
|
+
/** Skips one value iteratively (no recursion, so depth cannot overflow the stack); optionally requires finite numbers. */
|
|
180
|
+
skip(finite) {
|
|
181
|
+
const stack = [];
|
|
182
|
+
let expectValue = true;
|
|
183
|
+
for (; ; ) {
|
|
184
|
+
this.ws();
|
|
185
|
+
const c = this.s[this.i];
|
|
186
|
+
if (expectValue) {
|
|
187
|
+
if (c === "{" || c === "[") {
|
|
188
|
+
if (this.depth + stack.length >= MAX_DEPTH) reject("exceeded the maximum nesting depth.");
|
|
189
|
+
stack.push(c);
|
|
190
|
+
this.i++;
|
|
191
|
+
this.ws();
|
|
192
|
+
if (this.s[this.i] === (c === "{" ? "}" : "]")) {
|
|
193
|
+
this.i++;
|
|
194
|
+
stack.pop();
|
|
195
|
+
expectValue = false;
|
|
196
|
+
} else if (c === "{") {
|
|
197
|
+
this.key();
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (c === '"') {
|
|
202
|
+
this.string();
|
|
203
|
+
} else if (c === "-" || isDigit(c)) {
|
|
204
|
+
const token = this.number();
|
|
205
|
+
if (finite && !Number.isFinite(Number(token))) reject("a number in 'data' is not a finite double.");
|
|
206
|
+
} else if (this.s.startsWith("true", this.i)) {
|
|
207
|
+
this.i += 4;
|
|
208
|
+
} else if (this.s.startsWith("false", this.i)) {
|
|
209
|
+
this.i += 5;
|
|
210
|
+
} else if (this.s.startsWith("null", this.i)) {
|
|
211
|
+
this.i += 4;
|
|
212
|
+
} else {
|
|
213
|
+
reject("invalid JSON value.");
|
|
214
|
+
}
|
|
215
|
+
expectValue = false;
|
|
216
|
+
}
|
|
217
|
+
if (stack.length === 0) return;
|
|
218
|
+
const top = stack[stack.length - 1];
|
|
219
|
+
this.ws();
|
|
220
|
+
const d = this.s[this.i++];
|
|
221
|
+
if (d === ",") {
|
|
222
|
+
if (top === "{") this.key();
|
|
223
|
+
expectValue = true;
|
|
224
|
+
} else if (d === (top === "{" ? "}" : "]")) {
|
|
225
|
+
stack.pop();
|
|
226
|
+
if (stack.length === 0) return;
|
|
227
|
+
} else {
|
|
228
|
+
reject("expected ',' or the end of a container.");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Reads `"key" :` inside a skipped object. */
|
|
233
|
+
key() {
|
|
234
|
+
this.ws();
|
|
235
|
+
if (this.s[this.i] !== '"') reject("expected an object key.");
|
|
236
|
+
this.string();
|
|
237
|
+
this.ws();
|
|
238
|
+
if (this.s[this.i] !== ":") reject("expected ':' after an object key.");
|
|
239
|
+
this.i++;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
function envelopeViolation(raw) {
|
|
243
|
+
return new Scanner(raw).check();
|
|
244
|
+
}
|
|
245
|
+
function normalizedDecimal(token) {
|
|
246
|
+
const m = /^(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(token);
|
|
247
|
+
if (!m) return token;
|
|
248
|
+
const fraction = m[3] ?? "";
|
|
249
|
+
let digits = (m[2] + fraction).replace(/^0+/, "");
|
|
250
|
+
if (digits === "") return "0";
|
|
251
|
+
let exponent = Number(m[4] ?? "0") - fraction.length;
|
|
252
|
+
const trimmed = digits.replace(/0+$/, "");
|
|
253
|
+
exponent += digits.length - trimmed.length;
|
|
254
|
+
digits = trimmed;
|
|
255
|
+
return `${m[1]}${digits}e${exponent}`;
|
|
256
|
+
}
|
|
257
|
+
function isLossyNumberToken(token) {
|
|
258
|
+
const n = Number(token);
|
|
259
|
+
if (!Number.isFinite(n)) return true;
|
|
260
|
+
if (/^-?[0-9]+$/.test(token) && Number.isSafeInteger(n)) return false;
|
|
261
|
+
return normalizedDecimal(token) !== normalizedDecimal(String(n));
|
|
262
|
+
}
|
|
263
|
+
|
|
4
264
|
// src/headers.ts
|
|
5
265
|
function sanitizeHeaders(headers) {
|
|
6
266
|
const out = {};
|
|
@@ -58,6 +318,9 @@ function headerInt(headers, key, fallback) {
|
|
|
58
318
|
const n = Number.parseInt(s.trim(), 10);
|
|
59
319
|
return Number.isNaN(n) ? fallback : n;
|
|
60
320
|
}
|
|
321
|
+
function isThenable(value) {
|
|
322
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
|
|
323
|
+
}
|
|
61
324
|
var BLANK_ASCII = /^[ \t\n\v\f\r]*$/;
|
|
62
325
|
function unsupportedSchemaVersion(message) {
|
|
63
326
|
const value = headerString(message.headers, "bq-schema-version");
|
|
@@ -148,6 +411,180 @@ var KafkaPublisher = class _KafkaPublisher {
|
|
|
148
411
|
});
|
|
149
412
|
}
|
|
150
413
|
};
|
|
414
|
+
var KafkaRedriveIO = class {
|
|
415
|
+
constructor(options) {
|
|
416
|
+
this.options = options;
|
|
417
|
+
const consumer = options.consumer;
|
|
418
|
+
if (consumer) {
|
|
419
|
+
this.rewind = async (from) => {
|
|
420
|
+
if (from.topic === void 0 || from.partition === void 0 || from.offset === void 0) {
|
|
421
|
+
throw new Error("kafka redrive: cannot rewind a record without topic, partition and offset");
|
|
422
|
+
}
|
|
423
|
+
const pending = new Set(this.inFlight);
|
|
424
|
+
if (!this.acknowledged.has(from)) pending.add(from);
|
|
425
|
+
const first = /* @__PURE__ */ new Map();
|
|
426
|
+
for (const m of pending) {
|
|
427
|
+
if (m.topic === void 0 || m.partition === void 0 || m.offset === void 0) continue;
|
|
428
|
+
const key = `${m.topic}\0${m.partition}`;
|
|
429
|
+
const offset = BigInt(m.offset);
|
|
430
|
+
const seen = first.get(key);
|
|
431
|
+
if (!seen || offset < seen.offset) first.set(key, { topic: m.topic, partition: m.partition, offset });
|
|
432
|
+
}
|
|
433
|
+
this.inFlight.clear();
|
|
434
|
+
const failures = [];
|
|
435
|
+
for (const { topic, partition, offset } of first.values()) {
|
|
436
|
+
try {
|
|
437
|
+
await consumer.seek({ topic, partition, offset: offset.toString() });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
failures.push(err);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (failures.length === 1) throw failures[0];
|
|
443
|
+
if (failures.length > 1) throw new AggregateError(failures, "kafka redrive: seeking several partitions back failed");
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
options;
|
|
448
|
+
/** Seek the consumer back to every unacknowledged record (`from` included unless it was acknowledged already); defined only when a `consumer` was supplied. */
|
|
449
|
+
rewind;
|
|
450
|
+
/** Records handed out by {@link KafkaRedriveIO.pop} that carry a position and are not acknowledged yet. */
|
|
451
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
452
|
+
/** Records handed out by {@link KafkaRedriveIO.pop} that were acknowledged since; a rewind never re-reads them. */
|
|
453
|
+
acknowledged = /* @__PURE__ */ new WeakSet();
|
|
454
|
+
async pop(queue) {
|
|
455
|
+
const message = await this.options.pop(queue);
|
|
456
|
+
if (message && this.rewind && message.topic !== void 0 && message.partition !== void 0 && message.offset !== void 0) {
|
|
457
|
+
const tracked = {
|
|
458
|
+
body: message.body,
|
|
459
|
+
key: message.key,
|
|
460
|
+
value: message.value,
|
|
461
|
+
headers: message.headers,
|
|
462
|
+
topic: message.topic,
|
|
463
|
+
partition: message.partition,
|
|
464
|
+
offset: message.offset,
|
|
465
|
+
ack: async () => {
|
|
466
|
+
await message.ack();
|
|
467
|
+
this.inFlight.delete(tracked);
|
|
468
|
+
this.acknowledged.add(tracked);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
this.inFlight.add(tracked);
|
|
472
|
+
return tracked;
|
|
473
|
+
}
|
|
474
|
+
return message;
|
|
475
|
+
}
|
|
476
|
+
/** Append an already-encoded body to `queue` as a keyless record. */
|
|
477
|
+
async publish(queue, body) {
|
|
478
|
+
await this.send(queue, body, null);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Append `body` to `queue` on behalf of the reserved record `from`. Two modes (Go/Java/.NET parity):
|
|
482
|
+
*
|
|
483
|
+
* - **Restore** — `queue` is the topic `from` was consumed from and `body` is the body `pop` returned for
|
|
484
|
+
* it (redrive writing an unchanged DLQ record back). The original record is re-written verbatim: its
|
|
485
|
+
* value bytes (`from.value`), headers (`from.headers`) and key, with a fresh timestamp so the restored
|
|
486
|
+
* copy does not age out of the DLQ early (never `meta.created_at`). A message that carries no `value`
|
|
487
|
+
* re-writes `body`; one that carries no `headers` falls back to the `bq-*` projection of a decodable body.
|
|
488
|
+
* Needs `from.topic` to be set; without it a restore is indistinguishable from a republish.
|
|
489
|
+
* Header values the consumer decoded as `null` (a header with a -1 length) are not re-written: the
|
|
490
|
+
* restore copy goes through `copyHeaders`, which drops them (a deliberate bound; the Go reference
|
|
491
|
+
* writes them as they are).
|
|
492
|
+
* - **Republish** — any other queue/body (a redrive to the work topic): the `bq-*` header projection and
|
|
493
|
+
* the `meta.created_at` timestamp, plus the key of `from` byte-for-byte (§6.5).
|
|
494
|
+
*/
|
|
495
|
+
async publishFrom(queue, body, from) {
|
|
496
|
+
if (from.topic !== void 0 && queue === from.topic && body === from.body) {
|
|
497
|
+
await this.restoreVerbatim(queue, body, from);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
await this.send(queue, body, from.key ?? null);
|
|
501
|
+
}
|
|
502
|
+
async restoreVerbatim(queue, body, from) {
|
|
503
|
+
const record = {
|
|
504
|
+
// A tombstone stays a tombstone; KafkaJS accepts a null value although the structural type does not.
|
|
505
|
+
value: from.value === void 0 ? body : from.value,
|
|
506
|
+
timestamp: String(Date.now())
|
|
507
|
+
};
|
|
508
|
+
if (from.headers !== void 0) {
|
|
509
|
+
record.headers = copyHeaders(from.headers);
|
|
510
|
+
} else {
|
|
511
|
+
const decoded = EnvelopeCodec.decode(body);
|
|
512
|
+
if (EnvelopeCodec.accepts(decoded)) record.headers = kafkaHeaders(decoded);
|
|
513
|
+
}
|
|
514
|
+
if (from.key !== void 0 && from.key !== null) record.key = Buffer.from(from.key);
|
|
515
|
+
await this.options.producer.send({ topic: queue, messages: [record] });
|
|
516
|
+
}
|
|
517
|
+
async send(queue, body, key) {
|
|
518
|
+
const record = { value: body };
|
|
519
|
+
const decoded = EnvelopeCodec.decode(body);
|
|
520
|
+
if (EnvelopeCodec.accepts(decoded)) {
|
|
521
|
+
record.headers = kafkaHeaders(decoded);
|
|
522
|
+
record.timestamp = String(decoded.meta.created_at);
|
|
523
|
+
}
|
|
524
|
+
if (key !== null) record.key = Buffer.from(key);
|
|
525
|
+
await this.options.producer.send({ topic: queue, messages: [record] });
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
function decodeFailure(raw) {
|
|
529
|
+
const reason = envelopeViolation(raw);
|
|
530
|
+
return reason === null ? null : new BabelQueueError(`Undecodable BabelQueue body: ${reason}`);
|
|
531
|
+
}
|
|
532
|
+
function hasLossyNumber(raw) {
|
|
533
|
+
let i = 0;
|
|
534
|
+
while (i < raw.length) {
|
|
535
|
+
const c = raw[i];
|
|
536
|
+
if (c === '"') {
|
|
537
|
+
i++;
|
|
538
|
+
while (i < raw.length && raw[i] !== '"') i += raw[i] === "\\" ? 2 : 1;
|
|
539
|
+
i++;
|
|
540
|
+
} else if (c === "-" || c >= "0" && c <= "9") {
|
|
541
|
+
let j = i + 1;
|
|
542
|
+
while (j < raw.length && /[0-9.eE+-]/.test(raw[j])) j++;
|
|
543
|
+
if (isLossyNumberToken(raw.slice(i, j))) return true;
|
|
544
|
+
i = j;
|
|
545
|
+
} else {
|
|
546
|
+
i++;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
function poisonRecord(raw, cause, originalQueue, attempts, keepMembers = true) {
|
|
552
|
+
let record = { raw };
|
|
553
|
+
try {
|
|
554
|
+
if (!keepMembers) throw new Error("carry the body verbatim");
|
|
555
|
+
const parsed = JSON.parse(raw);
|
|
556
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && !hasLossyNumber(raw)) {
|
|
557
|
+
record = parsed;
|
|
558
|
+
}
|
|
559
|
+
} catch {
|
|
560
|
+
}
|
|
561
|
+
delete record.dead_letter;
|
|
562
|
+
record.dead_letter = annotate({ attempts }, "poison", originalQueue, {
|
|
563
|
+
attempts,
|
|
564
|
+
error: cause instanceof Error ? cause.message : null,
|
|
565
|
+
exception: cause instanceof Error ? cause.name : null
|
|
566
|
+
}).dead_letter;
|
|
567
|
+
return record;
|
|
568
|
+
}
|
|
569
|
+
function poisonHeaders(record, originalQueue, attempts) {
|
|
570
|
+
const headers = {};
|
|
571
|
+
const str = (v) => typeof v === "string" && v !== "" ? v : void 0;
|
|
572
|
+
const meta = record.meta !== null && typeof record.meta === "object" ? record.meta : {};
|
|
573
|
+
const job = str(record.job) ?? str(record.urn);
|
|
574
|
+
if (job) headers["bq-job"] = job;
|
|
575
|
+
const trace = str(record.trace_id);
|
|
576
|
+
if (trace) headers["bq-trace-id"] = trace;
|
|
577
|
+
const id = str(meta.id);
|
|
578
|
+
if (id) headers["bq-message-id"] = id;
|
|
579
|
+
if (meta.schema_version != null && typeof meta.schema_version !== "object") {
|
|
580
|
+
headers["bq-schema-version"] = String(meta.schema_version);
|
|
581
|
+
}
|
|
582
|
+
const lang = str(meta.lang);
|
|
583
|
+
if (lang) headers["bq-source-lang"] = lang;
|
|
584
|
+
headers["bq-attempts"] = String(attempts);
|
|
585
|
+
headers["bq-original-topic"] = originalQueue;
|
|
586
|
+
return headers;
|
|
587
|
+
}
|
|
151
588
|
var KafkaConsumer = class {
|
|
152
589
|
constructor(consumer, handlers, options = {}) {
|
|
153
590
|
this.consumer = consumer;
|
|
@@ -170,29 +607,28 @@ var KafkaConsumer = class {
|
|
|
170
607
|
const { message } = payload;
|
|
171
608
|
const unsupported = unsupportedSchemaVersion(message);
|
|
172
609
|
if (unsupported !== void 0) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
null,
|
|
176
|
-
payload
|
|
610
|
+
const rejected = new BabelQueueError(
|
|
611
|
+
`Rejected a record from Kafka with unsupported bq-schema-version "${unsupported}" (supported: ${SCHEMA_VERSION}).`
|
|
177
612
|
);
|
|
178
|
-
|
|
179
|
-
|
|
613
|
+
try {
|
|
614
|
+
await this.deadLetterRaw(payload);
|
|
615
|
+
await this.commit(payload);
|
|
616
|
+
} finally {
|
|
617
|
+
this.report(rejected, null, payload);
|
|
618
|
+
}
|
|
180
619
|
return;
|
|
181
620
|
}
|
|
182
621
|
const raw = message.value ? message.value.toString("utf8") : "";
|
|
622
|
+
const undecodable = decodeFailure(raw);
|
|
623
|
+
if (undecodable) {
|
|
624
|
+
await this.settlePoison(payload, raw, undecodable, null);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
183
627
|
let envelope;
|
|
184
628
|
try {
|
|
185
629
|
envelope = this.reconcile(EnvelopeCodec.decode(raw), message);
|
|
186
630
|
} catch (decodeError) {
|
|
187
|
-
this.
|
|
188
|
-
await this.deadLetterRaw(payload);
|
|
189
|
-
await this.commit(payload);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (!EnvelopeCodec.accepts(envelope)) {
|
|
193
|
-
this.report(new BabelQueueError("Rejected a non-conformant BabelQueue envelope from Kafka."), envelope, payload);
|
|
194
|
-
await this.deadLetterRaw(payload);
|
|
195
|
-
await this.commit(payload);
|
|
631
|
+
await this.settlePoison(payload, raw, decodeError, null);
|
|
196
632
|
return;
|
|
197
633
|
}
|
|
198
634
|
const urn = headerString(message.headers, "bq-job") ?? EnvelopeCodec.urn(envelope);
|
|
@@ -205,9 +641,12 @@ var KafkaConsumer = class {
|
|
|
205
641
|
await handler(envelope, message, headersOf(message));
|
|
206
642
|
await this.commit(payload);
|
|
207
643
|
} catch (error) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
644
|
+
try {
|
|
645
|
+
await this.retryOrDeadLetter(payload, envelope, error);
|
|
646
|
+
await this.commit(payload);
|
|
647
|
+
} finally {
|
|
648
|
+
this.report(error, envelope, payload);
|
|
649
|
+
}
|
|
211
650
|
}
|
|
212
651
|
}
|
|
213
652
|
reconcile(envelope, message) {
|
|
@@ -272,6 +711,32 @@ var KafkaConsumer = class {
|
|
|
272
711
|
headers["bq-original-topic"] = original;
|
|
273
712
|
await this.produce(dlq, annotated, headers, payload.message.key);
|
|
274
713
|
}
|
|
714
|
+
/** Dead-letter a poison record (wrapped), commit it, then report: settle first so `onError` cannot bypass it. */
|
|
715
|
+
async settlePoison(payload, raw, cause, envelope) {
|
|
716
|
+
try {
|
|
717
|
+
await this.deadLetterPoison(payload, raw, cause);
|
|
718
|
+
await this.commit(payload);
|
|
719
|
+
} finally {
|
|
720
|
+
this.report(cause, envelope, payload);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async deadLetterPoison(payload, raw, cause) {
|
|
724
|
+
const dlq = this.options.retryTopics?.dlqTopic ?? null;
|
|
725
|
+
if (!dlq) return;
|
|
726
|
+
this.requireProducer();
|
|
727
|
+
const original = this.originalTopic(payload);
|
|
728
|
+
const attempts = Math.max(headerInt(payload.message.headers, "bq-attempts", 0), 0);
|
|
729
|
+
let record = poisonRecord(raw, cause, original, attempts);
|
|
730
|
+
let json;
|
|
731
|
+
try {
|
|
732
|
+
json = JSON.stringify(record);
|
|
733
|
+
} catch {
|
|
734
|
+
record = poisonRecord(raw, cause, original, attempts, false);
|
|
735
|
+
json = JSON.stringify(record);
|
|
736
|
+
}
|
|
737
|
+
const headers = poisonHeaders(record, original, attempts);
|
|
738
|
+
await this.produceRaw(dlq, json, headers, payload.message.key);
|
|
739
|
+
}
|
|
275
740
|
async deadLetterRaw(payload) {
|
|
276
741
|
const dlq = this.options.retryTopics?.dlqTopic ?? null;
|
|
277
742
|
if (!dlq) return;
|
|
@@ -292,8 +757,11 @@ var KafkaConsumer = class {
|
|
|
292
757
|
* guaranteed.
|
|
293
758
|
*/
|
|
294
759
|
async produce(topic, envelope, headers, key) {
|
|
760
|
+
await this.produceRaw(topic, EnvelopeCodec.encode(envelope), headers, key);
|
|
761
|
+
}
|
|
762
|
+
async produceRaw(topic, value, headers, key) {
|
|
295
763
|
this.requireProducer();
|
|
296
|
-
const record = { value
|
|
764
|
+
const record = { value, headers, timestamp: String(Date.now()) };
|
|
297
765
|
if (key !== void 0 && key !== null) record.key = Buffer.from(key);
|
|
298
766
|
await this.options.producer.send({ topic, messages: [record] });
|
|
299
767
|
}
|
|
@@ -310,13 +778,26 @@ var KafkaConsumer = class {
|
|
|
310
778
|
throw new BabelQueueError("This Kafka consumer needs a producer to republish (retry/DLQ).");
|
|
311
779
|
}
|
|
312
780
|
}
|
|
781
|
+
/**
|
|
782
|
+
* Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been
|
|
783
|
+
* settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a
|
|
784
|
+
* promise that later rejects (an `async` function is assignable to the `void` callback type), is
|
|
785
|
+
* swallowed here so user code can never bypass settlement or stop the consumer.
|
|
786
|
+
*/
|
|
313
787
|
report(error, envelope, payload) {
|
|
314
|
-
|
|
788
|
+
try {
|
|
789
|
+
const returned = this.options.onError?.(error, envelope, payload);
|
|
790
|
+
if (isThenable(returned)) {
|
|
791
|
+
returned.then(void 0, () => void 0);
|
|
792
|
+
}
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
315
795
|
}
|
|
316
796
|
};
|
|
317
797
|
export {
|
|
318
798
|
KafkaConsumer,
|
|
319
799
|
KafkaPublisher,
|
|
800
|
+
KafkaRedriveIO,
|
|
320
801
|
RetryTopics,
|
|
321
802
|
headersOf,
|
|
322
803
|
kafkaHeaders
|