@drarzter/kafka-client 0.5.5 → 0.5.6
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/{chunk-Z3O5GTS7.mjs → chunk-6B72MJPU.mjs} +565 -495
- package/dist/chunk-6B72MJPU.mjs.map +1 -0
- package/dist/core.d.mts +9 -34
- package/dist/core.d.ts +9 -34
- package/dist/core.js +564 -494
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/{envelope-BpyKN_WL.d.mts → envelope-LeO5e3ob.d.mts} +4 -1
- package/dist/{envelope-BpyKN_WL.d.ts → envelope-LeO5e3ob.d.ts} +4 -1
- package/dist/index.d.mts +17 -13
- package/dist/index.d.ts +17 -13
- package/dist/index.js +603 -543
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +40 -50
- package/dist/index.mjs.map +1 -1
- package/dist/otel.d.mts +1 -1
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/otel.mjs.map +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +6 -6
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +6 -6
- package/dist/testing.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-Z3O5GTS7.mjs.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// src/client/kafka.client.ts
|
|
1
|
+
// src/client/kafka.client/index.ts
|
|
2
2
|
import { KafkaJS } from "@confluentinc/kafka-javascript";
|
|
3
3
|
|
|
4
|
-
// src/client/envelope.ts
|
|
4
|
+
// src/client/message/envelope.ts
|
|
5
5
|
import { AsyncLocalStorage } from "async_hooks";
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
7
|
var HEADER_EVENT_ID = "x-event-id";
|
|
@@ -61,6 +61,108 @@ function extractEnvelope(payload, headers, topic2, partition, offset) {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// src/client/kafka.client/producer-ops.ts
|
|
65
|
+
function resolveTopicName(topicOrDescriptor) {
|
|
66
|
+
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
67
|
+
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
68
|
+
return topicOrDescriptor.__topic;
|
|
69
|
+
}
|
|
70
|
+
return String(topicOrDescriptor);
|
|
71
|
+
}
|
|
72
|
+
function registerSchema(topicOrDesc, schemaRegistry) {
|
|
73
|
+
if (topicOrDesc?.__schema) {
|
|
74
|
+
const topic2 = resolveTopicName(topicOrDesc);
|
|
75
|
+
schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function validateMessage(topicOrDesc, message, deps) {
|
|
79
|
+
if (topicOrDesc?.__schema) {
|
|
80
|
+
return await topicOrDesc.__schema.parse(message);
|
|
81
|
+
}
|
|
82
|
+
if (deps.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
83
|
+
const schema = deps.schemaRegistry.get(topicOrDesc);
|
|
84
|
+
if (schema) return await schema.parse(message);
|
|
85
|
+
}
|
|
86
|
+
return message;
|
|
87
|
+
}
|
|
88
|
+
async function buildSendPayload(topicOrDesc, messages, deps) {
|
|
89
|
+
registerSchema(topicOrDesc, deps.schemaRegistry);
|
|
90
|
+
const topic2 = resolveTopicName(topicOrDesc);
|
|
91
|
+
const builtMessages = await Promise.all(
|
|
92
|
+
messages.map(async (m) => {
|
|
93
|
+
const envelopeHeaders = buildEnvelopeHeaders({
|
|
94
|
+
correlationId: m.correlationId,
|
|
95
|
+
schemaVersion: m.schemaVersion,
|
|
96
|
+
eventId: m.eventId,
|
|
97
|
+
headers: m.headers
|
|
98
|
+
});
|
|
99
|
+
for (const inst of deps.instrumentation) {
|
|
100
|
+
inst.beforeSend?.(topic2, envelopeHeaders);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
value: JSON.stringify(
|
|
104
|
+
await validateMessage(topicOrDesc, m.value, deps)
|
|
105
|
+
),
|
|
106
|
+
key: m.key ?? null,
|
|
107
|
+
headers: envelopeHeaders
|
|
108
|
+
};
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
return { topic: topic2, messages: builtMessages };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/client/kafka.client/consumer-ops.ts
|
|
115
|
+
function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps) {
|
|
116
|
+
const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
|
|
117
|
+
if (consumers.has(groupId)) {
|
|
118
|
+
const prev = consumerCreationOptions.get(groupId);
|
|
119
|
+
if (prev.fromBeginning !== fromBeginning || prev.autoCommit !== autoCommit) {
|
|
120
|
+
logger.warn(
|
|
121
|
+
`Consumer group "${groupId}" already exists with options (fromBeginning: ${prev.fromBeginning}, autoCommit: ${prev.autoCommit}) \u2014 new options (fromBeginning: ${fromBeginning}, autoCommit: ${autoCommit}) ignored. Use a different groupId to apply different options.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return consumers.get(groupId);
|
|
125
|
+
}
|
|
126
|
+
consumerCreationOptions.set(groupId, { fromBeginning, autoCommit });
|
|
127
|
+
const config = {
|
|
128
|
+
kafkaJS: { groupId, fromBeginning, autoCommit }
|
|
129
|
+
};
|
|
130
|
+
if (onRebalance) {
|
|
131
|
+
const cb = onRebalance;
|
|
132
|
+
config["rebalance_cb"] = (err, assignment) => {
|
|
133
|
+
const type = err.code === -175 ? "assign" : "revoke";
|
|
134
|
+
try {
|
|
135
|
+
cb(
|
|
136
|
+
type,
|
|
137
|
+
assignment.map((p) => ({ topic: p.topic, partition: p.partition }))
|
|
138
|
+
);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
logger.warn(`onRebalance callback threw: ${e.message}`);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const consumer = kafka.consumer(config);
|
|
145
|
+
consumers.set(groupId, consumer);
|
|
146
|
+
return consumer;
|
|
147
|
+
}
|
|
148
|
+
function buildSchemaMap(topics, schemaRegistry, optionSchemas) {
|
|
149
|
+
const schemaMap = /* @__PURE__ */ new Map();
|
|
150
|
+
for (const t of topics) {
|
|
151
|
+
if (t?.__schema) {
|
|
152
|
+
const name = resolveTopicName(t);
|
|
153
|
+
schemaMap.set(name, t.__schema);
|
|
154
|
+
schemaRegistry.set(name, t.__schema);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (optionSchemas) {
|
|
158
|
+
for (const [k, v] of optionSchemas) {
|
|
159
|
+
schemaMap.set(k, v);
|
|
160
|
+
schemaRegistry.set(k, v);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return schemaMap;
|
|
164
|
+
}
|
|
165
|
+
|
|
64
166
|
// src/client/errors.ts
|
|
65
167
|
var KafkaProcessingError = class extends Error {
|
|
66
168
|
constructor(message, topic2, originalMessage, options) {
|
|
@@ -93,7 +195,7 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
|
|
|
93
195
|
}
|
|
94
196
|
};
|
|
95
197
|
|
|
96
|
-
// src/client/consumer
|
|
198
|
+
// src/client/consumer/pipeline.ts
|
|
97
199
|
function toError(error) {
|
|
98
200
|
return error instanceof Error ? error : new Error(String(error));
|
|
99
201
|
}
|
|
@@ -212,6 +314,53 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
|
|
|
212
314
|
);
|
|
213
315
|
}
|
|
214
316
|
}
|
|
317
|
+
async function broadcastToInterceptors(envelopes, interceptors, cb) {
|
|
318
|
+
for (const env of envelopes) {
|
|
319
|
+
for (const interceptor of interceptors) {
|
|
320
|
+
await cb(interceptor, env);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function runHandlerWithPipeline(fn, envelopes, interceptors, instrumentation) {
|
|
325
|
+
const cleanups = [];
|
|
326
|
+
try {
|
|
327
|
+
for (const env of envelopes) {
|
|
328
|
+
for (const inst of instrumentation) {
|
|
329
|
+
const cleanup = inst.beforeConsume?.(env);
|
|
330
|
+
if (typeof cleanup === "function") cleanups.push(cleanup);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const env of envelopes) {
|
|
334
|
+
for (const interceptor of interceptors) {
|
|
335
|
+
await interceptor.before?.(env);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
await fn();
|
|
339
|
+
for (const env of envelopes) {
|
|
340
|
+
for (const interceptor of interceptors) {
|
|
341
|
+
await interceptor.after?.(env);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const cleanup of cleanups) cleanup();
|
|
345
|
+
return null;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const err = toError(error);
|
|
348
|
+
for (const env of envelopes) {
|
|
349
|
+
for (const inst of instrumentation) {
|
|
350
|
+
inst.onConsumeError?.(env, err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
for (const cleanup of cleanups) cleanup();
|
|
354
|
+
return err;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function notifyInterceptorsOnError(envelopes, interceptors, error) {
|
|
358
|
+
await broadcastToInterceptors(
|
|
359
|
+
envelopes,
|
|
360
|
+
interceptors,
|
|
361
|
+
(i, env) => i.onError?.(env, error)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
215
364
|
async function executeWithRetry(fn, ctx, deps) {
|
|
216
365
|
const {
|
|
217
366
|
envelope,
|
|
@@ -228,98 +377,181 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
228
377
|
const envelopes = Array.isArray(envelope) ? envelope : [envelope];
|
|
229
378
|
const topic2 = envelopes[0]?.topic ?? "unknown";
|
|
230
379
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (isLastAttempt && maxAttempts > 1) {
|
|
262
|
-
const exhaustedError = new KafkaRetryExhaustedError(
|
|
263
|
-
topic2,
|
|
264
|
-
envelopes.map((e) => e.payload),
|
|
265
|
-
maxAttempts,
|
|
266
|
-
{ cause: err }
|
|
267
|
-
);
|
|
268
|
-
for (const env of envelopes) {
|
|
269
|
-
for (const interceptor of interceptors) {
|
|
270
|
-
await interceptor.onError?.(env, exhaustedError);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
} else {
|
|
274
|
-
for (const env of envelopes) {
|
|
275
|
-
for (const interceptor of interceptors) {
|
|
276
|
-
await interceptor.onError?.(env, err);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
deps.logger.error(
|
|
281
|
-
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
282
|
-
err.stack
|
|
380
|
+
const error = await runHandlerWithPipeline(
|
|
381
|
+
fn,
|
|
382
|
+
envelopes,
|
|
383
|
+
interceptors,
|
|
384
|
+
deps.instrumentation
|
|
385
|
+
);
|
|
386
|
+
if (!error) return;
|
|
387
|
+
const isLastAttempt = attempt === maxAttempts;
|
|
388
|
+
const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
|
|
389
|
+
topic2,
|
|
390
|
+
envelopes.map((e) => e.payload),
|
|
391
|
+
maxAttempts,
|
|
392
|
+
{ cause: error }
|
|
393
|
+
) : error;
|
|
394
|
+
await notifyInterceptorsOnError(envelopes, interceptors, reportedError);
|
|
395
|
+
deps.logger.error(
|
|
396
|
+
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
397
|
+
error.stack
|
|
398
|
+
);
|
|
399
|
+
if (retryTopics && retry) {
|
|
400
|
+
const cap = Math.min(backoffMs, maxBackoffMs);
|
|
401
|
+
const delay = Math.floor(Math.random() * cap);
|
|
402
|
+
await sendToRetryTopic(
|
|
403
|
+
topic2,
|
|
404
|
+
rawMessages,
|
|
405
|
+
1,
|
|
406
|
+
retry.maxRetries,
|
|
407
|
+
delay,
|
|
408
|
+
envelopes[0]?.headers ?? {},
|
|
409
|
+
deps
|
|
283
410
|
);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
envelopes[0]?.headers ?? {},
|
|
294
|
-
deps
|
|
295
|
-
);
|
|
296
|
-
} else if (isLastAttempt) {
|
|
297
|
-
if (dlq) {
|
|
298
|
-
const dlqMeta = {
|
|
299
|
-
error: err,
|
|
300
|
-
attempt,
|
|
301
|
-
originalHeaders: envelopes[0]?.headers
|
|
302
|
-
};
|
|
303
|
-
for (const raw of rawMessages) {
|
|
304
|
-
await sendToDlq(topic2, raw, deps, dlqMeta);
|
|
305
|
-
}
|
|
306
|
-
} else {
|
|
307
|
-
await deps.onMessageLost?.({
|
|
308
|
-
topic: topic2,
|
|
309
|
-
error: err,
|
|
310
|
-
attempt,
|
|
311
|
-
headers: envelopes[0]?.headers ?? {}
|
|
312
|
-
});
|
|
411
|
+
} else if (isLastAttempt) {
|
|
412
|
+
if (dlq) {
|
|
413
|
+
const dlqMeta = {
|
|
414
|
+
error,
|
|
415
|
+
attempt,
|
|
416
|
+
originalHeaders: envelopes[0]?.headers
|
|
417
|
+
};
|
|
418
|
+
for (const raw of rawMessages) {
|
|
419
|
+
await sendToDlq(topic2, raw, deps, dlqMeta);
|
|
313
420
|
}
|
|
314
421
|
} else {
|
|
315
|
-
|
|
316
|
-
|
|
422
|
+
await deps.onMessageLost?.({
|
|
423
|
+
topic: topic2,
|
|
424
|
+
error,
|
|
425
|
+
attempt,
|
|
426
|
+
headers: envelopes[0]?.headers ?? {}
|
|
427
|
+
});
|
|
317
428
|
}
|
|
429
|
+
} else {
|
|
430
|
+
const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
|
|
431
|
+
await sleep(Math.random() * cap);
|
|
318
432
|
}
|
|
319
433
|
}
|
|
320
434
|
}
|
|
321
435
|
|
|
322
|
-
// src/client/
|
|
436
|
+
// src/client/kafka.client/message-handler.ts
|
|
437
|
+
async function parseSingleMessage(message, topic2, partition, schemaMap, interceptors, dlq, deps) {
|
|
438
|
+
if (!message.value) {
|
|
439
|
+
deps.logger.warn(`Received empty message from topic ${topic2}`);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const raw = message.value.toString();
|
|
443
|
+
const parsed = parseJsonMessage(raw, topic2, deps.logger);
|
|
444
|
+
if (parsed === null) return null;
|
|
445
|
+
const headers = decodeHeaders(message.headers);
|
|
446
|
+
const validated = await validateWithSchema(
|
|
447
|
+
parsed,
|
|
448
|
+
raw,
|
|
449
|
+
topic2,
|
|
450
|
+
schemaMap,
|
|
451
|
+
interceptors,
|
|
452
|
+
dlq,
|
|
453
|
+
{ ...deps, originalHeaders: headers }
|
|
454
|
+
);
|
|
455
|
+
if (validated === null) return null;
|
|
456
|
+
return extractEnvelope(validated, headers, topic2, partition, message.offset);
|
|
457
|
+
}
|
|
458
|
+
async function handleEachMessage(payload, opts, deps) {
|
|
459
|
+
const { topic: topic2, partition, message } = payload;
|
|
460
|
+
const {
|
|
461
|
+
schemaMap,
|
|
462
|
+
handleMessage,
|
|
463
|
+
interceptors,
|
|
464
|
+
dlq,
|
|
465
|
+
retry,
|
|
466
|
+
retryTopics,
|
|
467
|
+
timeoutMs,
|
|
468
|
+
wrapWithTimeout
|
|
469
|
+
} = opts;
|
|
470
|
+
const envelope = await parseSingleMessage(
|
|
471
|
+
message,
|
|
472
|
+
topic2,
|
|
473
|
+
partition,
|
|
474
|
+
schemaMap,
|
|
475
|
+
interceptors,
|
|
476
|
+
dlq,
|
|
477
|
+
deps
|
|
478
|
+
);
|
|
479
|
+
if (envelope === null) return;
|
|
480
|
+
await executeWithRetry(
|
|
481
|
+
() => {
|
|
482
|
+
const fn = () => runWithEnvelopeContext(
|
|
483
|
+
{
|
|
484
|
+
correlationId: envelope.correlationId,
|
|
485
|
+
traceparent: envelope.traceparent
|
|
486
|
+
},
|
|
487
|
+
() => handleMessage(envelope)
|
|
488
|
+
);
|
|
489
|
+
return timeoutMs ? wrapWithTimeout(fn, timeoutMs, topic2) : fn();
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
envelope,
|
|
493
|
+
rawMessages: [message.value.toString()],
|
|
494
|
+
interceptors,
|
|
495
|
+
dlq,
|
|
496
|
+
retry,
|
|
497
|
+
retryTopics
|
|
498
|
+
},
|
|
499
|
+
deps
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
async function handleEachBatch(payload, opts, deps) {
|
|
503
|
+
const { batch, heartbeat, resolveOffset, commitOffsetsIfNecessary } = payload;
|
|
504
|
+
const {
|
|
505
|
+
schemaMap,
|
|
506
|
+
handleBatch,
|
|
507
|
+
interceptors,
|
|
508
|
+
dlq,
|
|
509
|
+
retry,
|
|
510
|
+
timeoutMs,
|
|
511
|
+
wrapWithTimeout
|
|
512
|
+
} = opts;
|
|
513
|
+
const envelopes = [];
|
|
514
|
+
const rawMessages = [];
|
|
515
|
+
for (const message of batch.messages) {
|
|
516
|
+
const envelope = await parseSingleMessage(
|
|
517
|
+
message,
|
|
518
|
+
batch.topic,
|
|
519
|
+
batch.partition,
|
|
520
|
+
schemaMap,
|
|
521
|
+
interceptors,
|
|
522
|
+
dlq,
|
|
523
|
+
deps
|
|
524
|
+
);
|
|
525
|
+
if (envelope === null) continue;
|
|
526
|
+
envelopes.push(envelope);
|
|
527
|
+
rawMessages.push(message.value.toString());
|
|
528
|
+
}
|
|
529
|
+
if (envelopes.length === 0) return;
|
|
530
|
+
const meta = {
|
|
531
|
+
partition: batch.partition,
|
|
532
|
+
highWatermark: batch.highWatermark,
|
|
533
|
+
heartbeat,
|
|
534
|
+
resolveOffset,
|
|
535
|
+
commitOffsetsIfNecessary
|
|
536
|
+
};
|
|
537
|
+
await executeWithRetry(
|
|
538
|
+
() => {
|
|
539
|
+
const fn = () => handleBatch(envelopes, meta);
|
|
540
|
+
return timeoutMs ? wrapWithTimeout(fn, timeoutMs, batch.topic) : fn();
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
envelope: envelopes,
|
|
544
|
+
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
545
|
+
interceptors,
|
|
546
|
+
dlq,
|
|
547
|
+
retry,
|
|
548
|
+
isBatch: true
|
|
549
|
+
},
|
|
550
|
+
deps
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/client/consumer/subscribe-retry.ts
|
|
323
555
|
async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
324
556
|
const maxAttempts = retryOpts?.retries ?? 5;
|
|
325
557
|
const backoffMs = retryOpts?.backoffMs ?? 5e3;
|
|
@@ -338,7 +570,156 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
|
338
570
|
}
|
|
339
571
|
}
|
|
340
572
|
|
|
341
|
-
// src/client/kafka.client.ts
|
|
573
|
+
// src/client/kafka.client/retry-topic.ts
|
|
574
|
+
async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
|
|
575
|
+
const topicSet = new Set(topics);
|
|
576
|
+
const deadline = Date.now() + timeoutMs;
|
|
577
|
+
while (Date.now() < deadline) {
|
|
578
|
+
try {
|
|
579
|
+
const assigned = consumer.assignment();
|
|
580
|
+
if (assigned.some((a) => topicSet.has(a.topic))) return;
|
|
581
|
+
} catch {
|
|
582
|
+
}
|
|
583
|
+
await sleep(200);
|
|
584
|
+
}
|
|
585
|
+
logger.warn(
|
|
586
|
+
`Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
async function startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap, deps) {
|
|
590
|
+
const {
|
|
591
|
+
logger,
|
|
592
|
+
producer,
|
|
593
|
+
instrumentation,
|
|
594
|
+
onMessageLost,
|
|
595
|
+
ensureTopic,
|
|
596
|
+
getOrCreateConsumer: getOrCreateConsumer2,
|
|
597
|
+
runningConsumers
|
|
598
|
+
} = deps;
|
|
599
|
+
const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
|
|
600
|
+
const retryGroupId = `${originalGroupId}-retry`;
|
|
601
|
+
const backoffMs = retry.backoffMs ?? 1e3;
|
|
602
|
+
const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
|
|
603
|
+
const pipelineDeps = { logger, producer, instrumentation, onMessageLost };
|
|
604
|
+
for (const rt of retryTopicNames) {
|
|
605
|
+
await ensureTopic(rt);
|
|
606
|
+
}
|
|
607
|
+
const consumer = getOrCreateConsumer2(retryGroupId, false, true);
|
|
608
|
+
await consumer.connect();
|
|
609
|
+
await subscribeWithRetry(consumer, retryTopicNames, logger);
|
|
610
|
+
await consumer.run({
|
|
611
|
+
eachMessage: async ({ topic: retryTopic, partition, message }) => {
|
|
612
|
+
if (!message.value) return;
|
|
613
|
+
const raw = message.value.toString();
|
|
614
|
+
const parsed = parseJsonMessage(raw, retryTopic, logger);
|
|
615
|
+
if (parsed === null) return;
|
|
616
|
+
const headers = decodeHeaders(message.headers);
|
|
617
|
+
const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
|
|
618
|
+
const currentAttempt = parseInt(
|
|
619
|
+
headers[RETRY_HEADER_ATTEMPT] ?? "1",
|
|
620
|
+
10
|
|
621
|
+
);
|
|
622
|
+
const maxRetries = parseInt(
|
|
623
|
+
headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
|
|
624
|
+
10
|
|
625
|
+
);
|
|
626
|
+
const retryAfter = parseInt(
|
|
627
|
+
headers[RETRY_HEADER_AFTER] ?? "0",
|
|
628
|
+
10
|
|
629
|
+
);
|
|
630
|
+
const remaining = retryAfter - Date.now();
|
|
631
|
+
if (remaining > 0) {
|
|
632
|
+
consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
|
|
633
|
+
await sleep(remaining);
|
|
634
|
+
consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
|
|
635
|
+
}
|
|
636
|
+
const validated = await validateWithSchema(
|
|
637
|
+
parsed,
|
|
638
|
+
raw,
|
|
639
|
+
originalTopic,
|
|
640
|
+
schemaMap,
|
|
641
|
+
interceptors,
|
|
642
|
+
dlq,
|
|
643
|
+
{ ...pipelineDeps, originalHeaders: headers }
|
|
644
|
+
);
|
|
645
|
+
if (validated === null) return;
|
|
646
|
+
const envelope = extractEnvelope(
|
|
647
|
+
validated,
|
|
648
|
+
headers,
|
|
649
|
+
originalTopic,
|
|
650
|
+
partition,
|
|
651
|
+
message.offset
|
|
652
|
+
);
|
|
653
|
+
const error = await runHandlerWithPipeline(
|
|
654
|
+
() => runWithEnvelopeContext(
|
|
655
|
+
{
|
|
656
|
+
correlationId: envelope.correlationId,
|
|
657
|
+
traceparent: envelope.traceparent
|
|
658
|
+
},
|
|
659
|
+
() => handleMessage(envelope)
|
|
660
|
+
),
|
|
661
|
+
[envelope],
|
|
662
|
+
interceptors,
|
|
663
|
+
instrumentation
|
|
664
|
+
);
|
|
665
|
+
if (error) {
|
|
666
|
+
const nextAttempt = currentAttempt + 1;
|
|
667
|
+
const exhausted = currentAttempt >= maxRetries;
|
|
668
|
+
const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(
|
|
669
|
+
originalTopic,
|
|
670
|
+
[envelope.payload],
|
|
671
|
+
maxRetries,
|
|
672
|
+
{ cause: error }
|
|
673
|
+
) : error;
|
|
674
|
+
await notifyInterceptorsOnError(
|
|
675
|
+
[envelope],
|
|
676
|
+
interceptors,
|
|
677
|
+
reportedError
|
|
678
|
+
);
|
|
679
|
+
logger.error(
|
|
680
|
+
`Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
|
|
681
|
+
error.stack
|
|
682
|
+
);
|
|
683
|
+
if (!exhausted) {
|
|
684
|
+
const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
|
|
685
|
+
const delay = Math.floor(Math.random() * cap);
|
|
686
|
+
await sendToRetryTopic(
|
|
687
|
+
originalTopic,
|
|
688
|
+
[raw],
|
|
689
|
+
nextAttempt,
|
|
690
|
+
maxRetries,
|
|
691
|
+
delay,
|
|
692
|
+
headers,
|
|
693
|
+
pipelineDeps
|
|
694
|
+
);
|
|
695
|
+
} else if (dlq) {
|
|
696
|
+
await sendToDlq(originalTopic, raw, pipelineDeps, {
|
|
697
|
+
error,
|
|
698
|
+
// +1 to account for the main consumer's initial attempt before
|
|
699
|
+
// routing to the retry topic, making this consistent with the
|
|
700
|
+
// in-process retry path where attempt counts all tries.
|
|
701
|
+
attempt: currentAttempt + 1,
|
|
702
|
+
originalHeaders: headers
|
|
703
|
+
});
|
|
704
|
+
} else {
|
|
705
|
+
await onMessageLost?.({
|
|
706
|
+
topic: originalTopic,
|
|
707
|
+
error,
|
|
708
|
+
attempt: currentAttempt,
|
|
709
|
+
headers
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
runningConsumers.set(retryGroupId, "eachMessage");
|
|
716
|
+
await waitForPartitionAssignment(consumer, retryTopicNames, logger);
|
|
717
|
+
logger.log(
|
|
718
|
+
`Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/client/kafka.client/index.ts
|
|
342
723
|
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS;
|
|
343
724
|
var KafkaClient = class {
|
|
344
725
|
kafka;
|
|
@@ -354,6 +735,7 @@ var KafkaClient = class {
|
|
|
354
735
|
defaultGroupId;
|
|
355
736
|
schemaRegistry = /* @__PURE__ */ new Map();
|
|
356
737
|
runningConsumers = /* @__PURE__ */ new Map();
|
|
738
|
+
consumerCreationOptions = /* @__PURE__ */ new Map();
|
|
357
739
|
instrumentation;
|
|
358
740
|
onMessageLost;
|
|
359
741
|
onRebalance;
|
|
@@ -388,7 +770,7 @@ var KafkaClient = class {
|
|
|
388
770
|
this.admin = this.kafka.admin();
|
|
389
771
|
}
|
|
390
772
|
async sendMessage(topicOrDesc, message, options = {}) {
|
|
391
|
-
const payload = await this.
|
|
773
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
392
774
|
{
|
|
393
775
|
value: message,
|
|
394
776
|
key: options.key,
|
|
@@ -398,19 +780,13 @@ var KafkaClient = class {
|
|
|
398
780
|
eventId: options.eventId
|
|
399
781
|
}
|
|
400
782
|
]);
|
|
401
|
-
await this.ensureTopic(payload.topic);
|
|
402
783
|
await this.producer.send(payload);
|
|
403
|
-
|
|
404
|
-
inst.afterSend?.(payload.topic);
|
|
405
|
-
}
|
|
784
|
+
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
406
785
|
}
|
|
407
786
|
async sendBatch(topicOrDesc, messages) {
|
|
408
|
-
const payload = await this.
|
|
409
|
-
await this.ensureTopic(payload.topic);
|
|
787
|
+
const payload = await this.preparePayload(topicOrDesc, messages);
|
|
410
788
|
await this.producer.send(payload);
|
|
411
|
-
|
|
412
|
-
inst.afterSend?.(payload.topic);
|
|
413
|
-
}
|
|
789
|
+
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
414
790
|
}
|
|
415
791
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
416
792
|
async transaction(fn) {
|
|
@@ -429,7 +805,7 @@ var KafkaClient = class {
|
|
|
429
805
|
try {
|
|
430
806
|
const ctx = {
|
|
431
807
|
send: async (topicOrDesc, message, options = {}) => {
|
|
432
|
-
const payload = await this.
|
|
808
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
433
809
|
{
|
|
434
810
|
value: message,
|
|
435
811
|
key: options.key,
|
|
@@ -439,13 +815,10 @@ var KafkaClient = class {
|
|
|
439
815
|
eventId: options.eventId
|
|
440
816
|
}
|
|
441
817
|
]);
|
|
442
|
-
await this.ensureTopic(payload.topic);
|
|
443
818
|
await tx.send(payload);
|
|
444
819
|
},
|
|
445
820
|
sendBatch: async (topicOrDesc, messages) => {
|
|
446
|
-
|
|
447
|
-
await this.ensureTopic(payload.topic);
|
|
448
|
-
await tx.send(payload);
|
|
821
|
+
await tx.send(await this.preparePayload(topicOrDesc, messages));
|
|
449
822
|
}
|
|
450
823
|
};
|
|
451
824
|
await fn(ctx);
|
|
@@ -479,151 +852,57 @@ var KafkaClient = class {
|
|
|
479
852
|
);
|
|
480
853
|
}
|
|
481
854
|
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
|
|
482
|
-
const deps =
|
|
483
|
-
logger: this.logger,
|
|
484
|
-
producer: this.producer,
|
|
485
|
-
instrumentation: this.instrumentation,
|
|
486
|
-
onMessageLost: this.onMessageLost
|
|
487
|
-
};
|
|
855
|
+
const deps = this.messageDeps;
|
|
488
856
|
const timeoutMs = options.handlerTimeoutMs;
|
|
489
857
|
await consumer.run({
|
|
490
|
-
eachMessage:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
const raw = message.value.toString();
|
|
496
|
-
const parsed = parseJsonMessage(raw, topic2, this.logger);
|
|
497
|
-
if (parsed === null) return;
|
|
498
|
-
const headers = decodeHeaders(message.headers);
|
|
499
|
-
const validated = await validateWithSchema(
|
|
500
|
-
parsed,
|
|
501
|
-
raw,
|
|
502
|
-
topic2,
|
|
858
|
+
eachMessage: (payload) => handleEachMessage(
|
|
859
|
+
payload,
|
|
860
|
+
{
|
|
503
861
|
schemaMap,
|
|
862
|
+
handleMessage,
|
|
504
863
|
interceptors,
|
|
505
864
|
dlq,
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
partition,
|
|
514
|
-
message.offset
|
|
515
|
-
);
|
|
516
|
-
await executeWithRetry(
|
|
517
|
-
() => {
|
|
518
|
-
const fn = () => runWithEnvelopeContext(
|
|
519
|
-
{
|
|
520
|
-
correlationId: envelope.correlationId,
|
|
521
|
-
traceparent: envelope.traceparent
|
|
522
|
-
},
|
|
523
|
-
() => handleMessage(envelope)
|
|
524
|
-
);
|
|
525
|
-
return timeoutMs ? this.wrapWithTimeoutWarning(fn, timeoutMs, topic2) : fn();
|
|
526
|
-
},
|
|
527
|
-
{
|
|
528
|
-
envelope,
|
|
529
|
-
rawMessages: [raw],
|
|
530
|
-
interceptors,
|
|
531
|
-
dlq,
|
|
532
|
-
retry,
|
|
533
|
-
retryTopics: options.retryTopics
|
|
534
|
-
},
|
|
535
|
-
deps
|
|
536
|
-
);
|
|
537
|
-
}
|
|
865
|
+
retry,
|
|
866
|
+
retryTopics: options.retryTopics,
|
|
867
|
+
timeoutMs,
|
|
868
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
|
|
869
|
+
},
|
|
870
|
+
deps
|
|
871
|
+
)
|
|
538
872
|
});
|
|
539
873
|
this.runningConsumers.set(gid, "eachMessage");
|
|
540
874
|
if (options.retryTopics && retry) {
|
|
541
|
-
await
|
|
875
|
+
await startRetryTopicConsumers(
|
|
542
876
|
topicNames,
|
|
543
877
|
gid,
|
|
544
878
|
handleMessage,
|
|
545
879
|
retry,
|
|
546
880
|
dlq,
|
|
547
881
|
interceptors,
|
|
548
|
-
schemaMap
|
|
882
|
+
schemaMap,
|
|
883
|
+
this.retryTopicDeps
|
|
549
884
|
);
|
|
550
885
|
}
|
|
551
886
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
552
887
|
}
|
|
553
888
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
554
889
|
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
|
|
555
|
-
const deps =
|
|
556
|
-
logger: this.logger,
|
|
557
|
-
producer: this.producer,
|
|
558
|
-
instrumentation: this.instrumentation,
|
|
559
|
-
onMessageLost: this.onMessageLost
|
|
560
|
-
};
|
|
890
|
+
const deps = this.messageDeps;
|
|
561
891
|
const timeoutMs = options.handlerTimeoutMs;
|
|
562
892
|
await consumer.run({
|
|
563
|
-
eachBatch:
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
const raw = message.value.toString();
|
|
579
|
-
const parsed = parseJsonMessage(raw, batch.topic, this.logger);
|
|
580
|
-
if (parsed === null) continue;
|
|
581
|
-
const headers = decodeHeaders(message.headers);
|
|
582
|
-
const validated = await validateWithSchema(
|
|
583
|
-
parsed,
|
|
584
|
-
raw,
|
|
585
|
-
batch.topic,
|
|
586
|
-
schemaMap,
|
|
587
|
-
interceptors,
|
|
588
|
-
dlq,
|
|
589
|
-
{ ...deps, originalHeaders: headers }
|
|
590
|
-
);
|
|
591
|
-
if (validated === null) continue;
|
|
592
|
-
envelopes.push(
|
|
593
|
-
extractEnvelope(
|
|
594
|
-
validated,
|
|
595
|
-
headers,
|
|
596
|
-
batch.topic,
|
|
597
|
-
batch.partition,
|
|
598
|
-
message.offset
|
|
599
|
-
)
|
|
600
|
-
);
|
|
601
|
-
rawMessages.push(raw);
|
|
602
|
-
}
|
|
603
|
-
if (envelopes.length === 0) return;
|
|
604
|
-
const meta = {
|
|
605
|
-
partition: batch.partition,
|
|
606
|
-
highWatermark: batch.highWatermark,
|
|
607
|
-
heartbeat,
|
|
608
|
-
resolveOffset,
|
|
609
|
-
commitOffsetsIfNecessary
|
|
610
|
-
};
|
|
611
|
-
await executeWithRetry(
|
|
612
|
-
() => {
|
|
613
|
-
const fn = () => handleBatch(envelopes, meta);
|
|
614
|
-
return timeoutMs ? this.wrapWithTimeoutWarning(fn, timeoutMs, batch.topic) : fn();
|
|
615
|
-
},
|
|
616
|
-
{
|
|
617
|
-
envelope: envelopes,
|
|
618
|
-
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
619
|
-
interceptors,
|
|
620
|
-
dlq,
|
|
621
|
-
retry,
|
|
622
|
-
isBatch: true
|
|
623
|
-
},
|
|
624
|
-
deps
|
|
625
|
-
);
|
|
626
|
-
}
|
|
893
|
+
eachBatch: (payload) => handleEachBatch(
|
|
894
|
+
payload,
|
|
895
|
+
{
|
|
896
|
+
schemaMap,
|
|
897
|
+
handleBatch,
|
|
898
|
+
interceptors,
|
|
899
|
+
dlq,
|
|
900
|
+
retry,
|
|
901
|
+
timeoutMs,
|
|
902
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
|
|
903
|
+
},
|
|
904
|
+
deps
|
|
905
|
+
)
|
|
627
906
|
});
|
|
628
907
|
this.runningConsumers.set(gid, "eachBatch");
|
|
629
908
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
@@ -642,6 +921,7 @@ var KafkaClient = class {
|
|
|
642
921
|
});
|
|
643
922
|
this.consumers.delete(groupId);
|
|
644
923
|
this.runningConsumers.delete(groupId);
|
|
924
|
+
this.consumerCreationOptions.delete(groupId);
|
|
645
925
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
646
926
|
} else {
|
|
647
927
|
const tasks = Array.from(this.consumers.values()).map(
|
|
@@ -651,6 +931,7 @@ var KafkaClient = class {
|
|
|
651
931
|
await Promise.allSettled(tasks);
|
|
652
932
|
this.consumers.clear();
|
|
653
933
|
this.runningConsumers.clear();
|
|
934
|
+
this.consumerCreationOptions.clear();
|
|
654
935
|
this.logger.log("All consumers disconnected");
|
|
655
936
|
}
|
|
656
937
|
}
|
|
@@ -709,204 +990,26 @@ var KafkaClient = class {
|
|
|
709
990
|
await Promise.allSettled(tasks);
|
|
710
991
|
this.consumers.clear();
|
|
711
992
|
this.runningConsumers.clear();
|
|
993
|
+
this.consumerCreationOptions.clear();
|
|
712
994
|
this.logger.log("All connections closed");
|
|
713
995
|
}
|
|
714
|
-
// ── Retry topic chain ────────────────────────────────────────────
|
|
715
|
-
/**
|
|
716
|
-
* Auto-start companion consumers on `<topic>.retry` for each original topic.
|
|
717
|
-
* Called by `startConsumer` when `retryTopics: true`.
|
|
718
|
-
*
|
|
719
|
-
* Flow per message:
|
|
720
|
-
* 1. Sleep until `x-retry-after` (scheduled by the main consumer or previous retry hop)
|
|
721
|
-
* 2. Call the original handler
|
|
722
|
-
* 3. On failure: if retries remain → re-send to `<originalTopic>.retry` with incremented attempt
|
|
723
|
-
* if exhausted → DLQ or onMessageLost
|
|
724
|
-
*/
|
|
725
|
-
async startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap) {
|
|
726
|
-
const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
|
|
727
|
-
const retryGroupId = `${originalGroupId}-retry`;
|
|
728
|
-
const backoffMs = retry.backoffMs ?? 1e3;
|
|
729
|
-
const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
|
|
730
|
-
const deps = {
|
|
731
|
-
logger: this.logger,
|
|
732
|
-
producer: this.producer,
|
|
733
|
-
instrumentation: this.instrumentation,
|
|
734
|
-
onMessageLost: this.onMessageLost
|
|
735
|
-
};
|
|
736
|
-
for (const rt of retryTopicNames) {
|
|
737
|
-
await this.ensureTopic(rt);
|
|
738
|
-
}
|
|
739
|
-
const consumer = this.getOrCreateConsumer(retryGroupId, false, true);
|
|
740
|
-
await consumer.connect();
|
|
741
|
-
await subscribeWithRetry(consumer, retryTopicNames, this.logger);
|
|
742
|
-
await consumer.run({
|
|
743
|
-
eachMessage: async ({ topic: retryTopic, partition, message }) => {
|
|
744
|
-
if (!message.value) return;
|
|
745
|
-
const raw = message.value.toString();
|
|
746
|
-
const parsed = parseJsonMessage(raw, retryTopic, this.logger);
|
|
747
|
-
if (parsed === null) return;
|
|
748
|
-
const headers = decodeHeaders(message.headers);
|
|
749
|
-
const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
|
|
750
|
-
const currentAttempt = parseInt(
|
|
751
|
-
headers[RETRY_HEADER_ATTEMPT] ?? "1",
|
|
752
|
-
10
|
|
753
|
-
);
|
|
754
|
-
const maxRetries = parseInt(
|
|
755
|
-
headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
|
|
756
|
-
10
|
|
757
|
-
);
|
|
758
|
-
const retryAfter = parseInt(
|
|
759
|
-
headers[RETRY_HEADER_AFTER] ?? "0",
|
|
760
|
-
10
|
|
761
|
-
);
|
|
762
|
-
const remaining = retryAfter - Date.now();
|
|
763
|
-
if (remaining > 0) {
|
|
764
|
-
consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
|
|
765
|
-
await sleep(remaining);
|
|
766
|
-
consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
|
|
767
|
-
}
|
|
768
|
-
const validated = await validateWithSchema(
|
|
769
|
-
parsed,
|
|
770
|
-
raw,
|
|
771
|
-
originalTopic,
|
|
772
|
-
schemaMap,
|
|
773
|
-
interceptors,
|
|
774
|
-
dlq,
|
|
775
|
-
{ ...deps, originalHeaders: headers }
|
|
776
|
-
);
|
|
777
|
-
if (validated === null) return;
|
|
778
|
-
const envelope = extractEnvelope(
|
|
779
|
-
validated,
|
|
780
|
-
headers,
|
|
781
|
-
originalTopic,
|
|
782
|
-
partition,
|
|
783
|
-
message.offset
|
|
784
|
-
);
|
|
785
|
-
try {
|
|
786
|
-
const cleanups = [];
|
|
787
|
-
for (const inst of this.instrumentation) {
|
|
788
|
-
const c = inst.beforeConsume?.(envelope);
|
|
789
|
-
if (typeof c === "function") cleanups.push(c);
|
|
790
|
-
}
|
|
791
|
-
for (const interceptor of interceptors)
|
|
792
|
-
await interceptor.before?.(envelope);
|
|
793
|
-
await runWithEnvelopeContext(
|
|
794
|
-
{
|
|
795
|
-
correlationId: envelope.correlationId,
|
|
796
|
-
traceparent: envelope.traceparent
|
|
797
|
-
},
|
|
798
|
-
() => handleMessage(envelope)
|
|
799
|
-
);
|
|
800
|
-
for (const interceptor of interceptors)
|
|
801
|
-
await interceptor.after?.(envelope);
|
|
802
|
-
for (const cleanup of cleanups) cleanup();
|
|
803
|
-
} catch (error) {
|
|
804
|
-
const err = toError(error);
|
|
805
|
-
const nextAttempt = currentAttempt + 1;
|
|
806
|
-
const exhausted = currentAttempt >= maxRetries;
|
|
807
|
-
for (const inst of this.instrumentation)
|
|
808
|
-
inst.onConsumeError?.(envelope, err);
|
|
809
|
-
const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(
|
|
810
|
-
originalTopic,
|
|
811
|
-
[envelope.payload],
|
|
812
|
-
maxRetries,
|
|
813
|
-
{ cause: err }
|
|
814
|
-
) : err;
|
|
815
|
-
for (const interceptor of interceptors) {
|
|
816
|
-
await interceptor.onError?.(envelope, reportedError);
|
|
817
|
-
}
|
|
818
|
-
this.logger.error(
|
|
819
|
-
`Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
|
|
820
|
-
err.stack
|
|
821
|
-
);
|
|
822
|
-
if (!exhausted) {
|
|
823
|
-
const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
|
|
824
|
-
const delay = Math.floor(Math.random() * cap);
|
|
825
|
-
await sendToRetryTopic(
|
|
826
|
-
originalTopic,
|
|
827
|
-
[raw],
|
|
828
|
-
nextAttempt,
|
|
829
|
-
maxRetries,
|
|
830
|
-
delay,
|
|
831
|
-
headers,
|
|
832
|
-
deps
|
|
833
|
-
);
|
|
834
|
-
} else if (dlq) {
|
|
835
|
-
await sendToDlq(originalTopic, raw, deps, {
|
|
836
|
-
error: err,
|
|
837
|
-
// +1 to account for the main consumer's initial attempt before
|
|
838
|
-
// routing to the retry topic, making this consistent with the
|
|
839
|
-
// in-process retry path where attempt counts all tries.
|
|
840
|
-
attempt: currentAttempt + 1,
|
|
841
|
-
originalHeaders: headers
|
|
842
|
-
});
|
|
843
|
-
} else {
|
|
844
|
-
await deps.onMessageLost?.({
|
|
845
|
-
topic: originalTopic,
|
|
846
|
-
error: err,
|
|
847
|
-
attempt: currentAttempt,
|
|
848
|
-
headers
|
|
849
|
-
});
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
});
|
|
854
|
-
this.runningConsumers.set(retryGroupId, "eachMessage");
|
|
855
|
-
await this.waitForPartitionAssignment(consumer, retryTopicNames);
|
|
856
|
-
this.logger.log(
|
|
857
|
-
`Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
|
|
858
|
-
);
|
|
859
|
-
}
|
|
860
996
|
// ── Private helpers ──────────────────────────────────────────────
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
* missed, which is the same behaviour as before this guard was added.
|
|
867
|
-
*/
|
|
868
|
-
async waitForPartitionAssignment(consumer, topics, timeoutMs = 1e4) {
|
|
869
|
-
const topicSet = new Set(topics);
|
|
870
|
-
const deadline = Date.now() + timeoutMs;
|
|
871
|
-
while (Date.now() < deadline) {
|
|
872
|
-
try {
|
|
873
|
-
const assigned = consumer.assignment();
|
|
874
|
-
if (assigned.some((a) => topicSet.has(a.topic))) return;
|
|
875
|
-
} catch {
|
|
876
|
-
}
|
|
877
|
-
await sleep(200);
|
|
878
|
-
}
|
|
879
|
-
this.logger.warn(
|
|
880
|
-
`Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
|
|
997
|
+
async preparePayload(topicOrDesc, messages) {
|
|
998
|
+
const payload = await buildSendPayload(
|
|
999
|
+
topicOrDesc,
|
|
1000
|
+
messages,
|
|
1001
|
+
this.producerOpsDeps
|
|
881
1002
|
);
|
|
1003
|
+
await this.ensureTopic(payload.topic);
|
|
1004
|
+
return payload;
|
|
882
1005
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (this.onRebalance) {
|
|
889
|
-
const onRebalance = this.onRebalance;
|
|
890
|
-
config["rebalance_cb"] = (err, assignment) => {
|
|
891
|
-
const type = err.code === -175 ? "assign" : "revoke";
|
|
892
|
-
try {
|
|
893
|
-
onRebalance(
|
|
894
|
-
type,
|
|
895
|
-
assignment.map((p) => ({
|
|
896
|
-
topic: p.topic,
|
|
897
|
-
partition: p.partition
|
|
898
|
-
}))
|
|
899
|
-
);
|
|
900
|
-
} catch (e) {
|
|
901
|
-
this.logger.warn(
|
|
902
|
-
`onRebalance callback threw: ${e.message}`
|
|
903
|
-
);
|
|
904
|
-
}
|
|
905
|
-
};
|
|
1006
|
+
// afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
|
|
1007
|
+
notifyAfterSend(topic2, count) {
|
|
1008
|
+
for (let i = 0; i < count; i++) {
|
|
1009
|
+
for (const inst of this.instrumentation) {
|
|
1010
|
+
inst.afterSend?.(topic2);
|
|
906
1011
|
}
|
|
907
|
-
this.consumers.set(groupId, this.kafka.consumer(config));
|
|
908
1012
|
}
|
|
909
|
-
return this.consumers.get(groupId);
|
|
910
1013
|
}
|
|
911
1014
|
/**
|
|
912
1015
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
@@ -924,13 +1027,6 @@ var KafkaClient = class {
|
|
|
924
1027
|
}, timeoutMs);
|
|
925
1028
|
return promise;
|
|
926
1029
|
}
|
|
927
|
-
resolveTopicName(topicOrDescriptor) {
|
|
928
|
-
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
929
|
-
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
930
|
-
return topicOrDescriptor.__topic;
|
|
931
|
-
}
|
|
932
|
-
return String(topicOrDescriptor);
|
|
933
|
-
}
|
|
934
1030
|
async ensureTopic(topic2) {
|
|
935
1031
|
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
936
1032
|
if (!this.isAdminConnected) {
|
|
@@ -942,54 +1038,6 @@ var KafkaClient = class {
|
|
|
942
1038
|
});
|
|
943
1039
|
this.ensuredTopics.add(topic2);
|
|
944
1040
|
}
|
|
945
|
-
/** Register schema from descriptor into global registry (side-effect). */
|
|
946
|
-
registerSchema(topicOrDesc) {
|
|
947
|
-
if (topicOrDesc?.__schema) {
|
|
948
|
-
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
949
|
-
this.schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
/** Validate message against schema. Pure — no side-effects on registry. */
|
|
953
|
-
async validateMessage(topicOrDesc, message) {
|
|
954
|
-
if (topicOrDesc?.__schema) {
|
|
955
|
-
return await topicOrDesc.__schema.parse(message);
|
|
956
|
-
}
|
|
957
|
-
if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
958
|
-
const schema = this.schemaRegistry.get(topicOrDesc);
|
|
959
|
-
if (schema) return await schema.parse(message);
|
|
960
|
-
}
|
|
961
|
-
return message;
|
|
962
|
-
}
|
|
963
|
-
/**
|
|
964
|
-
* Build a kafkajs-ready send payload.
|
|
965
|
-
* Handles: topic resolution, schema registration, validation, JSON serialization,
|
|
966
|
-
* envelope header generation, and instrumentation hooks.
|
|
967
|
-
*/
|
|
968
|
-
async buildSendPayload(topicOrDesc, messages) {
|
|
969
|
-
this.registerSchema(topicOrDesc);
|
|
970
|
-
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
971
|
-
const builtMessages = await Promise.all(
|
|
972
|
-
messages.map(async (m) => {
|
|
973
|
-
const envelopeHeaders = buildEnvelopeHeaders({
|
|
974
|
-
correlationId: m.correlationId,
|
|
975
|
-
schemaVersion: m.schemaVersion,
|
|
976
|
-
eventId: m.eventId,
|
|
977
|
-
headers: m.headers
|
|
978
|
-
});
|
|
979
|
-
for (const inst of this.instrumentation) {
|
|
980
|
-
inst.beforeSend?.(topic2, envelopeHeaders);
|
|
981
|
-
}
|
|
982
|
-
return {
|
|
983
|
-
value: JSON.stringify(
|
|
984
|
-
await this.validateMessage(topicOrDesc, m.value)
|
|
985
|
-
),
|
|
986
|
-
key: m.key ?? null,
|
|
987
|
-
headers: envelopeHeaders
|
|
988
|
-
};
|
|
989
|
-
})
|
|
990
|
-
);
|
|
991
|
-
return { topic: topic2, messages: builtMessages };
|
|
992
|
-
}
|
|
993
1041
|
/** Shared consumer setup: groupId check, schema map, connect, subscribe. */
|
|
994
1042
|
async setupConsumer(topics, mode, options) {
|
|
995
1043
|
const {
|
|
@@ -1008,15 +1056,18 @@ var KafkaClient = class {
|
|
|
1008
1056
|
`Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
|
|
1009
1057
|
);
|
|
1010
1058
|
}
|
|
1011
|
-
const consumer =
|
|
1059
|
+
const consumer = getOrCreateConsumer(
|
|
1012
1060
|
gid,
|
|
1013
1061
|
fromBeginning,
|
|
1014
|
-
options.autoCommit ?? true
|
|
1062
|
+
options.autoCommit ?? true,
|
|
1063
|
+
this.consumerOpsDeps
|
|
1015
1064
|
);
|
|
1016
|
-
const schemaMap =
|
|
1017
|
-
|
|
1018
|
-
|
|
1065
|
+
const schemaMap = buildSchemaMap(
|
|
1066
|
+
topics,
|
|
1067
|
+
this.schemaRegistry,
|
|
1068
|
+
optionSchemas
|
|
1019
1069
|
);
|
|
1070
|
+
const topicNames = topics.map((t) => resolveTopicName(t));
|
|
1020
1071
|
for (const t of topicNames) {
|
|
1021
1072
|
await this.ensureTopic(t);
|
|
1022
1073
|
}
|
|
@@ -1037,26 +1088,45 @@ var KafkaClient = class {
|
|
|
1037
1088
|
);
|
|
1038
1089
|
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
|
|
1039
1090
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1091
|
+
// ── Deps object getters ──────────────────────────────────────────
|
|
1092
|
+
get producerOpsDeps() {
|
|
1093
|
+
return {
|
|
1094
|
+
schemaRegistry: this.schemaRegistry,
|
|
1095
|
+
strictSchemasEnabled: this.strictSchemasEnabled,
|
|
1096
|
+
instrumentation: this.instrumentation
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
get consumerOpsDeps() {
|
|
1100
|
+
return {
|
|
1101
|
+
consumers: this.consumers,
|
|
1102
|
+
consumerCreationOptions: this.consumerCreationOptions,
|
|
1103
|
+
kafka: this.kafka,
|
|
1104
|
+
onRebalance: this.onRebalance,
|
|
1105
|
+
logger: this.logger
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
get messageDeps() {
|
|
1109
|
+
return {
|
|
1110
|
+
logger: this.logger,
|
|
1111
|
+
producer: this.producer,
|
|
1112
|
+
instrumentation: this.instrumentation,
|
|
1113
|
+
onMessageLost: this.onMessageLost
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
get retryTopicDeps() {
|
|
1117
|
+
return {
|
|
1118
|
+
logger: this.logger,
|
|
1119
|
+
producer: this.producer,
|
|
1120
|
+
instrumentation: this.instrumentation,
|
|
1121
|
+
onMessageLost: this.onMessageLost,
|
|
1122
|
+
ensureTopic: (t) => this.ensureTopic(t),
|
|
1123
|
+
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
|
|
1124
|
+
runningConsumers: this.runningConsumers
|
|
1125
|
+
};
|
|
1056
1126
|
}
|
|
1057
1127
|
};
|
|
1058
1128
|
|
|
1059
|
-
// src/client/topic.ts
|
|
1129
|
+
// src/client/message/topic.ts
|
|
1060
1130
|
function topic(name) {
|
|
1061
1131
|
const fn = () => ({
|
|
1062
1132
|
__topic: name,
|
|
@@ -1087,4 +1157,4 @@ export {
|
|
|
1087
1157
|
KafkaClient,
|
|
1088
1158
|
topic
|
|
1089
1159
|
};
|
|
1090
|
-
//# sourceMappingURL=chunk-
|
|
1160
|
+
//# sourceMappingURL=chunk-6B72MJPU.mjs.map
|