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