@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
package/dist/index.js
CHANGED
|
@@ -55,10 +55,10 @@ __export(index_exports, {
|
|
|
55
55
|
});
|
|
56
56
|
module.exports = __toCommonJS(index_exports);
|
|
57
57
|
|
|
58
|
-
// src/client/kafka.client.ts
|
|
58
|
+
// src/client/kafka.client/index.ts
|
|
59
59
|
var import_kafka_javascript = require("@confluentinc/kafka-javascript");
|
|
60
60
|
|
|
61
|
-
// src/client/envelope.ts
|
|
61
|
+
// src/client/message/envelope.ts
|
|
62
62
|
var import_node_async_hooks = require("async_hooks");
|
|
63
63
|
var import_node_crypto = require("crypto");
|
|
64
64
|
var HEADER_EVENT_ID = "x-event-id";
|
|
@@ -118,6 +118,108 @@ function extractEnvelope(payload, headers, topic2, partition, offset) {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// src/client/kafka.client/producer-ops.ts
|
|
122
|
+
function resolveTopicName(topicOrDescriptor) {
|
|
123
|
+
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
124
|
+
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
125
|
+
return topicOrDescriptor.__topic;
|
|
126
|
+
}
|
|
127
|
+
return String(topicOrDescriptor);
|
|
128
|
+
}
|
|
129
|
+
function registerSchema(topicOrDesc, schemaRegistry) {
|
|
130
|
+
if (topicOrDesc?.__schema) {
|
|
131
|
+
const topic2 = resolveTopicName(topicOrDesc);
|
|
132
|
+
schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function validateMessage(topicOrDesc, message, deps) {
|
|
136
|
+
if (topicOrDesc?.__schema) {
|
|
137
|
+
return await topicOrDesc.__schema.parse(message);
|
|
138
|
+
}
|
|
139
|
+
if (deps.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
140
|
+
const schema = deps.schemaRegistry.get(topicOrDesc);
|
|
141
|
+
if (schema) return await schema.parse(message);
|
|
142
|
+
}
|
|
143
|
+
return message;
|
|
144
|
+
}
|
|
145
|
+
async function buildSendPayload(topicOrDesc, messages, deps) {
|
|
146
|
+
registerSchema(topicOrDesc, deps.schemaRegistry);
|
|
147
|
+
const topic2 = resolveTopicName(topicOrDesc);
|
|
148
|
+
const builtMessages = await Promise.all(
|
|
149
|
+
messages.map(async (m) => {
|
|
150
|
+
const envelopeHeaders = buildEnvelopeHeaders({
|
|
151
|
+
correlationId: m.correlationId,
|
|
152
|
+
schemaVersion: m.schemaVersion,
|
|
153
|
+
eventId: m.eventId,
|
|
154
|
+
headers: m.headers
|
|
155
|
+
});
|
|
156
|
+
for (const inst of deps.instrumentation) {
|
|
157
|
+
inst.beforeSend?.(topic2, envelopeHeaders);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
value: JSON.stringify(
|
|
161
|
+
await validateMessage(topicOrDesc, m.value, deps)
|
|
162
|
+
),
|
|
163
|
+
key: m.key ?? null,
|
|
164
|
+
headers: envelopeHeaders
|
|
165
|
+
};
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
return { topic: topic2, messages: builtMessages };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/client/kafka.client/consumer-ops.ts
|
|
172
|
+
function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps) {
|
|
173
|
+
const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
|
|
174
|
+
if (consumers.has(groupId)) {
|
|
175
|
+
const prev = consumerCreationOptions.get(groupId);
|
|
176
|
+
if (prev.fromBeginning !== fromBeginning || prev.autoCommit !== autoCommit) {
|
|
177
|
+
logger.warn(
|
|
178
|
+
`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.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return consumers.get(groupId);
|
|
182
|
+
}
|
|
183
|
+
consumerCreationOptions.set(groupId, { fromBeginning, autoCommit });
|
|
184
|
+
const config = {
|
|
185
|
+
kafkaJS: { groupId, fromBeginning, autoCommit }
|
|
186
|
+
};
|
|
187
|
+
if (onRebalance) {
|
|
188
|
+
const cb = onRebalance;
|
|
189
|
+
config["rebalance_cb"] = (err, assignment) => {
|
|
190
|
+
const type = err.code === -175 ? "assign" : "revoke";
|
|
191
|
+
try {
|
|
192
|
+
cb(
|
|
193
|
+
type,
|
|
194
|
+
assignment.map((p) => ({ topic: p.topic, partition: p.partition }))
|
|
195
|
+
);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
logger.warn(`onRebalance callback threw: ${e.message}`);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const consumer = kafka.consumer(config);
|
|
202
|
+
consumers.set(groupId, consumer);
|
|
203
|
+
return consumer;
|
|
204
|
+
}
|
|
205
|
+
function buildSchemaMap(topics, schemaRegistry, optionSchemas) {
|
|
206
|
+
const schemaMap = /* @__PURE__ */ new Map();
|
|
207
|
+
for (const t of topics) {
|
|
208
|
+
if (t?.__schema) {
|
|
209
|
+
const name = resolveTopicName(t);
|
|
210
|
+
schemaMap.set(name, t.__schema);
|
|
211
|
+
schemaRegistry.set(name, t.__schema);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (optionSchemas) {
|
|
215
|
+
for (const [k, v] of optionSchemas) {
|
|
216
|
+
schemaMap.set(k, v);
|
|
217
|
+
schemaRegistry.set(k, v);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return schemaMap;
|
|
221
|
+
}
|
|
222
|
+
|
|
121
223
|
// src/client/errors.ts
|
|
122
224
|
var KafkaProcessingError = class extends Error {
|
|
123
225
|
constructor(message, topic2, originalMessage, options) {
|
|
@@ -150,7 +252,7 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
|
|
|
150
252
|
}
|
|
151
253
|
};
|
|
152
254
|
|
|
153
|
-
// src/client/consumer
|
|
255
|
+
// src/client/consumer/pipeline.ts
|
|
154
256
|
function toError(error) {
|
|
155
257
|
return error instanceof Error ? error : new Error(String(error));
|
|
156
258
|
}
|
|
@@ -269,6 +371,53 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
|
|
|
269
371
|
);
|
|
270
372
|
}
|
|
271
373
|
}
|
|
374
|
+
async function broadcastToInterceptors(envelopes, interceptors, cb) {
|
|
375
|
+
for (const env of envelopes) {
|
|
376
|
+
for (const interceptor of interceptors) {
|
|
377
|
+
await cb(interceptor, env);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function runHandlerWithPipeline(fn, envelopes, interceptors, instrumentation) {
|
|
382
|
+
const cleanups = [];
|
|
383
|
+
try {
|
|
384
|
+
for (const env of envelopes) {
|
|
385
|
+
for (const inst of instrumentation) {
|
|
386
|
+
const cleanup = inst.beforeConsume?.(env);
|
|
387
|
+
if (typeof cleanup === "function") cleanups.push(cleanup);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const env of envelopes) {
|
|
391
|
+
for (const interceptor of interceptors) {
|
|
392
|
+
await interceptor.before?.(env);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
await fn();
|
|
396
|
+
for (const env of envelopes) {
|
|
397
|
+
for (const interceptor of interceptors) {
|
|
398
|
+
await interceptor.after?.(env);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const cleanup of cleanups) cleanup();
|
|
402
|
+
return null;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const err = toError(error);
|
|
405
|
+
for (const env of envelopes) {
|
|
406
|
+
for (const inst of instrumentation) {
|
|
407
|
+
inst.onConsumeError?.(env, err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const cleanup of cleanups) cleanup();
|
|
411
|
+
return err;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function notifyInterceptorsOnError(envelopes, interceptors, error) {
|
|
415
|
+
await broadcastToInterceptors(
|
|
416
|
+
envelopes,
|
|
417
|
+
interceptors,
|
|
418
|
+
(i, env) => i.onError?.(env, error)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
272
421
|
async function executeWithRetry(fn, ctx, deps) {
|
|
273
422
|
const {
|
|
274
423
|
envelope,
|
|
@@ -285,98 +434,181 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
285
434
|
const envelopes = Array.isArray(envelope) ? envelope : [envelope];
|
|
286
435
|
const topic2 = envelopes[0]?.topic ?? "unknown";
|
|
287
436
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (isLastAttempt && maxAttempts > 1) {
|
|
319
|
-
const exhaustedError = new KafkaRetryExhaustedError(
|
|
320
|
-
topic2,
|
|
321
|
-
envelopes.map((e) => e.payload),
|
|
322
|
-
maxAttempts,
|
|
323
|
-
{ cause: err }
|
|
324
|
-
);
|
|
325
|
-
for (const env of envelopes) {
|
|
326
|
-
for (const interceptor of interceptors) {
|
|
327
|
-
await interceptor.onError?.(env, exhaustedError);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
} else {
|
|
331
|
-
for (const env of envelopes) {
|
|
332
|
-
for (const interceptor of interceptors) {
|
|
333
|
-
await interceptor.onError?.(env, err);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
deps.logger.error(
|
|
338
|
-
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
339
|
-
err.stack
|
|
437
|
+
const error = await runHandlerWithPipeline(
|
|
438
|
+
fn,
|
|
439
|
+
envelopes,
|
|
440
|
+
interceptors,
|
|
441
|
+
deps.instrumentation
|
|
442
|
+
);
|
|
443
|
+
if (!error) return;
|
|
444
|
+
const isLastAttempt = attempt === maxAttempts;
|
|
445
|
+
const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
|
|
446
|
+
topic2,
|
|
447
|
+
envelopes.map((e) => e.payload),
|
|
448
|
+
maxAttempts,
|
|
449
|
+
{ cause: error }
|
|
450
|
+
) : error;
|
|
451
|
+
await notifyInterceptorsOnError(envelopes, interceptors, reportedError);
|
|
452
|
+
deps.logger.error(
|
|
453
|
+
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
454
|
+
error.stack
|
|
455
|
+
);
|
|
456
|
+
if (retryTopics && retry) {
|
|
457
|
+
const cap = Math.min(backoffMs, maxBackoffMs);
|
|
458
|
+
const delay = Math.floor(Math.random() * cap);
|
|
459
|
+
await sendToRetryTopic(
|
|
460
|
+
topic2,
|
|
461
|
+
rawMessages,
|
|
462
|
+
1,
|
|
463
|
+
retry.maxRetries,
|
|
464
|
+
delay,
|
|
465
|
+
envelopes[0]?.headers ?? {},
|
|
466
|
+
deps
|
|
340
467
|
);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
envelopes[0]?.headers ?? {},
|
|
351
|
-
deps
|
|
352
|
-
);
|
|
353
|
-
} else if (isLastAttempt) {
|
|
354
|
-
if (dlq) {
|
|
355
|
-
const dlqMeta = {
|
|
356
|
-
error: err,
|
|
357
|
-
attempt,
|
|
358
|
-
originalHeaders: envelopes[0]?.headers
|
|
359
|
-
};
|
|
360
|
-
for (const raw of rawMessages) {
|
|
361
|
-
await sendToDlq(topic2, raw, deps, dlqMeta);
|
|
362
|
-
}
|
|
363
|
-
} else {
|
|
364
|
-
await deps.onMessageLost?.({
|
|
365
|
-
topic: topic2,
|
|
366
|
-
error: err,
|
|
367
|
-
attempt,
|
|
368
|
-
headers: envelopes[0]?.headers ?? {}
|
|
369
|
-
});
|
|
468
|
+
} else if (isLastAttempt) {
|
|
469
|
+
if (dlq) {
|
|
470
|
+
const dlqMeta = {
|
|
471
|
+
error,
|
|
472
|
+
attempt,
|
|
473
|
+
originalHeaders: envelopes[0]?.headers
|
|
474
|
+
};
|
|
475
|
+
for (const raw of rawMessages) {
|
|
476
|
+
await sendToDlq(topic2, raw, deps, dlqMeta);
|
|
370
477
|
}
|
|
371
478
|
} else {
|
|
372
|
-
|
|
373
|
-
|
|
479
|
+
await deps.onMessageLost?.({
|
|
480
|
+
topic: topic2,
|
|
481
|
+
error,
|
|
482
|
+
attempt,
|
|
483
|
+
headers: envelopes[0]?.headers ?? {}
|
|
484
|
+
});
|
|
374
485
|
}
|
|
486
|
+
} else {
|
|
487
|
+
const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
|
|
488
|
+
await sleep(Math.random() * cap);
|
|
375
489
|
}
|
|
376
490
|
}
|
|
377
491
|
}
|
|
378
492
|
|
|
379
|
-
// src/client/
|
|
493
|
+
// src/client/kafka.client/message-handler.ts
|
|
494
|
+
async function parseSingleMessage(message, topic2, partition, schemaMap, interceptors, dlq, deps) {
|
|
495
|
+
if (!message.value) {
|
|
496
|
+
deps.logger.warn(`Received empty message from topic ${topic2}`);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
const raw = message.value.toString();
|
|
500
|
+
const parsed = parseJsonMessage(raw, topic2, deps.logger);
|
|
501
|
+
if (parsed === null) return null;
|
|
502
|
+
const headers = decodeHeaders(message.headers);
|
|
503
|
+
const validated = await validateWithSchema(
|
|
504
|
+
parsed,
|
|
505
|
+
raw,
|
|
506
|
+
topic2,
|
|
507
|
+
schemaMap,
|
|
508
|
+
interceptors,
|
|
509
|
+
dlq,
|
|
510
|
+
{ ...deps, originalHeaders: headers }
|
|
511
|
+
);
|
|
512
|
+
if (validated === null) return null;
|
|
513
|
+
return extractEnvelope(validated, headers, topic2, partition, message.offset);
|
|
514
|
+
}
|
|
515
|
+
async function handleEachMessage(payload, opts, deps) {
|
|
516
|
+
const { topic: topic2, partition, message } = payload;
|
|
517
|
+
const {
|
|
518
|
+
schemaMap,
|
|
519
|
+
handleMessage,
|
|
520
|
+
interceptors,
|
|
521
|
+
dlq,
|
|
522
|
+
retry,
|
|
523
|
+
retryTopics,
|
|
524
|
+
timeoutMs,
|
|
525
|
+
wrapWithTimeout
|
|
526
|
+
} = opts;
|
|
527
|
+
const envelope = await parseSingleMessage(
|
|
528
|
+
message,
|
|
529
|
+
topic2,
|
|
530
|
+
partition,
|
|
531
|
+
schemaMap,
|
|
532
|
+
interceptors,
|
|
533
|
+
dlq,
|
|
534
|
+
deps
|
|
535
|
+
);
|
|
536
|
+
if (envelope === null) return;
|
|
537
|
+
await executeWithRetry(
|
|
538
|
+
() => {
|
|
539
|
+
const fn = () => runWithEnvelopeContext(
|
|
540
|
+
{
|
|
541
|
+
correlationId: envelope.correlationId,
|
|
542
|
+
traceparent: envelope.traceparent
|
|
543
|
+
},
|
|
544
|
+
() => handleMessage(envelope)
|
|
545
|
+
);
|
|
546
|
+
return timeoutMs ? wrapWithTimeout(fn, timeoutMs, topic2) : fn();
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
envelope,
|
|
550
|
+
rawMessages: [message.value.toString()],
|
|
551
|
+
interceptors,
|
|
552
|
+
dlq,
|
|
553
|
+
retry,
|
|
554
|
+
retryTopics
|
|
555
|
+
},
|
|
556
|
+
deps
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
async function handleEachBatch(payload, opts, deps) {
|
|
560
|
+
const { batch, heartbeat, resolveOffset, commitOffsetsIfNecessary } = payload;
|
|
561
|
+
const {
|
|
562
|
+
schemaMap,
|
|
563
|
+
handleBatch,
|
|
564
|
+
interceptors,
|
|
565
|
+
dlq,
|
|
566
|
+
retry,
|
|
567
|
+
timeoutMs,
|
|
568
|
+
wrapWithTimeout
|
|
569
|
+
} = opts;
|
|
570
|
+
const envelopes = [];
|
|
571
|
+
const rawMessages = [];
|
|
572
|
+
for (const message of batch.messages) {
|
|
573
|
+
const envelope = await parseSingleMessage(
|
|
574
|
+
message,
|
|
575
|
+
batch.topic,
|
|
576
|
+
batch.partition,
|
|
577
|
+
schemaMap,
|
|
578
|
+
interceptors,
|
|
579
|
+
dlq,
|
|
580
|
+
deps
|
|
581
|
+
);
|
|
582
|
+
if (envelope === null) continue;
|
|
583
|
+
envelopes.push(envelope);
|
|
584
|
+
rawMessages.push(message.value.toString());
|
|
585
|
+
}
|
|
586
|
+
if (envelopes.length === 0) return;
|
|
587
|
+
const meta = {
|
|
588
|
+
partition: batch.partition,
|
|
589
|
+
highWatermark: batch.highWatermark,
|
|
590
|
+
heartbeat,
|
|
591
|
+
resolveOffset,
|
|
592
|
+
commitOffsetsIfNecessary
|
|
593
|
+
};
|
|
594
|
+
await executeWithRetry(
|
|
595
|
+
() => {
|
|
596
|
+
const fn = () => handleBatch(envelopes, meta);
|
|
597
|
+
return timeoutMs ? wrapWithTimeout(fn, timeoutMs, batch.topic) : fn();
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
envelope: envelopes,
|
|
601
|
+
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
602
|
+
interceptors,
|
|
603
|
+
dlq,
|
|
604
|
+
retry,
|
|
605
|
+
isBatch: true
|
|
606
|
+
},
|
|
607
|
+
deps
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/client/consumer/subscribe-retry.ts
|
|
380
612
|
async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
381
613
|
const maxAttempts = retryOpts?.retries ?? 5;
|
|
382
614
|
const backoffMs = retryOpts?.backoffMs ?? 5e3;
|
|
@@ -395,7 +627,156 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
|
395
627
|
}
|
|
396
628
|
}
|
|
397
629
|
|
|
398
|
-
// src/client/kafka.client.ts
|
|
630
|
+
// src/client/kafka.client/retry-topic.ts
|
|
631
|
+
async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
|
|
632
|
+
const topicSet = new Set(topics);
|
|
633
|
+
const deadline = Date.now() + timeoutMs;
|
|
634
|
+
while (Date.now() < deadline) {
|
|
635
|
+
try {
|
|
636
|
+
const assigned = consumer.assignment();
|
|
637
|
+
if (assigned.some((a) => topicSet.has(a.topic))) return;
|
|
638
|
+
} catch {
|
|
639
|
+
}
|
|
640
|
+
await sleep(200);
|
|
641
|
+
}
|
|
642
|
+
logger.warn(
|
|
643
|
+
`Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
async function startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap, deps) {
|
|
647
|
+
const {
|
|
648
|
+
logger,
|
|
649
|
+
producer,
|
|
650
|
+
instrumentation,
|
|
651
|
+
onMessageLost,
|
|
652
|
+
ensureTopic,
|
|
653
|
+
getOrCreateConsumer: getOrCreateConsumer2,
|
|
654
|
+
runningConsumers
|
|
655
|
+
} = deps;
|
|
656
|
+
const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
|
|
657
|
+
const retryGroupId = `${originalGroupId}-retry`;
|
|
658
|
+
const backoffMs = retry.backoffMs ?? 1e3;
|
|
659
|
+
const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
|
|
660
|
+
const pipelineDeps = { logger, producer, instrumentation, onMessageLost };
|
|
661
|
+
for (const rt of retryTopicNames) {
|
|
662
|
+
await ensureTopic(rt);
|
|
663
|
+
}
|
|
664
|
+
const consumer = getOrCreateConsumer2(retryGroupId, false, true);
|
|
665
|
+
await consumer.connect();
|
|
666
|
+
await subscribeWithRetry(consumer, retryTopicNames, logger);
|
|
667
|
+
await consumer.run({
|
|
668
|
+
eachMessage: async ({ topic: retryTopic, partition, message }) => {
|
|
669
|
+
if (!message.value) return;
|
|
670
|
+
const raw = message.value.toString();
|
|
671
|
+
const parsed = parseJsonMessage(raw, retryTopic, logger);
|
|
672
|
+
if (parsed === null) return;
|
|
673
|
+
const headers = decodeHeaders(message.headers);
|
|
674
|
+
const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
|
|
675
|
+
const currentAttempt = parseInt(
|
|
676
|
+
headers[RETRY_HEADER_ATTEMPT] ?? "1",
|
|
677
|
+
10
|
|
678
|
+
);
|
|
679
|
+
const maxRetries = parseInt(
|
|
680
|
+
headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
|
|
681
|
+
10
|
|
682
|
+
);
|
|
683
|
+
const retryAfter = parseInt(
|
|
684
|
+
headers[RETRY_HEADER_AFTER] ?? "0",
|
|
685
|
+
10
|
|
686
|
+
);
|
|
687
|
+
const remaining = retryAfter - Date.now();
|
|
688
|
+
if (remaining > 0) {
|
|
689
|
+
consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
|
|
690
|
+
await sleep(remaining);
|
|
691
|
+
consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
|
|
692
|
+
}
|
|
693
|
+
const validated = await validateWithSchema(
|
|
694
|
+
parsed,
|
|
695
|
+
raw,
|
|
696
|
+
originalTopic,
|
|
697
|
+
schemaMap,
|
|
698
|
+
interceptors,
|
|
699
|
+
dlq,
|
|
700
|
+
{ ...pipelineDeps, originalHeaders: headers }
|
|
701
|
+
);
|
|
702
|
+
if (validated === null) return;
|
|
703
|
+
const envelope = extractEnvelope(
|
|
704
|
+
validated,
|
|
705
|
+
headers,
|
|
706
|
+
originalTopic,
|
|
707
|
+
partition,
|
|
708
|
+
message.offset
|
|
709
|
+
);
|
|
710
|
+
const error = await runHandlerWithPipeline(
|
|
711
|
+
() => runWithEnvelopeContext(
|
|
712
|
+
{
|
|
713
|
+
correlationId: envelope.correlationId,
|
|
714
|
+
traceparent: envelope.traceparent
|
|
715
|
+
},
|
|
716
|
+
() => handleMessage(envelope)
|
|
717
|
+
),
|
|
718
|
+
[envelope],
|
|
719
|
+
interceptors,
|
|
720
|
+
instrumentation
|
|
721
|
+
);
|
|
722
|
+
if (error) {
|
|
723
|
+
const nextAttempt = currentAttempt + 1;
|
|
724
|
+
const exhausted = currentAttempt >= maxRetries;
|
|
725
|
+
const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(
|
|
726
|
+
originalTopic,
|
|
727
|
+
[envelope.payload],
|
|
728
|
+
maxRetries,
|
|
729
|
+
{ cause: error }
|
|
730
|
+
) : error;
|
|
731
|
+
await notifyInterceptorsOnError(
|
|
732
|
+
[envelope],
|
|
733
|
+
interceptors,
|
|
734
|
+
reportedError
|
|
735
|
+
);
|
|
736
|
+
logger.error(
|
|
737
|
+
`Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
|
|
738
|
+
error.stack
|
|
739
|
+
);
|
|
740
|
+
if (!exhausted) {
|
|
741
|
+
const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
|
|
742
|
+
const delay = Math.floor(Math.random() * cap);
|
|
743
|
+
await sendToRetryTopic(
|
|
744
|
+
originalTopic,
|
|
745
|
+
[raw],
|
|
746
|
+
nextAttempt,
|
|
747
|
+
maxRetries,
|
|
748
|
+
delay,
|
|
749
|
+
headers,
|
|
750
|
+
pipelineDeps
|
|
751
|
+
);
|
|
752
|
+
} else if (dlq) {
|
|
753
|
+
await sendToDlq(originalTopic, raw, pipelineDeps, {
|
|
754
|
+
error,
|
|
755
|
+
// +1 to account for the main consumer's initial attempt before
|
|
756
|
+
// routing to the retry topic, making this consistent with the
|
|
757
|
+
// in-process retry path where attempt counts all tries.
|
|
758
|
+
attempt: currentAttempt + 1,
|
|
759
|
+
originalHeaders: headers
|
|
760
|
+
});
|
|
761
|
+
} else {
|
|
762
|
+
await onMessageLost?.({
|
|
763
|
+
topic: originalTopic,
|
|
764
|
+
error,
|
|
765
|
+
attempt: currentAttempt,
|
|
766
|
+
headers
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
runningConsumers.set(retryGroupId, "eachMessage");
|
|
773
|
+
await waitForPartitionAssignment(consumer, retryTopicNames, logger);
|
|
774
|
+
logger.log(
|
|
775
|
+
`Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/client/kafka.client/index.ts
|
|
399
780
|
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
|
|
400
781
|
var KafkaClient = class {
|
|
401
782
|
kafka;
|
|
@@ -411,6 +792,7 @@ var KafkaClient = class {
|
|
|
411
792
|
defaultGroupId;
|
|
412
793
|
schemaRegistry = /* @__PURE__ */ new Map();
|
|
413
794
|
runningConsumers = /* @__PURE__ */ new Map();
|
|
795
|
+
consumerCreationOptions = /* @__PURE__ */ new Map();
|
|
414
796
|
instrumentation;
|
|
415
797
|
onMessageLost;
|
|
416
798
|
onRebalance;
|
|
@@ -445,7 +827,7 @@ var KafkaClient = class {
|
|
|
445
827
|
this.admin = this.kafka.admin();
|
|
446
828
|
}
|
|
447
829
|
async sendMessage(topicOrDesc, message, options = {}) {
|
|
448
|
-
const payload = await this.
|
|
830
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
449
831
|
{
|
|
450
832
|
value: message,
|
|
451
833
|
key: options.key,
|
|
@@ -455,19 +837,13 @@ var KafkaClient = class {
|
|
|
455
837
|
eventId: options.eventId
|
|
456
838
|
}
|
|
457
839
|
]);
|
|
458
|
-
await this.ensureTopic(payload.topic);
|
|
459
840
|
await this.producer.send(payload);
|
|
460
|
-
|
|
461
|
-
inst.afterSend?.(payload.topic);
|
|
462
|
-
}
|
|
841
|
+
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
463
842
|
}
|
|
464
843
|
async sendBatch(topicOrDesc, messages) {
|
|
465
|
-
const payload = await this.
|
|
466
|
-
await this.ensureTopic(payload.topic);
|
|
844
|
+
const payload = await this.preparePayload(topicOrDesc, messages);
|
|
467
845
|
await this.producer.send(payload);
|
|
468
|
-
|
|
469
|
-
inst.afterSend?.(payload.topic);
|
|
470
|
-
}
|
|
846
|
+
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
471
847
|
}
|
|
472
848
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
473
849
|
async transaction(fn) {
|
|
@@ -486,7 +862,7 @@ var KafkaClient = class {
|
|
|
486
862
|
try {
|
|
487
863
|
const ctx = {
|
|
488
864
|
send: async (topicOrDesc, message, options = {}) => {
|
|
489
|
-
const payload = await this.
|
|
865
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
490
866
|
{
|
|
491
867
|
value: message,
|
|
492
868
|
key: options.key,
|
|
@@ -496,13 +872,10 @@ var KafkaClient = class {
|
|
|
496
872
|
eventId: options.eventId
|
|
497
873
|
}
|
|
498
874
|
]);
|
|
499
|
-
await this.ensureTopic(payload.topic);
|
|
500
875
|
await tx.send(payload);
|
|
501
876
|
},
|
|
502
877
|
sendBatch: async (topicOrDesc, messages) => {
|
|
503
|
-
|
|
504
|
-
await this.ensureTopic(payload.topic);
|
|
505
|
-
await tx.send(payload);
|
|
878
|
+
await tx.send(await this.preparePayload(topicOrDesc, messages));
|
|
506
879
|
}
|
|
507
880
|
};
|
|
508
881
|
await fn(ctx);
|
|
@@ -536,151 +909,57 @@ var KafkaClient = class {
|
|
|
536
909
|
);
|
|
537
910
|
}
|
|
538
911
|
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
|
|
539
|
-
const deps =
|
|
540
|
-
logger: this.logger,
|
|
541
|
-
producer: this.producer,
|
|
542
|
-
instrumentation: this.instrumentation,
|
|
543
|
-
onMessageLost: this.onMessageLost
|
|
544
|
-
};
|
|
912
|
+
const deps = this.messageDeps;
|
|
545
913
|
const timeoutMs = options.handlerTimeoutMs;
|
|
546
914
|
await consumer.run({
|
|
547
|
-
eachMessage:
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
const raw = message.value.toString();
|
|
553
|
-
const parsed = parseJsonMessage(raw, topic2, this.logger);
|
|
554
|
-
if (parsed === null) return;
|
|
555
|
-
const headers = decodeHeaders(message.headers);
|
|
556
|
-
const validated = await validateWithSchema(
|
|
557
|
-
parsed,
|
|
558
|
-
raw,
|
|
559
|
-
topic2,
|
|
915
|
+
eachMessage: (payload) => handleEachMessage(
|
|
916
|
+
payload,
|
|
917
|
+
{
|
|
560
918
|
schemaMap,
|
|
919
|
+
handleMessage,
|
|
561
920
|
interceptors,
|
|
562
921
|
dlq,
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
partition,
|
|
571
|
-
message.offset
|
|
572
|
-
);
|
|
573
|
-
await executeWithRetry(
|
|
574
|
-
() => {
|
|
575
|
-
const fn = () => runWithEnvelopeContext(
|
|
576
|
-
{
|
|
577
|
-
correlationId: envelope.correlationId,
|
|
578
|
-
traceparent: envelope.traceparent
|
|
579
|
-
},
|
|
580
|
-
() => handleMessage(envelope)
|
|
581
|
-
);
|
|
582
|
-
return timeoutMs ? this.wrapWithTimeoutWarning(fn, timeoutMs, topic2) : fn();
|
|
583
|
-
},
|
|
584
|
-
{
|
|
585
|
-
envelope,
|
|
586
|
-
rawMessages: [raw],
|
|
587
|
-
interceptors,
|
|
588
|
-
dlq,
|
|
589
|
-
retry,
|
|
590
|
-
retryTopics: options.retryTopics
|
|
591
|
-
},
|
|
592
|
-
deps
|
|
593
|
-
);
|
|
594
|
-
}
|
|
922
|
+
retry,
|
|
923
|
+
retryTopics: options.retryTopics,
|
|
924
|
+
timeoutMs,
|
|
925
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
|
|
926
|
+
},
|
|
927
|
+
deps
|
|
928
|
+
)
|
|
595
929
|
});
|
|
596
930
|
this.runningConsumers.set(gid, "eachMessage");
|
|
597
931
|
if (options.retryTopics && retry) {
|
|
598
|
-
await
|
|
932
|
+
await startRetryTopicConsumers(
|
|
599
933
|
topicNames,
|
|
600
934
|
gid,
|
|
601
935
|
handleMessage,
|
|
602
936
|
retry,
|
|
603
937
|
dlq,
|
|
604
938
|
interceptors,
|
|
605
|
-
schemaMap
|
|
939
|
+
schemaMap,
|
|
940
|
+
this.retryTopicDeps
|
|
606
941
|
);
|
|
607
942
|
}
|
|
608
943
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
609
944
|
}
|
|
610
945
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
611
946
|
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
|
|
612
|
-
const deps =
|
|
613
|
-
logger: this.logger,
|
|
614
|
-
producer: this.producer,
|
|
615
|
-
instrumentation: this.instrumentation,
|
|
616
|
-
onMessageLost: this.onMessageLost
|
|
617
|
-
};
|
|
947
|
+
const deps = this.messageDeps;
|
|
618
948
|
const timeoutMs = options.handlerTimeoutMs;
|
|
619
949
|
await consumer.run({
|
|
620
|
-
eachBatch:
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
const raw = message.value.toString();
|
|
636
|
-
const parsed = parseJsonMessage(raw, batch.topic, this.logger);
|
|
637
|
-
if (parsed === null) continue;
|
|
638
|
-
const headers = decodeHeaders(message.headers);
|
|
639
|
-
const validated = await validateWithSchema(
|
|
640
|
-
parsed,
|
|
641
|
-
raw,
|
|
642
|
-
batch.topic,
|
|
643
|
-
schemaMap,
|
|
644
|
-
interceptors,
|
|
645
|
-
dlq,
|
|
646
|
-
{ ...deps, originalHeaders: headers }
|
|
647
|
-
);
|
|
648
|
-
if (validated === null) continue;
|
|
649
|
-
envelopes.push(
|
|
650
|
-
extractEnvelope(
|
|
651
|
-
validated,
|
|
652
|
-
headers,
|
|
653
|
-
batch.topic,
|
|
654
|
-
batch.partition,
|
|
655
|
-
message.offset
|
|
656
|
-
)
|
|
657
|
-
);
|
|
658
|
-
rawMessages.push(raw);
|
|
659
|
-
}
|
|
660
|
-
if (envelopes.length === 0) return;
|
|
661
|
-
const meta = {
|
|
662
|
-
partition: batch.partition,
|
|
663
|
-
highWatermark: batch.highWatermark,
|
|
664
|
-
heartbeat,
|
|
665
|
-
resolveOffset,
|
|
666
|
-
commitOffsetsIfNecessary
|
|
667
|
-
};
|
|
668
|
-
await executeWithRetry(
|
|
669
|
-
() => {
|
|
670
|
-
const fn = () => handleBatch(envelopes, meta);
|
|
671
|
-
return timeoutMs ? this.wrapWithTimeoutWarning(fn, timeoutMs, batch.topic) : fn();
|
|
672
|
-
},
|
|
673
|
-
{
|
|
674
|
-
envelope: envelopes,
|
|
675
|
-
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
676
|
-
interceptors,
|
|
677
|
-
dlq,
|
|
678
|
-
retry,
|
|
679
|
-
isBatch: true
|
|
680
|
-
},
|
|
681
|
-
deps
|
|
682
|
-
);
|
|
683
|
-
}
|
|
950
|
+
eachBatch: (payload) => handleEachBatch(
|
|
951
|
+
payload,
|
|
952
|
+
{
|
|
953
|
+
schemaMap,
|
|
954
|
+
handleBatch,
|
|
955
|
+
interceptors,
|
|
956
|
+
dlq,
|
|
957
|
+
retry,
|
|
958
|
+
timeoutMs,
|
|
959
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
|
|
960
|
+
},
|
|
961
|
+
deps
|
|
962
|
+
)
|
|
684
963
|
});
|
|
685
964
|
this.runningConsumers.set(gid, "eachBatch");
|
|
686
965
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
@@ -699,6 +978,7 @@ var KafkaClient = class {
|
|
|
699
978
|
});
|
|
700
979
|
this.consumers.delete(groupId);
|
|
701
980
|
this.runningConsumers.delete(groupId);
|
|
981
|
+
this.consumerCreationOptions.delete(groupId);
|
|
702
982
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
703
983
|
} else {
|
|
704
984
|
const tasks = Array.from(this.consumers.values()).map(
|
|
@@ -708,6 +988,7 @@ var KafkaClient = class {
|
|
|
708
988
|
await Promise.allSettled(tasks);
|
|
709
989
|
this.consumers.clear();
|
|
710
990
|
this.runningConsumers.clear();
|
|
991
|
+
this.consumerCreationOptions.clear();
|
|
711
992
|
this.logger.log("All consumers disconnected");
|
|
712
993
|
}
|
|
713
994
|
}
|
|
@@ -766,204 +1047,26 @@ var KafkaClient = class {
|
|
|
766
1047
|
await Promise.allSettled(tasks);
|
|
767
1048
|
this.consumers.clear();
|
|
768
1049
|
this.runningConsumers.clear();
|
|
1050
|
+
this.consumerCreationOptions.clear();
|
|
769
1051
|
this.logger.log("All connections closed");
|
|
770
1052
|
}
|
|
771
|
-
// ── Retry topic chain ────────────────────────────────────────────
|
|
772
|
-
/**
|
|
773
|
-
* Auto-start companion consumers on `<topic>.retry` for each original topic.
|
|
774
|
-
* Called by `startConsumer` when `retryTopics: true`.
|
|
775
|
-
*
|
|
776
|
-
* Flow per message:
|
|
777
|
-
* 1. Sleep until `x-retry-after` (scheduled by the main consumer or previous retry hop)
|
|
778
|
-
* 2. Call the original handler
|
|
779
|
-
* 3. On failure: if retries remain → re-send to `<originalTopic>.retry` with incremented attempt
|
|
780
|
-
* if exhausted → DLQ or onMessageLost
|
|
781
|
-
*/
|
|
782
|
-
async startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap) {
|
|
783
|
-
const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
|
|
784
|
-
const retryGroupId = `${originalGroupId}-retry`;
|
|
785
|
-
const backoffMs = retry.backoffMs ?? 1e3;
|
|
786
|
-
const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
|
|
787
|
-
const deps = {
|
|
788
|
-
logger: this.logger,
|
|
789
|
-
producer: this.producer,
|
|
790
|
-
instrumentation: this.instrumentation,
|
|
791
|
-
onMessageLost: this.onMessageLost
|
|
792
|
-
};
|
|
793
|
-
for (const rt of retryTopicNames) {
|
|
794
|
-
await this.ensureTopic(rt);
|
|
795
|
-
}
|
|
796
|
-
const consumer = this.getOrCreateConsumer(retryGroupId, false, true);
|
|
797
|
-
await consumer.connect();
|
|
798
|
-
await subscribeWithRetry(consumer, retryTopicNames, this.logger);
|
|
799
|
-
await consumer.run({
|
|
800
|
-
eachMessage: async ({ topic: retryTopic, partition, message }) => {
|
|
801
|
-
if (!message.value) return;
|
|
802
|
-
const raw = message.value.toString();
|
|
803
|
-
const parsed = parseJsonMessage(raw, retryTopic, this.logger);
|
|
804
|
-
if (parsed === null) return;
|
|
805
|
-
const headers = decodeHeaders(message.headers);
|
|
806
|
-
const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
|
|
807
|
-
const currentAttempt = parseInt(
|
|
808
|
-
headers[RETRY_HEADER_ATTEMPT] ?? "1",
|
|
809
|
-
10
|
|
810
|
-
);
|
|
811
|
-
const maxRetries = parseInt(
|
|
812
|
-
headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
|
|
813
|
-
10
|
|
814
|
-
);
|
|
815
|
-
const retryAfter = parseInt(
|
|
816
|
-
headers[RETRY_HEADER_AFTER] ?? "0",
|
|
817
|
-
10
|
|
818
|
-
);
|
|
819
|
-
const remaining = retryAfter - Date.now();
|
|
820
|
-
if (remaining > 0) {
|
|
821
|
-
consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
|
|
822
|
-
await sleep(remaining);
|
|
823
|
-
consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
|
|
824
|
-
}
|
|
825
|
-
const validated = await validateWithSchema(
|
|
826
|
-
parsed,
|
|
827
|
-
raw,
|
|
828
|
-
originalTopic,
|
|
829
|
-
schemaMap,
|
|
830
|
-
interceptors,
|
|
831
|
-
dlq,
|
|
832
|
-
{ ...deps, originalHeaders: headers }
|
|
833
|
-
);
|
|
834
|
-
if (validated === null) return;
|
|
835
|
-
const envelope = extractEnvelope(
|
|
836
|
-
validated,
|
|
837
|
-
headers,
|
|
838
|
-
originalTopic,
|
|
839
|
-
partition,
|
|
840
|
-
message.offset
|
|
841
|
-
);
|
|
842
|
-
try {
|
|
843
|
-
const cleanups = [];
|
|
844
|
-
for (const inst of this.instrumentation) {
|
|
845
|
-
const c = inst.beforeConsume?.(envelope);
|
|
846
|
-
if (typeof c === "function") cleanups.push(c);
|
|
847
|
-
}
|
|
848
|
-
for (const interceptor of interceptors)
|
|
849
|
-
await interceptor.before?.(envelope);
|
|
850
|
-
await runWithEnvelopeContext(
|
|
851
|
-
{
|
|
852
|
-
correlationId: envelope.correlationId,
|
|
853
|
-
traceparent: envelope.traceparent
|
|
854
|
-
},
|
|
855
|
-
() => handleMessage(envelope)
|
|
856
|
-
);
|
|
857
|
-
for (const interceptor of interceptors)
|
|
858
|
-
await interceptor.after?.(envelope);
|
|
859
|
-
for (const cleanup of cleanups) cleanup();
|
|
860
|
-
} catch (error) {
|
|
861
|
-
const err = toError(error);
|
|
862
|
-
const nextAttempt = currentAttempt + 1;
|
|
863
|
-
const exhausted = currentAttempt >= maxRetries;
|
|
864
|
-
for (const inst of this.instrumentation)
|
|
865
|
-
inst.onConsumeError?.(envelope, err);
|
|
866
|
-
const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(
|
|
867
|
-
originalTopic,
|
|
868
|
-
[envelope.payload],
|
|
869
|
-
maxRetries,
|
|
870
|
-
{ cause: err }
|
|
871
|
-
) : err;
|
|
872
|
-
for (const interceptor of interceptors) {
|
|
873
|
-
await interceptor.onError?.(envelope, reportedError);
|
|
874
|
-
}
|
|
875
|
-
this.logger.error(
|
|
876
|
-
`Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
|
|
877
|
-
err.stack
|
|
878
|
-
);
|
|
879
|
-
if (!exhausted) {
|
|
880
|
-
const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
|
|
881
|
-
const delay = Math.floor(Math.random() * cap);
|
|
882
|
-
await sendToRetryTopic(
|
|
883
|
-
originalTopic,
|
|
884
|
-
[raw],
|
|
885
|
-
nextAttempt,
|
|
886
|
-
maxRetries,
|
|
887
|
-
delay,
|
|
888
|
-
headers,
|
|
889
|
-
deps
|
|
890
|
-
);
|
|
891
|
-
} else if (dlq) {
|
|
892
|
-
await sendToDlq(originalTopic, raw, deps, {
|
|
893
|
-
error: err,
|
|
894
|
-
// +1 to account for the main consumer's initial attempt before
|
|
895
|
-
// routing to the retry topic, making this consistent with the
|
|
896
|
-
// in-process retry path where attempt counts all tries.
|
|
897
|
-
attempt: currentAttempt + 1,
|
|
898
|
-
originalHeaders: headers
|
|
899
|
-
});
|
|
900
|
-
} else {
|
|
901
|
-
await deps.onMessageLost?.({
|
|
902
|
-
topic: originalTopic,
|
|
903
|
-
error: err,
|
|
904
|
-
attempt: currentAttempt,
|
|
905
|
-
headers
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
});
|
|
911
|
-
this.runningConsumers.set(retryGroupId, "eachMessage");
|
|
912
|
-
await this.waitForPartitionAssignment(consumer, retryTopicNames);
|
|
913
|
-
this.logger.log(
|
|
914
|
-
`Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
1053
|
// ── Private helpers ──────────────────────────────────────────────
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
* missed, which is the same behaviour as before this guard was added.
|
|
924
|
-
*/
|
|
925
|
-
async waitForPartitionAssignment(consumer, topics, timeoutMs = 1e4) {
|
|
926
|
-
const topicSet = new Set(topics);
|
|
927
|
-
const deadline = Date.now() + timeoutMs;
|
|
928
|
-
while (Date.now() < deadline) {
|
|
929
|
-
try {
|
|
930
|
-
const assigned = consumer.assignment();
|
|
931
|
-
if (assigned.some((a) => topicSet.has(a.topic))) return;
|
|
932
|
-
} catch {
|
|
933
|
-
}
|
|
934
|
-
await sleep(200);
|
|
935
|
-
}
|
|
936
|
-
this.logger.warn(
|
|
937
|
-
`Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
|
|
1054
|
+
async preparePayload(topicOrDesc, messages) {
|
|
1055
|
+
const payload = await buildSendPayload(
|
|
1056
|
+
topicOrDesc,
|
|
1057
|
+
messages,
|
|
1058
|
+
this.producerOpsDeps
|
|
938
1059
|
);
|
|
1060
|
+
await this.ensureTopic(payload.topic);
|
|
1061
|
+
return payload;
|
|
939
1062
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
if (this.onRebalance) {
|
|
946
|
-
const onRebalance = this.onRebalance;
|
|
947
|
-
config["rebalance_cb"] = (err, assignment) => {
|
|
948
|
-
const type = err.code === -175 ? "assign" : "revoke";
|
|
949
|
-
try {
|
|
950
|
-
onRebalance(
|
|
951
|
-
type,
|
|
952
|
-
assignment.map((p) => ({
|
|
953
|
-
topic: p.topic,
|
|
954
|
-
partition: p.partition
|
|
955
|
-
}))
|
|
956
|
-
);
|
|
957
|
-
} catch (e) {
|
|
958
|
-
this.logger.warn(
|
|
959
|
-
`onRebalance callback threw: ${e.message}`
|
|
960
|
-
);
|
|
961
|
-
}
|
|
962
|
-
};
|
|
1063
|
+
// afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
|
|
1064
|
+
notifyAfterSend(topic2, count) {
|
|
1065
|
+
for (let i = 0; i < count; i++) {
|
|
1066
|
+
for (const inst of this.instrumentation) {
|
|
1067
|
+
inst.afterSend?.(topic2);
|
|
963
1068
|
}
|
|
964
|
-
this.consumers.set(groupId, this.kafka.consumer(config));
|
|
965
1069
|
}
|
|
966
|
-
return this.consumers.get(groupId);
|
|
967
1070
|
}
|
|
968
1071
|
/**
|
|
969
1072
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
@@ -981,13 +1084,6 @@ var KafkaClient = class {
|
|
|
981
1084
|
}, timeoutMs);
|
|
982
1085
|
return promise;
|
|
983
1086
|
}
|
|
984
|
-
resolveTopicName(topicOrDescriptor) {
|
|
985
|
-
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
986
|
-
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
987
|
-
return topicOrDescriptor.__topic;
|
|
988
|
-
}
|
|
989
|
-
return String(topicOrDescriptor);
|
|
990
|
-
}
|
|
991
1087
|
async ensureTopic(topic2) {
|
|
992
1088
|
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
993
1089
|
if (!this.isAdminConnected) {
|
|
@@ -999,54 +1095,6 @@ var KafkaClient = class {
|
|
|
999
1095
|
});
|
|
1000
1096
|
this.ensuredTopics.add(topic2);
|
|
1001
1097
|
}
|
|
1002
|
-
/** Register schema from descriptor into global registry (side-effect). */
|
|
1003
|
-
registerSchema(topicOrDesc) {
|
|
1004
|
-
if (topicOrDesc?.__schema) {
|
|
1005
|
-
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
1006
|
-
this.schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
/** Validate message against schema. Pure — no side-effects on registry. */
|
|
1010
|
-
async validateMessage(topicOrDesc, message) {
|
|
1011
|
-
if (topicOrDesc?.__schema) {
|
|
1012
|
-
return await topicOrDesc.__schema.parse(message);
|
|
1013
|
-
}
|
|
1014
|
-
if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
1015
|
-
const schema = this.schemaRegistry.get(topicOrDesc);
|
|
1016
|
-
if (schema) return await schema.parse(message);
|
|
1017
|
-
}
|
|
1018
|
-
return message;
|
|
1019
|
-
}
|
|
1020
|
-
/**
|
|
1021
|
-
* Build a kafkajs-ready send payload.
|
|
1022
|
-
* Handles: topic resolution, schema registration, validation, JSON serialization,
|
|
1023
|
-
* envelope header generation, and instrumentation hooks.
|
|
1024
|
-
*/
|
|
1025
|
-
async buildSendPayload(topicOrDesc, messages) {
|
|
1026
|
-
this.registerSchema(topicOrDesc);
|
|
1027
|
-
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
1028
|
-
const builtMessages = await Promise.all(
|
|
1029
|
-
messages.map(async (m) => {
|
|
1030
|
-
const envelopeHeaders = buildEnvelopeHeaders({
|
|
1031
|
-
correlationId: m.correlationId,
|
|
1032
|
-
schemaVersion: m.schemaVersion,
|
|
1033
|
-
eventId: m.eventId,
|
|
1034
|
-
headers: m.headers
|
|
1035
|
-
});
|
|
1036
|
-
for (const inst of this.instrumentation) {
|
|
1037
|
-
inst.beforeSend?.(topic2, envelopeHeaders);
|
|
1038
|
-
}
|
|
1039
|
-
return {
|
|
1040
|
-
value: JSON.stringify(
|
|
1041
|
-
await this.validateMessage(topicOrDesc, m.value)
|
|
1042
|
-
),
|
|
1043
|
-
key: m.key ?? null,
|
|
1044
|
-
headers: envelopeHeaders
|
|
1045
|
-
};
|
|
1046
|
-
})
|
|
1047
|
-
);
|
|
1048
|
-
return { topic: topic2, messages: builtMessages };
|
|
1049
|
-
}
|
|
1050
1098
|
/** Shared consumer setup: groupId check, schema map, connect, subscribe. */
|
|
1051
1099
|
async setupConsumer(topics, mode, options) {
|
|
1052
1100
|
const {
|
|
@@ -1065,15 +1113,18 @@ var KafkaClient = class {
|
|
|
1065
1113
|
`Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
|
|
1066
1114
|
);
|
|
1067
1115
|
}
|
|
1068
|
-
const consumer =
|
|
1116
|
+
const consumer = getOrCreateConsumer(
|
|
1069
1117
|
gid,
|
|
1070
1118
|
fromBeginning,
|
|
1071
|
-
options.autoCommit ?? true
|
|
1119
|
+
options.autoCommit ?? true,
|
|
1120
|
+
this.consumerOpsDeps
|
|
1072
1121
|
);
|
|
1073
|
-
const schemaMap =
|
|
1074
|
-
|
|
1075
|
-
|
|
1122
|
+
const schemaMap = buildSchemaMap(
|
|
1123
|
+
topics,
|
|
1124
|
+
this.schemaRegistry,
|
|
1125
|
+
optionSchemas
|
|
1076
1126
|
);
|
|
1127
|
+
const topicNames = topics.map((t) => resolveTopicName(t));
|
|
1077
1128
|
for (const t of topicNames) {
|
|
1078
1129
|
await this.ensureTopic(t);
|
|
1079
1130
|
}
|
|
@@ -1094,26 +1145,45 @@ var KafkaClient = class {
|
|
|
1094
1145
|
);
|
|
1095
1146
|
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
|
|
1096
1147
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1148
|
+
// ── Deps object getters ──────────────────────────────────────────
|
|
1149
|
+
get producerOpsDeps() {
|
|
1150
|
+
return {
|
|
1151
|
+
schemaRegistry: this.schemaRegistry,
|
|
1152
|
+
strictSchemasEnabled: this.strictSchemasEnabled,
|
|
1153
|
+
instrumentation: this.instrumentation
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
get consumerOpsDeps() {
|
|
1157
|
+
return {
|
|
1158
|
+
consumers: this.consumers,
|
|
1159
|
+
consumerCreationOptions: this.consumerCreationOptions,
|
|
1160
|
+
kafka: this.kafka,
|
|
1161
|
+
onRebalance: this.onRebalance,
|
|
1162
|
+
logger: this.logger
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
get messageDeps() {
|
|
1166
|
+
return {
|
|
1167
|
+
logger: this.logger,
|
|
1168
|
+
producer: this.producer,
|
|
1169
|
+
instrumentation: this.instrumentation,
|
|
1170
|
+
onMessageLost: this.onMessageLost
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
get retryTopicDeps() {
|
|
1174
|
+
return {
|
|
1175
|
+
logger: this.logger,
|
|
1176
|
+
producer: this.producer,
|
|
1177
|
+
instrumentation: this.instrumentation,
|
|
1178
|
+
onMessageLost: this.onMessageLost,
|
|
1179
|
+
ensureTopic: (t) => this.ensureTopic(t),
|
|
1180
|
+
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
|
|
1181
|
+
runningConsumers: this.runningConsumers
|
|
1182
|
+
};
|
|
1113
1183
|
}
|
|
1114
1184
|
};
|
|
1115
1185
|
|
|
1116
|
-
// src/client/topic.ts
|
|
1186
|
+
// src/client/message/topic.ts
|
|
1117
1187
|
function topic(name) {
|
|
1118
1188
|
const fn = () => ({
|
|
1119
1189
|
__topic: name,
|
|
@@ -1243,35 +1313,17 @@ var KafkaModule = class {
|
|
|
1243
1313
|
const token = getKafkaClientToken(options.name);
|
|
1244
1314
|
const kafkaClientProvider = {
|
|
1245
1315
|
provide: token,
|
|
1246
|
-
useFactory:
|
|
1247
|
-
const client = new KafkaClient(
|
|
1248
|
-
options.clientId,
|
|
1249
|
-
options.groupId,
|
|
1250
|
-
options.brokers,
|
|
1251
|
-
{
|
|
1252
|
-
autoCreateTopics: options.autoCreateTopics,
|
|
1253
|
-
strictSchemas: options.strictSchemas,
|
|
1254
|
-
numPartitions: options.numPartitions,
|
|
1255
|
-
instrumentation: options.instrumentation,
|
|
1256
|
-
logger: new import_common3.Logger(`KafkaClient:${options.clientId}`)
|
|
1257
|
-
}
|
|
1258
|
-
);
|
|
1259
|
-
await client.connectProducer();
|
|
1260
|
-
return client;
|
|
1261
|
-
}
|
|
1262
|
-
};
|
|
1263
|
-
const destroyProvider = {
|
|
1264
|
-
provide: `${token}_DESTROY`,
|
|
1265
|
-
useFactory: (client) => ({
|
|
1266
|
-
onModuleDestroy: () => client.disconnect()
|
|
1267
|
-
}),
|
|
1268
|
-
inject: [token]
|
|
1316
|
+
useFactory: () => KafkaModule.buildClient(options)
|
|
1269
1317
|
};
|
|
1270
1318
|
return {
|
|
1271
1319
|
global: options.isGlobal ?? false,
|
|
1272
1320
|
module: KafkaModule,
|
|
1273
1321
|
imports: [import_core2.DiscoveryModule],
|
|
1274
|
-
providers: [
|
|
1322
|
+
providers: [
|
|
1323
|
+
kafkaClientProvider,
|
|
1324
|
+
KafkaModule.buildDestroyProvider(token),
|
|
1325
|
+
KafkaExplorer
|
|
1326
|
+
],
|
|
1275
1327
|
exports: [kafkaClientProvider]
|
|
1276
1328
|
};
|
|
1277
1329
|
}
|
|
@@ -1280,40 +1332,48 @@ var KafkaModule = class {
|
|
|
1280
1332
|
const token = getKafkaClientToken(asyncOptions.name);
|
|
1281
1333
|
const kafkaClientProvider = {
|
|
1282
1334
|
provide: token,
|
|
1283
|
-
useFactory: async (...args) =>
|
|
1284
|
-
const options = await asyncOptions.useFactory(...args);
|
|
1285
|
-
const client = new KafkaClient(
|
|
1286
|
-
options.clientId,
|
|
1287
|
-
options.groupId,
|
|
1288
|
-
options.brokers,
|
|
1289
|
-
{
|
|
1290
|
-
autoCreateTopics: options.autoCreateTopics,
|
|
1291
|
-
strictSchemas: options.strictSchemas,
|
|
1292
|
-
numPartitions: options.numPartitions,
|
|
1293
|
-
instrumentation: options.instrumentation,
|
|
1294
|
-
logger: new import_common3.Logger(`KafkaClient:${options.clientId}`)
|
|
1295
|
-
}
|
|
1296
|
-
);
|
|
1297
|
-
await client.connectProducer();
|
|
1298
|
-
return client;
|
|
1299
|
-
},
|
|
1335
|
+
useFactory: async (...args) => KafkaModule.buildClient(await asyncOptions.useFactory(...args)),
|
|
1300
1336
|
inject: asyncOptions.inject || []
|
|
1301
1337
|
};
|
|
1302
|
-
const destroyProvider = {
|
|
1303
|
-
provide: `${token}_DESTROY`,
|
|
1304
|
-
useFactory: (client) => ({
|
|
1305
|
-
onModuleDestroy: () => client.disconnect()
|
|
1306
|
-
}),
|
|
1307
|
-
inject: [token]
|
|
1308
|
-
};
|
|
1309
1338
|
return {
|
|
1310
1339
|
global: asyncOptions.isGlobal ?? false,
|
|
1311
1340
|
module: KafkaModule,
|
|
1312
1341
|
imports: [...asyncOptions.imports || [], import_core2.DiscoveryModule],
|
|
1313
|
-
providers: [
|
|
1342
|
+
providers: [
|
|
1343
|
+
kafkaClientProvider,
|
|
1344
|
+
KafkaModule.buildDestroyProvider(token),
|
|
1345
|
+
KafkaExplorer
|
|
1346
|
+
],
|
|
1314
1347
|
exports: [kafkaClientProvider]
|
|
1315
1348
|
};
|
|
1316
1349
|
}
|
|
1350
|
+
static async buildClient(options) {
|
|
1351
|
+
const client = new KafkaClient(
|
|
1352
|
+
options.clientId,
|
|
1353
|
+
options.groupId,
|
|
1354
|
+
options.brokers,
|
|
1355
|
+
{
|
|
1356
|
+
autoCreateTopics: options.autoCreateTopics,
|
|
1357
|
+
strictSchemas: options.strictSchemas,
|
|
1358
|
+
numPartitions: options.numPartitions,
|
|
1359
|
+
instrumentation: options.instrumentation,
|
|
1360
|
+
onMessageLost: options.onMessageLost,
|
|
1361
|
+
onRebalance: options.onRebalance,
|
|
1362
|
+
logger: new import_common3.Logger(`KafkaClient:${options.clientId}`)
|
|
1363
|
+
}
|
|
1364
|
+
);
|
|
1365
|
+
await client.connectProducer();
|
|
1366
|
+
return client;
|
|
1367
|
+
}
|
|
1368
|
+
static buildDestroyProvider(token) {
|
|
1369
|
+
return {
|
|
1370
|
+
provide: `${token}_DESTROY`,
|
|
1371
|
+
useFactory: (client) => ({
|
|
1372
|
+
onModuleDestroy: () => client.disconnect()
|
|
1373
|
+
}),
|
|
1374
|
+
inject: [token]
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1317
1377
|
};
|
|
1318
1378
|
KafkaModule = __decorateClass([
|
|
1319
1379
|
(0, import_common3.Module)({})
|