@drarzter/kafka-client 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -17
- package/dist/chunk-TD2AE774.mjs +1231 -0
- package/dist/chunk-TD2AE774.mjs.map +1 -0
- package/dist/core.d.mts +14 -41
- package/dist/core.d.ts +14 -41
- package/dist/core.js +652 -511
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/index.d.mts +17 -23
- package/dist/index.d.ts +17 -23
- package/dist/index.js +692 -569
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +41 -59
- 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 +9 -4
- package/dist/otel.js.map +1 -1
- package/dist/otel.mjs +9 -4
- 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/dist/{envelope-BpyKN_WL.d.mts → types-DwERZ6ql.d.mts} +99 -83
- package/dist/{envelope-BpyKN_WL.d.ts → types-DwERZ6ql.d.ts} +99 -83
- package/package.json +1 -1
- package/dist/chunk-Z3O5GTS7.mjs +0 -1090
- 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";
|
|
@@ -133,7 +133,123 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
|
|
|
133
133
|
}
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
-
// src/client/
|
|
136
|
+
// src/client/kafka.client/producer-ops.ts
|
|
137
|
+
function resolveTopicName(topicOrDescriptor) {
|
|
138
|
+
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
139
|
+
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
140
|
+
return topicOrDescriptor.__topic;
|
|
141
|
+
}
|
|
142
|
+
return String(topicOrDescriptor);
|
|
143
|
+
}
|
|
144
|
+
function registerSchema(topicOrDesc, schemaRegistry) {
|
|
145
|
+
if (topicOrDesc?.__schema) {
|
|
146
|
+
const topic2 = resolveTopicName(topicOrDesc);
|
|
147
|
+
schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function validateMessage(topicOrDesc, message, deps) {
|
|
151
|
+
const topicName = resolveTopicName(topicOrDesc);
|
|
152
|
+
if (topicOrDesc?.__schema) {
|
|
153
|
+
try {
|
|
154
|
+
return await topicOrDesc.__schema.parse(message);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new KafkaValidationError(topicName, message, {
|
|
157
|
+
cause: error instanceof Error ? error : new Error(String(error))
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (deps.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
162
|
+
const schema = deps.schemaRegistry.get(topicOrDesc);
|
|
163
|
+
if (schema) {
|
|
164
|
+
try {
|
|
165
|
+
return await schema.parse(message);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new KafkaValidationError(topicName, message, {
|
|
168
|
+
cause: error instanceof Error ? error : new Error(String(error))
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return message;
|
|
174
|
+
}
|
|
175
|
+
async function buildSendPayload(topicOrDesc, messages, deps) {
|
|
176
|
+
const topic2 = resolveTopicName(topicOrDesc);
|
|
177
|
+
const builtMessages = await Promise.all(
|
|
178
|
+
messages.map(async (m) => {
|
|
179
|
+
const envelopeHeaders = buildEnvelopeHeaders({
|
|
180
|
+
correlationId: m.correlationId,
|
|
181
|
+
schemaVersion: m.schemaVersion,
|
|
182
|
+
eventId: m.eventId,
|
|
183
|
+
headers: m.headers
|
|
184
|
+
});
|
|
185
|
+
for (const inst of deps.instrumentation) {
|
|
186
|
+
inst.beforeSend?.(topic2, envelopeHeaders);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
value: JSON.stringify(
|
|
190
|
+
await validateMessage(topicOrDesc, m.value, deps)
|
|
191
|
+
),
|
|
192
|
+
key: m.key ?? null,
|
|
193
|
+
headers: envelopeHeaders
|
|
194
|
+
};
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
return { topic: topic2, messages: builtMessages };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/client/kafka.client/consumer-ops.ts
|
|
201
|
+
function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps) {
|
|
202
|
+
const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
|
|
203
|
+
if (consumers.has(groupId)) {
|
|
204
|
+
const prev = consumerCreationOptions.get(groupId);
|
|
205
|
+
if (prev.fromBeginning !== fromBeginning || prev.autoCommit !== autoCommit) {
|
|
206
|
+
logger.warn(
|
|
207
|
+
`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.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return consumers.get(groupId);
|
|
211
|
+
}
|
|
212
|
+
consumerCreationOptions.set(groupId, { fromBeginning, autoCommit });
|
|
213
|
+
const config = {
|
|
214
|
+
kafkaJS: { groupId, fromBeginning, autoCommit }
|
|
215
|
+
};
|
|
216
|
+
if (onRebalance) {
|
|
217
|
+
const cb = onRebalance;
|
|
218
|
+
config["rebalance_cb"] = (err, assignment) => {
|
|
219
|
+
const type = err.code === -175 ? "assign" : "revoke";
|
|
220
|
+
try {
|
|
221
|
+
cb(
|
|
222
|
+
type,
|
|
223
|
+
assignment.map((p) => ({ topic: p.topic, partition: p.partition }))
|
|
224
|
+
);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
logger.warn(`onRebalance callback threw: ${e.message}`);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const consumer = kafka.consumer(config);
|
|
231
|
+
consumers.set(groupId, consumer);
|
|
232
|
+
return consumer;
|
|
233
|
+
}
|
|
234
|
+
function buildSchemaMap(topics, schemaRegistry, optionSchemas) {
|
|
235
|
+
const schemaMap = /* @__PURE__ */ new Map();
|
|
236
|
+
for (const t of topics) {
|
|
237
|
+
if (t?.__schema) {
|
|
238
|
+
const name = resolveTopicName(t);
|
|
239
|
+
schemaMap.set(name, t.__schema);
|
|
240
|
+
schemaRegistry.set(name, t.__schema);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (optionSchemas) {
|
|
244
|
+
for (const [k, v] of optionSchemas) {
|
|
245
|
+
schemaMap.set(k, v);
|
|
246
|
+
schemaRegistry.set(k, v);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return schemaMap;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/client/consumer/pipeline.ts
|
|
137
253
|
function toError(error) {
|
|
138
254
|
return error instanceof Error ? error : new Error(String(error));
|
|
139
255
|
}
|
|
@@ -220,7 +336,7 @@ var RETRY_HEADER_AFTER = "x-retry-after";
|
|
|
220
336
|
var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
|
|
221
337
|
var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
|
|
222
338
|
async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders, deps) {
|
|
223
|
-
const retryTopic = `${originalTopic}.retry`;
|
|
339
|
+
const retryTopic = `${originalTopic}.retry.${attempt}`;
|
|
224
340
|
const {
|
|
225
341
|
[RETRY_HEADER_ATTEMPT]: _a,
|
|
226
342
|
[RETRY_HEADER_AFTER]: _b,
|
|
@@ -252,6 +368,53 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
|
|
|
252
368
|
);
|
|
253
369
|
}
|
|
254
370
|
}
|
|
371
|
+
async function broadcastToInterceptors(envelopes, interceptors, cb) {
|
|
372
|
+
for (const env of envelopes) {
|
|
373
|
+
for (const interceptor of interceptors) {
|
|
374
|
+
await cb(interceptor, env);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function runHandlerWithPipeline(fn, envelopes, interceptors, instrumentation) {
|
|
379
|
+
const cleanups = [];
|
|
380
|
+
try {
|
|
381
|
+
for (const env of envelopes) {
|
|
382
|
+
for (const inst of instrumentation) {
|
|
383
|
+
const cleanup = inst.beforeConsume?.(env);
|
|
384
|
+
if (typeof cleanup === "function") cleanups.push(cleanup);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const env of envelopes) {
|
|
388
|
+
for (const interceptor of interceptors) {
|
|
389
|
+
await interceptor.before?.(env);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
await fn();
|
|
393
|
+
for (const env of envelopes) {
|
|
394
|
+
for (const interceptor of interceptors) {
|
|
395
|
+
await interceptor.after?.(env);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const cleanup of cleanups) cleanup();
|
|
399
|
+
return null;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const err = toError(error);
|
|
402
|
+
for (const env of envelopes) {
|
|
403
|
+
for (const inst of instrumentation) {
|
|
404
|
+
inst.onConsumeError?.(env, err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const cleanup of cleanups) cleanup();
|
|
408
|
+
return err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function notifyInterceptorsOnError(envelopes, interceptors, error) {
|
|
412
|
+
await broadcastToInterceptors(
|
|
413
|
+
envelopes,
|
|
414
|
+
interceptors,
|
|
415
|
+
(i, env) => i.onError?.(env, error)
|
|
416
|
+
);
|
|
417
|
+
}
|
|
255
418
|
async function executeWithRetry(fn, ctx, deps) {
|
|
256
419
|
const {
|
|
257
420
|
envelope,
|
|
@@ -268,98 +431,181 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
268
431
|
const envelopes = Array.isArray(envelope) ? envelope : [envelope];
|
|
269
432
|
const topic2 = envelopes[0]?.topic ?? "unknown";
|
|
270
433
|
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
|
|
434
|
+
const error = await runHandlerWithPipeline(
|
|
435
|
+
fn,
|
|
436
|
+
envelopes,
|
|
437
|
+
interceptors,
|
|
438
|
+
deps.instrumentation
|
|
439
|
+
);
|
|
440
|
+
if (!error) return;
|
|
441
|
+
const isLastAttempt = attempt === maxAttempts;
|
|
442
|
+
const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
|
|
443
|
+
topic2,
|
|
444
|
+
envelopes.map((e) => e.payload),
|
|
445
|
+
maxAttempts,
|
|
446
|
+
{ cause: error }
|
|
447
|
+
) : error;
|
|
448
|
+
await notifyInterceptorsOnError(envelopes, interceptors, reportedError);
|
|
449
|
+
deps.logger.error(
|
|
450
|
+
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
451
|
+
error.stack
|
|
452
|
+
);
|
|
453
|
+
if (retryTopics && retry) {
|
|
454
|
+
const cap = Math.min(backoffMs, maxBackoffMs);
|
|
455
|
+
const delay = Math.floor(Math.random() * cap);
|
|
456
|
+
await sendToRetryTopic(
|
|
457
|
+
topic2,
|
|
458
|
+
rawMessages,
|
|
459
|
+
1,
|
|
460
|
+
retry.maxRetries,
|
|
461
|
+
delay,
|
|
462
|
+
envelopes[0]?.headers ?? {},
|
|
463
|
+
deps
|
|
323
464
|
);
|
|
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
|
-
});
|
|
465
|
+
} else if (isLastAttempt) {
|
|
466
|
+
if (dlq) {
|
|
467
|
+
const dlqMeta = {
|
|
468
|
+
error,
|
|
469
|
+
attempt,
|
|
470
|
+
originalHeaders: envelopes[0]?.headers
|
|
471
|
+
};
|
|
472
|
+
for (const raw of rawMessages) {
|
|
473
|
+
await sendToDlq(topic2, raw, deps, dlqMeta);
|
|
353
474
|
}
|
|
354
475
|
} else {
|
|
355
|
-
|
|
356
|
-
|
|
476
|
+
await deps.onMessageLost?.({
|
|
477
|
+
topic: topic2,
|
|
478
|
+
error,
|
|
479
|
+
attempt,
|
|
480
|
+
headers: envelopes[0]?.headers ?? {}
|
|
481
|
+
});
|
|
357
482
|
}
|
|
483
|
+
} else {
|
|
484
|
+
const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
|
|
485
|
+
await sleep(Math.random() * cap);
|
|
358
486
|
}
|
|
359
487
|
}
|
|
360
488
|
}
|
|
361
489
|
|
|
362
|
-
// src/client/
|
|
490
|
+
// src/client/kafka.client/message-handler.ts
|
|
491
|
+
async function parseSingleMessage(message, topic2, partition, schemaMap, interceptors, dlq, deps) {
|
|
492
|
+
if (!message.value) {
|
|
493
|
+
deps.logger.warn(`Received empty message from topic ${topic2}`);
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const raw = message.value.toString();
|
|
497
|
+
const parsed = parseJsonMessage(raw, topic2, deps.logger);
|
|
498
|
+
if (parsed === null) return null;
|
|
499
|
+
const headers = decodeHeaders(message.headers);
|
|
500
|
+
const validated = await validateWithSchema(
|
|
501
|
+
parsed,
|
|
502
|
+
raw,
|
|
503
|
+
topic2,
|
|
504
|
+
schemaMap,
|
|
505
|
+
interceptors,
|
|
506
|
+
dlq,
|
|
507
|
+
{ ...deps, originalHeaders: headers }
|
|
508
|
+
);
|
|
509
|
+
if (validated === null) return null;
|
|
510
|
+
return extractEnvelope(validated, headers, topic2, partition, message.offset);
|
|
511
|
+
}
|
|
512
|
+
async function handleEachMessage(payload, opts, deps) {
|
|
513
|
+
const { topic: topic2, partition, message } = payload;
|
|
514
|
+
const {
|
|
515
|
+
schemaMap,
|
|
516
|
+
handleMessage,
|
|
517
|
+
interceptors,
|
|
518
|
+
dlq,
|
|
519
|
+
retry,
|
|
520
|
+
retryTopics,
|
|
521
|
+
timeoutMs,
|
|
522
|
+
wrapWithTimeout
|
|
523
|
+
} = opts;
|
|
524
|
+
const envelope = await parseSingleMessage(
|
|
525
|
+
message,
|
|
526
|
+
topic2,
|
|
527
|
+
partition,
|
|
528
|
+
schemaMap,
|
|
529
|
+
interceptors,
|
|
530
|
+
dlq,
|
|
531
|
+
deps
|
|
532
|
+
);
|
|
533
|
+
if (envelope === null) return;
|
|
534
|
+
await executeWithRetry(
|
|
535
|
+
() => {
|
|
536
|
+
const fn = () => runWithEnvelopeContext(
|
|
537
|
+
{
|
|
538
|
+
correlationId: envelope.correlationId,
|
|
539
|
+
traceparent: envelope.traceparent
|
|
540
|
+
},
|
|
541
|
+
() => handleMessage(envelope)
|
|
542
|
+
);
|
|
543
|
+
return timeoutMs ? wrapWithTimeout(fn, timeoutMs, topic2) : fn();
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
envelope,
|
|
547
|
+
rawMessages: [message.value.toString()],
|
|
548
|
+
interceptors,
|
|
549
|
+
dlq,
|
|
550
|
+
retry,
|
|
551
|
+
retryTopics
|
|
552
|
+
},
|
|
553
|
+
deps
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
async function handleEachBatch(payload, opts, deps) {
|
|
557
|
+
const { batch, heartbeat, resolveOffset, commitOffsetsIfNecessary } = payload;
|
|
558
|
+
const {
|
|
559
|
+
schemaMap,
|
|
560
|
+
handleBatch,
|
|
561
|
+
interceptors,
|
|
562
|
+
dlq,
|
|
563
|
+
retry,
|
|
564
|
+
timeoutMs,
|
|
565
|
+
wrapWithTimeout
|
|
566
|
+
} = opts;
|
|
567
|
+
const envelopes = [];
|
|
568
|
+
const rawMessages = [];
|
|
569
|
+
for (const message of batch.messages) {
|
|
570
|
+
const envelope = await parseSingleMessage(
|
|
571
|
+
message,
|
|
572
|
+
batch.topic,
|
|
573
|
+
batch.partition,
|
|
574
|
+
schemaMap,
|
|
575
|
+
interceptors,
|
|
576
|
+
dlq,
|
|
577
|
+
deps
|
|
578
|
+
);
|
|
579
|
+
if (envelope === null) continue;
|
|
580
|
+
envelopes.push(envelope);
|
|
581
|
+
rawMessages.push(message.value.toString());
|
|
582
|
+
}
|
|
583
|
+
if (envelopes.length === 0) return;
|
|
584
|
+
const meta = {
|
|
585
|
+
partition: batch.partition,
|
|
586
|
+
highWatermark: batch.highWatermark,
|
|
587
|
+
heartbeat,
|
|
588
|
+
resolveOffset,
|
|
589
|
+
commitOffsetsIfNecessary
|
|
590
|
+
};
|
|
591
|
+
await executeWithRetry(
|
|
592
|
+
() => {
|
|
593
|
+
const fn = () => handleBatch(envelopes, meta);
|
|
594
|
+
return timeoutMs ? wrapWithTimeout(fn, timeoutMs, batch.topic) : fn();
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
envelope: envelopes,
|
|
598
|
+
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
599
|
+
interceptors,
|
|
600
|
+
dlq,
|
|
601
|
+
retry,
|
|
602
|
+
isBatch: true
|
|
603
|
+
},
|
|
604
|
+
deps
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/client/consumer/subscribe-retry.ts
|
|
363
609
|
async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
364
610
|
const maxAttempts = retryOpts?.retries ?? 5;
|
|
365
611
|
const backoffMs = retryOpts?.backoffMs ?? 5e3;
|
|
@@ -378,7 +624,183 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
|
378
624
|
}
|
|
379
625
|
}
|
|
380
626
|
|
|
381
|
-
// src/client/kafka.client.ts
|
|
627
|
+
// src/client/kafka.client/retry-topic.ts
|
|
628
|
+
async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
|
|
629
|
+
const topicSet = new Set(topics);
|
|
630
|
+
const deadline = Date.now() + timeoutMs;
|
|
631
|
+
while (Date.now() < deadline) {
|
|
632
|
+
try {
|
|
633
|
+
const assigned = consumer.assignment();
|
|
634
|
+
if (assigned.some((a) => topicSet.has(a.topic))) return;
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
await sleep(200);
|
|
638
|
+
}
|
|
639
|
+
logger.warn(
|
|
640
|
+
`Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopics, handleMessage, retry, dlq, interceptors, schemaMap, deps, assignmentTimeoutMs) {
|
|
644
|
+
const {
|
|
645
|
+
logger,
|
|
646
|
+
producer,
|
|
647
|
+
instrumentation,
|
|
648
|
+
onMessageLost,
|
|
649
|
+
ensureTopic,
|
|
650
|
+
getOrCreateConsumer: getOrCreateConsumer2,
|
|
651
|
+
runningConsumers
|
|
652
|
+
} = deps;
|
|
653
|
+
const backoffMs = retry.backoffMs ?? 1e3;
|
|
654
|
+
const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
|
|
655
|
+
const pipelineDeps = { logger, producer, instrumentation, onMessageLost };
|
|
656
|
+
for (const lt of levelTopics) {
|
|
657
|
+
await ensureTopic(lt);
|
|
658
|
+
}
|
|
659
|
+
const consumer = getOrCreateConsumer2(levelGroupId, false, false);
|
|
660
|
+
await consumer.connect();
|
|
661
|
+
await subscribeWithRetry(consumer, levelTopics, logger);
|
|
662
|
+
await consumer.run({
|
|
663
|
+
eachMessage: async ({ topic: levelTopic, partition, message }) => {
|
|
664
|
+
const nextOffset = {
|
|
665
|
+
topic: levelTopic,
|
|
666
|
+
partition,
|
|
667
|
+
offset: (parseInt(message.offset, 10) + 1).toString()
|
|
668
|
+
};
|
|
669
|
+
if (!message.value) {
|
|
670
|
+
await consumer.commitOffsets([nextOffset]);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const headers = decodeHeaders(message.headers);
|
|
674
|
+
const retryAfter = parseInt(
|
|
675
|
+
headers[RETRY_HEADER_AFTER] ?? "0",
|
|
676
|
+
10
|
|
677
|
+
);
|
|
678
|
+
const remaining = retryAfter - Date.now();
|
|
679
|
+
if (remaining > 0) {
|
|
680
|
+
consumer.pause([{ topic: levelTopic, partitions: [partition] }]);
|
|
681
|
+
await sleep(remaining);
|
|
682
|
+
consumer.resume([{ topic: levelTopic, partitions: [partition] }]);
|
|
683
|
+
}
|
|
684
|
+
const raw = message.value.toString();
|
|
685
|
+
const parsed = parseJsonMessage(raw, levelTopic, logger);
|
|
686
|
+
if (parsed === null) {
|
|
687
|
+
await consumer.commitOffsets([nextOffset]);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const currentMaxRetries = parseInt(
|
|
691
|
+
headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
|
|
692
|
+
10
|
|
693
|
+
);
|
|
694
|
+
const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? levelTopic.replace(/\.retry\.\d+$/, "");
|
|
695
|
+
const validated = await validateWithSchema(
|
|
696
|
+
parsed,
|
|
697
|
+
raw,
|
|
698
|
+
originalTopic,
|
|
699
|
+
schemaMap,
|
|
700
|
+
interceptors,
|
|
701
|
+
dlq,
|
|
702
|
+
{ ...pipelineDeps, originalHeaders: headers }
|
|
703
|
+
);
|
|
704
|
+
if (validated === null) {
|
|
705
|
+
await consumer.commitOffsets([nextOffset]);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const envelope = extractEnvelope(
|
|
709
|
+
validated,
|
|
710
|
+
headers,
|
|
711
|
+
originalTopic,
|
|
712
|
+
partition,
|
|
713
|
+
message.offset
|
|
714
|
+
);
|
|
715
|
+
const error = await runHandlerWithPipeline(
|
|
716
|
+
() => runWithEnvelopeContext(
|
|
717
|
+
{
|
|
718
|
+
correlationId: envelope.correlationId,
|
|
719
|
+
traceparent: envelope.traceparent
|
|
720
|
+
},
|
|
721
|
+
() => handleMessage(envelope)
|
|
722
|
+
),
|
|
723
|
+
[envelope],
|
|
724
|
+
interceptors,
|
|
725
|
+
instrumentation
|
|
726
|
+
);
|
|
727
|
+
if (!error) {
|
|
728
|
+
await consumer.commitOffsets([nextOffset]);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const exhausted = level >= currentMaxRetries;
|
|
732
|
+
const reportedError = exhausted && currentMaxRetries > 1 ? new KafkaRetryExhaustedError(
|
|
733
|
+
originalTopic,
|
|
734
|
+
[envelope.payload],
|
|
735
|
+
currentMaxRetries,
|
|
736
|
+
{ cause: error }
|
|
737
|
+
) : error;
|
|
738
|
+
await notifyInterceptorsOnError([envelope], interceptors, reportedError);
|
|
739
|
+
logger.error(
|
|
740
|
+
`Retry consumer error for ${originalTopic} (level ${level}/${currentMaxRetries}):`,
|
|
741
|
+
error.stack
|
|
742
|
+
);
|
|
743
|
+
if (!exhausted) {
|
|
744
|
+
const nextLevel = level + 1;
|
|
745
|
+
const cap = Math.min(backoffMs * 2 ** level, maxBackoffMs);
|
|
746
|
+
const delay = Math.floor(Math.random() * cap);
|
|
747
|
+
await sendToRetryTopic(
|
|
748
|
+
originalTopic,
|
|
749
|
+
[raw],
|
|
750
|
+
nextLevel,
|
|
751
|
+
currentMaxRetries,
|
|
752
|
+
delay,
|
|
753
|
+
headers,
|
|
754
|
+
pipelineDeps
|
|
755
|
+
);
|
|
756
|
+
} else if (dlq) {
|
|
757
|
+
await sendToDlq(originalTopic, raw, pipelineDeps, {
|
|
758
|
+
error,
|
|
759
|
+
// +1 to account for the main consumer's initial attempt before routing.
|
|
760
|
+
attempt: level + 1,
|
|
761
|
+
originalHeaders: headers
|
|
762
|
+
});
|
|
763
|
+
} else {
|
|
764
|
+
await onMessageLost?.({
|
|
765
|
+
topic: originalTopic,
|
|
766
|
+
error,
|
|
767
|
+
attempt: level,
|
|
768
|
+
headers
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
await consumer.commitOffsets([nextOffset]);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
runningConsumers.set(levelGroupId, "eachMessage");
|
|
775
|
+
await waitForPartitionAssignment(consumer, levelTopics, logger, assignmentTimeoutMs);
|
|
776
|
+
logger.log(
|
|
777
|
+
`Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
async function startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap, deps, assignmentTimeoutMs) {
|
|
781
|
+
const levelGroupIds = [];
|
|
782
|
+
for (let level = 1; level <= retry.maxRetries; level++) {
|
|
783
|
+
const levelTopics = originalTopics.map((t) => `${t}.retry.${level}`);
|
|
784
|
+
const levelGroupId = `${originalGroupId}-retry.${level}`;
|
|
785
|
+
await startLevelConsumer(
|
|
786
|
+
level,
|
|
787
|
+
levelTopics,
|
|
788
|
+
levelGroupId,
|
|
789
|
+
originalTopics,
|
|
790
|
+
handleMessage,
|
|
791
|
+
retry,
|
|
792
|
+
dlq,
|
|
793
|
+
interceptors,
|
|
794
|
+
schemaMap,
|
|
795
|
+
deps,
|
|
796
|
+
assignmentTimeoutMs
|
|
797
|
+
);
|
|
798
|
+
levelGroupIds.push(levelGroupId);
|
|
799
|
+
}
|
|
800
|
+
return levelGroupIds;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/client/kafka.client/index.ts
|
|
382
804
|
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
|
|
383
805
|
var KafkaClient = class {
|
|
384
806
|
kafka;
|
|
@@ -394,6 +816,9 @@ var KafkaClient = class {
|
|
|
394
816
|
defaultGroupId;
|
|
395
817
|
schemaRegistry = /* @__PURE__ */ new Map();
|
|
396
818
|
runningConsumers = /* @__PURE__ */ new Map();
|
|
819
|
+
consumerCreationOptions = /* @__PURE__ */ new Map();
|
|
820
|
+
/** Maps each main consumer groupId to its companion retry level groupIds. */
|
|
821
|
+
companionGroupIds = /* @__PURE__ */ new Map();
|
|
397
822
|
instrumentation;
|
|
398
823
|
onMessageLost;
|
|
399
824
|
onRebalance;
|
|
@@ -428,7 +853,7 @@ var KafkaClient = class {
|
|
|
428
853
|
this.admin = this.kafka.admin();
|
|
429
854
|
}
|
|
430
855
|
async sendMessage(topicOrDesc, message, options = {}) {
|
|
431
|
-
const payload = await this.
|
|
856
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
432
857
|
{
|
|
433
858
|
value: message,
|
|
434
859
|
key: options.key,
|
|
@@ -438,19 +863,13 @@ var KafkaClient = class {
|
|
|
438
863
|
eventId: options.eventId
|
|
439
864
|
}
|
|
440
865
|
]);
|
|
441
|
-
await this.ensureTopic(payload.topic);
|
|
442
866
|
await this.producer.send(payload);
|
|
443
|
-
|
|
444
|
-
inst.afterSend?.(payload.topic);
|
|
445
|
-
}
|
|
867
|
+
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
446
868
|
}
|
|
447
869
|
async sendBatch(topicOrDesc, messages) {
|
|
448
|
-
const payload = await this.
|
|
449
|
-
await this.ensureTopic(payload.topic);
|
|
870
|
+
const payload = await this.preparePayload(topicOrDesc, messages);
|
|
450
871
|
await this.producer.send(payload);
|
|
451
|
-
|
|
452
|
-
inst.afterSend?.(payload.topic);
|
|
453
|
-
}
|
|
872
|
+
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
454
873
|
}
|
|
455
874
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
456
875
|
async transaction(fn) {
|
|
@@ -469,7 +888,7 @@ var KafkaClient = class {
|
|
|
469
888
|
try {
|
|
470
889
|
const ctx = {
|
|
471
890
|
send: async (topicOrDesc, message, options = {}) => {
|
|
472
|
-
const payload = await this.
|
|
891
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
473
892
|
{
|
|
474
893
|
value: message,
|
|
475
894
|
key: options.key,
|
|
@@ -479,13 +898,10 @@ var KafkaClient = class {
|
|
|
479
898
|
eventId: options.eventId
|
|
480
899
|
}
|
|
481
900
|
]);
|
|
482
|
-
await this.ensureTopic(payload.topic);
|
|
483
901
|
await tx.send(payload);
|
|
484
902
|
},
|
|
485
903
|
sendBatch: async (topicOrDesc, messages) => {
|
|
486
|
-
|
|
487
|
-
await this.ensureTopic(payload.topic);
|
|
488
|
-
await tx.send(payload);
|
|
904
|
+
await tx.send(await this.preparePayload(topicOrDesc, messages));
|
|
489
905
|
}
|
|
490
906
|
};
|
|
491
907
|
await fn(ctx);
|
|
@@ -519,151 +935,59 @@ var KafkaClient = class {
|
|
|
519
935
|
);
|
|
520
936
|
}
|
|
521
937
|
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
|
-
};
|
|
938
|
+
const deps = this.messageDeps;
|
|
528
939
|
const timeoutMs = options.handlerTimeoutMs;
|
|
529
940
|
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,
|
|
941
|
+
eachMessage: (payload) => handleEachMessage(
|
|
942
|
+
payload,
|
|
943
|
+
{
|
|
543
944
|
schemaMap,
|
|
945
|
+
handleMessage,
|
|
544
946
|
interceptors,
|
|
545
947
|
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
|
-
}
|
|
948
|
+
retry,
|
|
949
|
+
retryTopics: options.retryTopics,
|
|
950
|
+
timeoutMs,
|
|
951
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
|
|
952
|
+
},
|
|
953
|
+
deps
|
|
954
|
+
)
|
|
578
955
|
});
|
|
579
956
|
this.runningConsumers.set(gid, "eachMessage");
|
|
580
957
|
if (options.retryTopics && retry) {
|
|
581
|
-
await
|
|
958
|
+
const companions = await startRetryTopicConsumers(
|
|
582
959
|
topicNames,
|
|
583
960
|
gid,
|
|
584
961
|
handleMessage,
|
|
585
962
|
retry,
|
|
586
963
|
dlq,
|
|
587
964
|
interceptors,
|
|
588
|
-
schemaMap
|
|
965
|
+
schemaMap,
|
|
966
|
+
this.retryTopicDeps,
|
|
967
|
+
options.retryTopicAssignmentTimeoutMs
|
|
589
968
|
);
|
|
969
|
+
this.companionGroupIds.set(gid, companions);
|
|
590
970
|
}
|
|
591
971
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
592
972
|
}
|
|
593
973
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
594
974
|
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
|
-
};
|
|
975
|
+
const deps = this.messageDeps;
|
|
601
976
|
const timeoutMs = options.handlerTimeoutMs;
|
|
602
977
|
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
|
-
}
|
|
978
|
+
eachBatch: (payload) => handleEachBatch(
|
|
979
|
+
payload,
|
|
980
|
+
{
|
|
981
|
+
schemaMap,
|
|
982
|
+
handleBatch,
|
|
983
|
+
interceptors,
|
|
984
|
+
dlq,
|
|
985
|
+
retry,
|
|
986
|
+
timeoutMs,
|
|
987
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
|
|
988
|
+
},
|
|
989
|
+
deps
|
|
990
|
+
)
|
|
667
991
|
});
|
|
668
992
|
this.runningConsumers.set(gid, "eachBatch");
|
|
669
993
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
@@ -682,7 +1006,21 @@ var KafkaClient = class {
|
|
|
682
1006
|
});
|
|
683
1007
|
this.consumers.delete(groupId);
|
|
684
1008
|
this.runningConsumers.delete(groupId);
|
|
1009
|
+
this.consumerCreationOptions.delete(groupId);
|
|
685
1010
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
1011
|
+
const companions = this.companionGroupIds.get(groupId) ?? [];
|
|
1012
|
+
for (const cGroupId of companions) {
|
|
1013
|
+
const cConsumer = this.consumers.get(cGroupId);
|
|
1014
|
+
if (cConsumer) {
|
|
1015
|
+
await cConsumer.disconnect().catch(() => {
|
|
1016
|
+
});
|
|
1017
|
+
this.consumers.delete(cGroupId);
|
|
1018
|
+
this.runningConsumers.delete(cGroupId);
|
|
1019
|
+
this.consumerCreationOptions.delete(cGroupId);
|
|
1020
|
+
this.logger.log(`Retry consumer disconnected: group "${cGroupId}"`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
this.companionGroupIds.delete(groupId);
|
|
686
1024
|
} else {
|
|
687
1025
|
const tasks = Array.from(this.consumers.values()).map(
|
|
688
1026
|
(c) => c.disconnect().catch(() => {
|
|
@@ -691,6 +1029,8 @@ var KafkaClient = class {
|
|
|
691
1029
|
await Promise.allSettled(tasks);
|
|
692
1030
|
this.consumers.clear();
|
|
693
1031
|
this.runningConsumers.clear();
|
|
1032
|
+
this.consumerCreationOptions.clear();
|
|
1033
|
+
this.companionGroupIds.clear();
|
|
694
1034
|
this.logger.log("All consumers disconnected");
|
|
695
1035
|
}
|
|
696
1036
|
}
|
|
@@ -720,14 +1060,22 @@ var KafkaClient = class {
|
|
|
720
1060
|
}
|
|
721
1061
|
return result;
|
|
722
1062
|
}
|
|
723
|
-
/** Check broker connectivity
|
|
1063
|
+
/** Check broker connectivity. Never throws — returns a discriminated union. */
|
|
724
1064
|
async checkStatus() {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1065
|
+
try {
|
|
1066
|
+
if (!this.isAdminConnected) {
|
|
1067
|
+
await this.admin.connect();
|
|
1068
|
+
this.isAdminConnected = true;
|
|
1069
|
+
}
|
|
1070
|
+
const topics = await this.admin.listTopics();
|
|
1071
|
+
return { status: "up", clientId: this.clientId, topics };
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
return {
|
|
1074
|
+
status: "down",
|
|
1075
|
+
clientId: this.clientId,
|
|
1076
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1077
|
+
};
|
|
728
1078
|
}
|
|
729
|
-
const topics = await this.admin.listTopics();
|
|
730
|
-
return { status: "up", clientId: this.clientId, topics };
|
|
731
1079
|
}
|
|
732
1080
|
getClientId() {
|
|
733
1081
|
return this.clientId;
|
|
@@ -749,204 +1097,28 @@ var KafkaClient = class {
|
|
|
749
1097
|
await Promise.allSettled(tasks);
|
|
750
1098
|
this.consumers.clear();
|
|
751
1099
|
this.runningConsumers.clear();
|
|
1100
|
+
this.consumerCreationOptions.clear();
|
|
1101
|
+
this.companionGroupIds.clear();
|
|
752
1102
|
this.logger.log("All connections closed");
|
|
753
1103
|
}
|
|
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
1104
|
// ── Private helpers ──────────────────────────────────────────────
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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`
|
|
1105
|
+
async preparePayload(topicOrDesc, messages) {
|
|
1106
|
+
registerSchema(topicOrDesc, this.schemaRegistry);
|
|
1107
|
+
const payload = await buildSendPayload(
|
|
1108
|
+
topicOrDesc,
|
|
1109
|
+
messages,
|
|
1110
|
+
this.producerOpsDeps
|
|
921
1111
|
);
|
|
1112
|
+
await this.ensureTopic(payload.topic);
|
|
1113
|
+
return payload;
|
|
922
1114
|
}
|
|
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
|
-
};
|
|
1115
|
+
// afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
|
|
1116
|
+
notifyAfterSend(topic2, count) {
|
|
1117
|
+
for (let i = 0; i < count; i++) {
|
|
1118
|
+
for (const inst of this.instrumentation) {
|
|
1119
|
+
inst.afterSend?.(topic2);
|
|
946
1120
|
}
|
|
947
|
-
this.consumers.set(groupId, this.kafka.consumer(config));
|
|
948
1121
|
}
|
|
949
|
-
return this.consumers.get(groupId);
|
|
950
1122
|
}
|
|
951
1123
|
/**
|
|
952
1124
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
@@ -964,13 +1136,6 @@ var KafkaClient = class {
|
|
|
964
1136
|
}, timeoutMs);
|
|
965
1137
|
return promise;
|
|
966
1138
|
}
|
|
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
1139
|
async ensureTopic(topic2) {
|
|
975
1140
|
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
976
1141
|
if (!this.isAdminConnected) {
|
|
@@ -982,54 +1147,6 @@ var KafkaClient = class {
|
|
|
982
1147
|
});
|
|
983
1148
|
this.ensuredTopics.add(topic2);
|
|
984
1149
|
}
|
|
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
1150
|
/** Shared consumer setup: groupId check, schema map, connect, subscribe. */
|
|
1034
1151
|
async setupConsumer(topics, mode, options) {
|
|
1035
1152
|
const {
|
|
@@ -1048,15 +1165,18 @@ var KafkaClient = class {
|
|
|
1048
1165
|
`Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
|
|
1049
1166
|
);
|
|
1050
1167
|
}
|
|
1051
|
-
const consumer =
|
|
1168
|
+
const consumer = getOrCreateConsumer(
|
|
1052
1169
|
gid,
|
|
1053
1170
|
fromBeginning,
|
|
1054
|
-
options.autoCommit ?? true
|
|
1171
|
+
options.autoCommit ?? true,
|
|
1172
|
+
this.consumerOpsDeps
|
|
1055
1173
|
);
|
|
1056
|
-
const schemaMap =
|
|
1057
|
-
|
|
1058
|
-
|
|
1174
|
+
const schemaMap = buildSchemaMap(
|
|
1175
|
+
topics,
|
|
1176
|
+
this.schemaRegistry,
|
|
1177
|
+
optionSchemas
|
|
1059
1178
|
);
|
|
1179
|
+
const topicNames = topics.map((t) => resolveTopicName(t));
|
|
1060
1180
|
for (const t of topicNames) {
|
|
1061
1181
|
await this.ensureTopic(t);
|
|
1062
1182
|
}
|
|
@@ -1077,37 +1197,58 @@ var KafkaClient = class {
|
|
|
1077
1197
|
);
|
|
1078
1198
|
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
|
|
1079
1199
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1200
|
+
// ── Deps object getters ──────────────────────────────────────────
|
|
1201
|
+
get producerOpsDeps() {
|
|
1202
|
+
return {
|
|
1203
|
+
schemaRegistry: this.schemaRegistry,
|
|
1204
|
+
strictSchemasEnabled: this.strictSchemasEnabled,
|
|
1205
|
+
instrumentation: this.instrumentation
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
get consumerOpsDeps() {
|
|
1209
|
+
return {
|
|
1210
|
+
consumers: this.consumers,
|
|
1211
|
+
consumerCreationOptions: this.consumerCreationOptions,
|
|
1212
|
+
kafka: this.kafka,
|
|
1213
|
+
onRebalance: this.onRebalance,
|
|
1214
|
+
logger: this.logger
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
get messageDeps() {
|
|
1218
|
+
return {
|
|
1219
|
+
logger: this.logger,
|
|
1220
|
+
producer: this.producer,
|
|
1221
|
+
instrumentation: this.instrumentation,
|
|
1222
|
+
onMessageLost: this.onMessageLost
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
get retryTopicDeps() {
|
|
1226
|
+
return {
|
|
1227
|
+
logger: this.logger,
|
|
1228
|
+
producer: this.producer,
|
|
1229
|
+
instrumentation: this.instrumentation,
|
|
1230
|
+
onMessageLost: this.onMessageLost,
|
|
1231
|
+
ensureTopic: (t) => this.ensureTopic(t),
|
|
1232
|
+
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
|
|
1233
|
+
runningConsumers: this.runningConsumers
|
|
1234
|
+
};
|
|
1096
1235
|
}
|
|
1097
1236
|
};
|
|
1098
1237
|
|
|
1099
|
-
// src/client/topic.ts
|
|
1238
|
+
// src/client/message/topic.ts
|
|
1100
1239
|
function topic(name) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1240
|
+
return {
|
|
1241
|
+
/** Provide an explicit message type without a runtime schema. */
|
|
1242
|
+
type: () => ({
|
|
1243
|
+
__topic: name,
|
|
1244
|
+
__type: void 0
|
|
1245
|
+
}),
|
|
1246
|
+
schema: (schema) => ({
|
|
1247
|
+
__topic: name,
|
|
1248
|
+
__type: void 0,
|
|
1249
|
+
__schema: schema
|
|
1250
|
+
})
|
|
1251
|
+
};
|
|
1111
1252
|
}
|
|
1112
1253
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1113
1254
|
0 && (module.exports = {
|