@drarzter/kafka-client 0.6.6 → 0.6.9

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/core.js CHANGED
@@ -359,7 +359,10 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
359
359
  deps.logger.warn(`Message sent to DLQ: ${payload.topic}`);
360
360
  } catch (error) {
361
361
  const err = toError(error);
362
- deps.logger.error(`Failed to send message to DLQ ${payload.topic}:`, err.stack);
362
+ deps.logger.error(
363
+ `Failed to send message to DLQ ${payload.topic}:`,
364
+ err.stack
365
+ );
363
366
  await deps.onMessageLost?.({
364
367
  topic: topic2,
365
368
  error: err,
@@ -374,14 +377,9 @@ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
374
377
  var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
375
378
  function buildRetryTopicPayload(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders) {
376
379
  const retryTopic = `${originalTopic}.retry.${attempt}`;
380
+ const STRIP = /* @__PURE__ */ new Set([RETRY_HEADER_ATTEMPT, RETRY_HEADER_AFTER, RETRY_HEADER_MAX_RETRIES, RETRY_HEADER_ORIGINAL_TOPIC]);
377
381
  function buildHeaders(hdr) {
378
- const {
379
- [RETRY_HEADER_ATTEMPT]: _a,
380
- [RETRY_HEADER_AFTER]: _b,
381
- [RETRY_HEADER_MAX_RETRIES]: _c,
382
- [RETRY_HEADER_ORIGINAL_TOPIC]: _d,
383
- ...userHeaders
384
- } = hdr;
382
+ const userHeaders = Object.fromEntries(Object.entries(hdr).filter(([k]) => !STRIP.has(k)));
385
383
  return {
386
384
  ...userHeaders,
387
385
  [RETRY_HEADER_ATTEMPT]: String(attempt),
@@ -437,10 +435,18 @@ function buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, m
437
435
  "x-duplicate-incoming-clock": String(meta?.incomingClock ?? 0),
438
436
  "x-duplicate-last-processed-clock": String(meta?.lastProcessedClock ?? 0)
439
437
  };
440
- return { topic: destinationTopic, messages: [{ value: rawMessage, headers }] };
438
+ return {
439
+ topic: destinationTopic,
440
+ messages: [{ value: rawMessage, headers }]
441
+ };
441
442
  }
442
443
  async function sendToDuplicatesTopic(sourceTopic, rawMessage, destinationTopic, deps, meta) {
443
- const payload = buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, meta);
444
+ const payload = buildDuplicateTopicPayload(
445
+ sourceTopic,
446
+ rawMessage,
447
+ destinationTopic,
448
+ meta
449
+ );
444
450
  try {
445
451
  await deps.producer.send(payload);
446
452
  deps.logger.warn(`Duplicate message forwarded to ${destinationTopic}`);
@@ -532,7 +538,21 @@ async function executeWithRetry(fn, ctx, deps) {
532
538
  interceptors,
533
539
  deps.instrumentation
534
540
  );
535
- if (!error) return;
541
+ if (!error) {
542
+ if (deps.eosCommitOnSuccess) {
543
+ try {
544
+ await deps.eosCommitOnSuccess();
545
+ } catch (commitErr) {
546
+ deps.logger.error(
547
+ `EOS offset commit failed after successful handler \u2014 message will be redelivered:`,
548
+ toError(commitErr).stack
549
+ );
550
+ return;
551
+ }
552
+ }
553
+ for (const env of envelopes) deps.onMessage?.(env);
554
+ return;
555
+ }
536
556
  const isLastAttempt = attempt === maxAttempts;
537
557
  const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
538
558
  topic2,
@@ -548,15 +568,28 @@ async function executeWithRetry(fn, ctx, deps) {
548
568
  if (retryTopics && retry) {
549
569
  const cap = Math.min(backoffMs, maxBackoffMs);
550
570
  const delay = Math.floor(Math.random() * cap);
551
- await sendToRetryTopic(
552
- topic2,
553
- rawMessages,
554
- 1,
555
- retry.maxRetries,
556
- delay,
557
- isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
558
- deps
559
- );
571
+ if (deps.eosRouteToRetry) {
572
+ try {
573
+ await deps.eosRouteToRetry(rawMessages, envelopes, delay);
574
+ deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
575
+ } catch (txErr) {
576
+ deps.logger.error(
577
+ `EOS routing to retry topic failed \u2014 message will be redelivered:`,
578
+ toError(txErr).stack
579
+ );
580
+ }
581
+ } else {
582
+ await sendToRetryTopic(
583
+ topic2,
584
+ rawMessages,
585
+ 1,
586
+ retry.maxRetries,
587
+ delay,
588
+ isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
589
+ deps
590
+ );
591
+ deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
592
+ }
560
593
  } else if (isLastAttempt) {
561
594
  if (dlq) {
562
595
  for (let i = 0; i < rawMessages.length; i++) {
@@ -565,6 +598,7 @@ async function executeWithRetry(fn, ctx, deps) {
565
598
  attempt,
566
599
  originalHeaders: envelopes[i]?.headers
567
600
  });
601
+ deps.onDlq?.(envelopes[i] ?? envelopes[0], "handler-error");
568
602
  }
569
603
  } else {
570
604
  await deps.onMessageLost?.({
@@ -576,6 +610,7 @@ async function executeWithRetry(fn, ctx, deps) {
576
610
  }
577
611
  } else {
578
612
  const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
613
+ deps.onRetry?.(envelopes[0], attempt, maxAttempts - 1);
579
614
  await sleep(Math.floor(Math.random() * cap));
580
615
  }
581
616
  }
@@ -599,6 +634,7 @@ async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
599
634
  deps.logger.warn(
600
635
  `Duplicate message on ${envelope.topic}[${envelope.partition}]: clock=${incomingClock} <= last=${lastProcessedClock} \u2014 strategy=${strategy}`
601
636
  );
637
+ deps.onDuplicate?.(envelope, strategy);
602
638
  if (strategy === "dlq" && dlq) {
603
639
  const augmentedHeaders = {
604
640
  ...envelope.headers,
@@ -653,6 +689,43 @@ async function handleEachMessage(payload, opts, deps) {
653
689
  timeoutMs,
654
690
  wrapWithTimeout
655
691
  } = opts;
692
+ const eos = opts.eosMainContext;
693
+ const nextOffsetStr = (parseInt(message.offset, 10) + 1).toString();
694
+ const commitOffset = eos ? async () => {
695
+ await eos.consumer.commitOffsets([
696
+ { topic: topic2, partition, offset: nextOffsetStr }
697
+ ]);
698
+ } : void 0;
699
+ const eosRouteToRetry = eos && retry ? async (rawMsgs, envelopes, delay) => {
700
+ const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
701
+ topic2,
702
+ rawMsgs,
703
+ 1,
704
+ retry.maxRetries,
705
+ delay,
706
+ envelopes[0]?.headers ?? {}
707
+ );
708
+ const tx = await eos.txProducer.transaction();
709
+ try {
710
+ await tx.send({ topic: rtTopic, messages: rtMsgs });
711
+ await tx.sendOffsets({
712
+ consumer: eos.consumer,
713
+ topics: [
714
+ {
715
+ topic: topic2,
716
+ partitions: [{ partition, offset: nextOffsetStr }]
717
+ }
718
+ ]
719
+ });
720
+ await tx.commit();
721
+ } catch (txErr) {
722
+ try {
723
+ await tx.abort();
724
+ } catch {
725
+ }
726
+ throw txErr;
727
+ }
728
+ } : void 0;
656
729
  const envelope = await parseSingleMessage(
657
730
  message,
658
731
  topic2,
@@ -662,7 +735,10 @@ async function handleEachMessage(payload, opts, deps) {
662
735
  dlq,
663
736
  deps
664
737
  );
665
- if (envelope === null) return;
738
+ if (envelope === null) {
739
+ await commitOffset?.();
740
+ return;
741
+ }
666
742
  if (opts.deduplication) {
667
743
  const isDuplicate = await applyDeduplication(
668
744
  envelope,
@@ -671,7 +747,10 @@ async function handleEachMessage(payload, opts, deps) {
671
747
  dlq,
672
748
  deps
673
749
  );
674
- if (isDuplicate) return;
750
+ if (isDuplicate) {
751
+ await commitOffset?.();
752
+ return;
753
+ }
675
754
  }
676
755
  await executeWithRetry(
677
756
  () => {
@@ -692,7 +771,7 @@ async function handleEachMessage(payload, opts, deps) {
692
771
  retry,
693
772
  retryTopics
694
773
  },
695
- deps
774
+ { ...deps, eosRouteToRetry, eosCommitOnSuccess: commitOffset }
696
775
  );
697
776
  }
698
777
  async function handleEachBatch(payload, opts, deps) {
@@ -707,6 +786,50 @@ async function handleEachBatch(payload, opts, deps) {
707
786
  timeoutMs,
708
787
  wrapWithTimeout
709
788
  } = opts;
789
+ const eos = opts.eosMainContext;
790
+ const lastRawOffset = batch.messages.length > 0 ? batch.messages[batch.messages.length - 1].offset : void 0;
791
+ const batchNextOffsetStr = lastRawOffset ? (parseInt(lastRawOffset, 10) + 1).toString() : void 0;
792
+ const commitBatchOffset = eos && batchNextOffsetStr ? async () => {
793
+ await eos.consumer.commitOffsets([
794
+ {
795
+ topic: batch.topic,
796
+ partition: batch.partition,
797
+ offset: batchNextOffsetStr
798
+ }
799
+ ]);
800
+ } : void 0;
801
+ const eosRouteToRetry = eos && retry && batchNextOffsetStr ? async (rawMsgs, envelopes2, delay) => {
802
+ const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
803
+ batch.topic,
804
+ rawMsgs,
805
+ 1,
806
+ retry.maxRetries,
807
+ delay,
808
+ envelopes2.map((e) => e.headers)
809
+ );
810
+ const tx = await eos.txProducer.transaction();
811
+ try {
812
+ await tx.send({ topic: rtTopic, messages: rtMsgs });
813
+ await tx.sendOffsets({
814
+ consumer: eos.consumer,
815
+ topics: [
816
+ {
817
+ topic: batch.topic,
818
+ partitions: [
819
+ { partition: batch.partition, offset: batchNextOffsetStr }
820
+ ]
821
+ }
822
+ ]
823
+ });
824
+ await tx.commit();
825
+ } catch (txErr) {
826
+ try {
827
+ await tx.abort();
828
+ } catch {
829
+ }
830
+ throw txErr;
831
+ }
832
+ } : void 0;
710
833
  const envelopes = [];
711
834
  const rawMessages = [];
712
835
  for (const message of batch.messages) {
@@ -734,7 +857,10 @@ async function handleEachBatch(payload, opts, deps) {
734
857
  envelopes.push(envelope);
735
858
  rawMessages.push(message.value.toString());
736
859
  }
737
- if (envelopes.length === 0) return;
860
+ if (envelopes.length === 0) {
861
+ await commitBatchOffset?.();
862
+ return;
863
+ }
738
864
  const meta = {
739
865
  partition: batch.partition,
740
866
  highWatermark: batch.highWatermark,
@@ -756,7 +882,7 @@ async function handleEachBatch(payload, opts, deps) {
756
882
  isBatch: true,
757
883
  retryTopics
758
884
  },
759
- deps
885
+ { ...deps, eosRouteToRetry, eosCommitOnSuccess: commitBatchOffset }
760
886
  );
761
887
  }
762
888
 
@@ -802,6 +928,9 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
802
928
  producer,
803
929
  instrumentation,
804
930
  onMessageLost,
931
+ onRetry,
932
+ onDlq,
933
+ onMessage,
805
934
  ensureTopic,
806
935
  getOrCreateConsumer: getOrCreateConsumer2,
807
936
  runningConsumers,
@@ -883,6 +1012,7 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
883
1012
  instrumentation
884
1013
  );
885
1014
  if (!error) {
1015
+ onMessage?.(envelope);
886
1016
  await consumer.commitOffsets([nextOffset]);
887
1017
  return;
888
1018
  }
@@ -915,12 +1045,23 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
915
1045
  await tx.send({ topic: rtTopic, messages: rtMsgs });
916
1046
  await tx.sendOffsets({
917
1047
  consumer,
918
- topics: [{ topic: nextOffset.topic, partitions: [{ partition: nextOffset.partition, offset: nextOffset.offset }] }]
1048
+ topics: [
1049
+ {
1050
+ topic: nextOffset.topic,
1051
+ partitions: [
1052
+ {
1053
+ partition: nextOffset.partition,
1054
+ offset: nextOffset.offset
1055
+ }
1056
+ ]
1057
+ }
1058
+ ]
919
1059
  });
920
1060
  await tx.commit();
921
1061
  logger.warn(
922
1062
  `Message routed to ${rtTopic} (EOS, level ${nextLevel}/${currentMaxRetries})`
923
1063
  );
1064
+ onRetry?.(envelope, nextLevel, currentMaxRetries);
924
1065
  } catch (txErr) {
925
1066
  try {
926
1067
  await tx.abort();
@@ -948,10 +1089,21 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
948
1089
  await tx.send({ topic: dTopic, messages: dMsgs });
949
1090
  await tx.sendOffsets({
950
1091
  consumer,
951
- topics: [{ topic: nextOffset.topic, partitions: [{ partition: nextOffset.partition, offset: nextOffset.offset }] }]
1092
+ topics: [
1093
+ {
1094
+ topic: nextOffset.topic,
1095
+ partitions: [
1096
+ {
1097
+ partition: nextOffset.partition,
1098
+ offset: nextOffset.offset
1099
+ }
1100
+ ]
1101
+ }
1102
+ ]
952
1103
  });
953
1104
  await tx.commit();
954
1105
  logger.warn(`Message sent to DLQ: ${dTopic} (EOS)`);
1106
+ onDlq?.(envelope, "handler-error");
955
1107
  } catch (txErr) {
956
1108
  try {
957
1109
  await tx.abort();
@@ -975,7 +1127,12 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
975
1127
  }
976
1128
  });
977
1129
  runningConsumers.set(levelGroupId, "eachMessage");
978
- await waitForPartitionAssignment(consumer, levelTopics, logger, assignmentTimeoutMs);
1130
+ await waitForPartitionAssignment(
1131
+ consumer,
1132
+ levelTopics,
1133
+ logger,
1134
+ assignmentTimeoutMs
1135
+ );
979
1136
  logger.log(
980
1137
  `Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
981
1138
  );
@@ -1005,7 +1162,8 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
1005
1162
 
1006
1163
  // src/client/kafka.client/index.ts
1007
1164
  var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
1008
- var KafkaClient = class {
1165
+ var _activeTransactionalIds = /* @__PURE__ */ new Set();
1166
+ var KafkaClient = class _KafkaClient {
1009
1167
  kafka;
1010
1168
  producer;
1011
1169
  txProducer;
@@ -1030,6 +1188,10 @@ var KafkaClient = class {
1030
1188
  instrumentation;
1031
1189
  onMessageLost;
1032
1190
  onRebalance;
1191
+ /** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
1192
+ txId;
1193
+ /** Per-topic event counters, lazily created on first event. Aggregated by `getMetrics()`. */
1194
+ _topicMetrics = /* @__PURE__ */ new Map();
1033
1195
  /** Monotonically increasing Lamport clock stamped on every outgoing message. */
1034
1196
  _lamportClock = 0;
1035
1197
  /** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
@@ -1053,6 +1215,7 @@ var KafkaClient = class {
1053
1215
  this.instrumentation = options?.instrumentation ?? [];
1054
1216
  this.onMessageLost = options?.onMessageLost;
1055
1217
  this.onRebalance = options?.onRebalance;
1218
+ this.txId = options?.transactionalId ?? `${clientId}-tx`;
1056
1219
  this.kafka = new KafkaClass({
1057
1220
  kafkaJS: {
1058
1221
  clientId: this.clientId,
@@ -1089,16 +1252,22 @@ var KafkaClient = class {
1089
1252
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
1090
1253
  async transaction(fn) {
1091
1254
  if (!this.txProducerInitPromise) {
1255
+ if (_activeTransactionalIds.has(this.txId)) {
1256
+ this.logger.warn(
1257
+ `transactionalId "${this.txId}" is already in use by another KafkaClient in this process. Kafka will fence one of the producers. Set a unique \`transactionalId\` (or distinct \`clientId\`) per instance.`
1258
+ );
1259
+ }
1092
1260
  const initPromise = (async () => {
1093
1261
  const p = this.kafka.producer({
1094
1262
  kafkaJS: {
1095
1263
  acks: -1,
1096
1264
  idempotent: true,
1097
- transactionalId: `${this.clientId}-tx`,
1265
+ transactionalId: this.txId,
1098
1266
  maxInFlightRequests: 1
1099
1267
  }
1100
1268
  });
1101
1269
  await p.connect();
1270
+ _activeTransactionalIds.add(this.txId);
1102
1271
  return p;
1103
1272
  })();
1104
1273
  this.txProducerInitPromise = initPromise.catch((err) => {
@@ -1166,10 +1335,20 @@ var KafkaClient = class {
1166
1335
  "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
1167
1336
  );
1168
1337
  }
1169
- const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
1338
+ const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
1339
+ const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", setupOptions);
1170
1340
  const deps = this.messageDeps;
1171
1341
  const timeoutMs = options.handlerTimeoutMs;
1172
- const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1342
+ const deduplication = this.resolveDeduplicationContext(
1343
+ gid,
1344
+ options.deduplication
1345
+ );
1346
+ let eosMainContext;
1347
+ if (options.retryTopics && retry) {
1348
+ const mainTxId = `${gid}-main-tx`;
1349
+ const txProducer = await this.createRetryTxProducer(mainTxId);
1350
+ eosMainContext = { txProducer, consumer };
1351
+ }
1173
1352
  await consumer.run({
1174
1353
  eachMessage: (payload) => this.trackInFlight(
1175
1354
  () => handleEachMessage(
@@ -1183,7 +1362,8 @@ var KafkaClient = class {
1183
1362
  retryTopics: options.retryTopics,
1184
1363
  timeoutMs,
1185
1364
  wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1186
- deduplication
1365
+ deduplication,
1366
+ eosMainContext
1187
1367
  },
1188
1368
  deps
1189
1369
  )
@@ -1215,15 +1395,26 @@ var KafkaClient = class {
1215
1395
  "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
1216
1396
  );
1217
1397
  }
1218
- if (options.autoCommit !== false) {
1398
+ if (options.retryTopics) {
1399
+ } else if (options.autoCommit !== false) {
1219
1400
  this.logger.debug?.(
1220
1401
  `startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
1221
1402
  );
1222
1403
  }
1223
- const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
1404
+ const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
1405
+ const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", setupOptions);
1224
1406
  const deps = this.messageDeps;
1225
1407
  const timeoutMs = options.handlerTimeoutMs;
1226
- const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1408
+ const deduplication = this.resolveDeduplicationContext(
1409
+ gid,
1410
+ options.deduplication
1411
+ );
1412
+ let eosMainContext;
1413
+ if (options.retryTopics && retry) {
1414
+ const mainTxId = `${gid}-main-tx`;
1415
+ const txProducer = await this.createRetryTxProducer(mainTxId);
1416
+ eosMainContext = { txProducer, consumer };
1417
+ }
1227
1418
  await consumer.run({
1228
1419
  eachBatch: (payload) => this.trackInFlight(
1229
1420
  () => handleEachBatch(
@@ -1237,7 +1428,8 @@ var KafkaClient = class {
1237
1428
  retryTopics: options.retryTopics,
1238
1429
  timeoutMs,
1239
1430
  wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1240
- deduplication
1431
+ deduplication,
1432
+ eosMainContext
1241
1433
  },
1242
1434
  deps
1243
1435
  )
@@ -1250,7 +1442,7 @@ var KafkaClient = class {
1250
1442
  }
1251
1443
  const handleMessageForRetry = (env) => handleBatch([env], {
1252
1444
  partition: env.partition,
1253
- highWatermark: env.offset,
1445
+ highWatermark: null,
1254
1446
  heartbeat: async () => {
1255
1447
  },
1256
1448
  resolveOffset: () => {
@@ -1294,6 +1486,18 @@ var KafkaClient = class {
1294
1486
  this.consumerCreationOptions.delete(groupId);
1295
1487
  this.dedupStates.delete(groupId);
1296
1488
  this.logger.log(`Consumer disconnected: group "${groupId}"`);
1489
+ const mainTxId = `${groupId}-main-tx`;
1490
+ const mainTxProducer = this.retryTxProducers.get(mainTxId);
1491
+ if (mainTxProducer) {
1492
+ await mainTxProducer.disconnect().catch(
1493
+ (e) => this.logger.warn(
1494
+ `Error disconnecting main tx producer "${mainTxId}":`,
1495
+ toError(e).message
1496
+ )
1497
+ );
1498
+ _activeTransactionalIds.delete(mainTxId);
1499
+ this.retryTxProducers.delete(mainTxId);
1500
+ }
1297
1501
  const companions = this.companionGroupIds.get(groupId) ?? [];
1298
1502
  for (const cGroupId of companions) {
1299
1503
  const cConsumer = this.consumers.get(cGroupId);
@@ -1318,6 +1522,7 @@ var KafkaClient = class {
1318
1522
  toError(e).message
1319
1523
  )
1320
1524
  );
1525
+ _activeTransactionalIds.delete(txId);
1321
1526
  this.retryTxProducers.delete(txId);
1322
1527
  }
1323
1528
  }
@@ -1343,6 +1548,144 @@ var KafkaClient = class {
1343
1548
  this.logger.log("All consumers disconnected");
1344
1549
  }
1345
1550
  }
1551
+ pauseConsumer(groupId, assignments) {
1552
+ const gid = groupId ?? this.defaultGroupId;
1553
+ const consumer = this.consumers.get(gid);
1554
+ if (!consumer) {
1555
+ this.logger.warn(`pauseConsumer: no active consumer for group "${gid}"`);
1556
+ return;
1557
+ }
1558
+ consumer.pause(
1559
+ assignments.flatMap(
1560
+ ({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
1561
+ )
1562
+ );
1563
+ }
1564
+ resumeConsumer(groupId, assignments) {
1565
+ const gid = groupId ?? this.defaultGroupId;
1566
+ const consumer = this.consumers.get(gid);
1567
+ if (!consumer) {
1568
+ this.logger.warn(`resumeConsumer: no active consumer for group "${gid}"`);
1569
+ return;
1570
+ }
1571
+ consumer.resume(
1572
+ assignments.flatMap(
1573
+ ({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
1574
+ )
1575
+ );
1576
+ }
1577
+ /** DLQ header keys added by `sendToDlq` — stripped before re-publishing. */
1578
+ static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
1579
+ "x-dlq-original-topic",
1580
+ "x-dlq-failed-at",
1581
+ "x-dlq-error-message",
1582
+ "x-dlq-error-stack",
1583
+ "x-dlq-attempt-count"
1584
+ ]);
1585
+ async replayDlq(topic2, options = {}) {
1586
+ const dlqTopic = `${topic2}.dlq`;
1587
+ await this.ensureAdminConnected();
1588
+ const partitionOffsets = await this.admin.fetchTopicOffsets(dlqTopic);
1589
+ const activePartitions = partitionOffsets.filter(
1590
+ (p) => parseInt(p.high, 10) > 0
1591
+ );
1592
+ if (activePartitions.length === 0) {
1593
+ this.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
1594
+ return { replayed: 0, skipped: 0 };
1595
+ }
1596
+ const highWatermarks = new Map(
1597
+ activePartitions.map(({ partition, high }) => [
1598
+ partition,
1599
+ parseInt(high, 10)
1600
+ ])
1601
+ );
1602
+ const processedOffsets = /* @__PURE__ */ new Map();
1603
+ let replayed = 0;
1604
+ let skipped = 0;
1605
+ const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
1606
+ await new Promise((resolve, reject) => {
1607
+ const consumer = getOrCreateConsumer(
1608
+ tempGroupId,
1609
+ true,
1610
+ true,
1611
+ this.consumerOpsDeps
1612
+ );
1613
+ const cleanup = () => {
1614
+ consumer.disconnect().catch(() => {
1615
+ }).finally(() => {
1616
+ this.consumers.delete(tempGroupId);
1617
+ this.runningConsumers.delete(tempGroupId);
1618
+ this.consumerCreationOptions.delete(tempGroupId);
1619
+ });
1620
+ };
1621
+ consumer.connect().then(
1622
+ () => subscribeWithRetry(consumer, [dlqTopic], this.logger)
1623
+ ).then(
1624
+ () => consumer.run({
1625
+ eachMessage: async ({ partition, message }) => {
1626
+ if (!message.value) return;
1627
+ const offset = parseInt(message.offset, 10);
1628
+ processedOffsets.set(partition, offset);
1629
+ const headers = decodeHeaders(message.headers);
1630
+ const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
1631
+ const originalHeaders = Object.fromEntries(
1632
+ Object.entries(headers).filter(
1633
+ ([k]) => !_KafkaClient.DLQ_HEADER_KEYS.has(k)
1634
+ )
1635
+ );
1636
+ const value = message.value.toString();
1637
+ const shouldProcess = !options.filter || options.filter(headers, value);
1638
+ if (!targetTopic || !shouldProcess) {
1639
+ skipped++;
1640
+ } else if (options.dryRun) {
1641
+ this.logger.log(
1642
+ `[DLQ replay dry-run] Would replay to "${targetTopic}"`
1643
+ );
1644
+ replayed++;
1645
+ } else {
1646
+ await this.producer.send({
1647
+ topic: targetTopic,
1648
+ messages: [{ value, headers: originalHeaders }]
1649
+ });
1650
+ replayed++;
1651
+ }
1652
+ const allDone = Array.from(highWatermarks.entries()).every(
1653
+ ([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
1654
+ );
1655
+ if (allDone) {
1656
+ cleanup();
1657
+ resolve();
1658
+ }
1659
+ }
1660
+ })
1661
+ ).catch((err) => {
1662
+ cleanup();
1663
+ reject(err);
1664
+ });
1665
+ });
1666
+ this.logger.log(
1667
+ `replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
1668
+ );
1669
+ return { replayed, skipped };
1670
+ }
1671
+ async resetOffsets(groupId, topic2, position) {
1672
+ const gid = groupId ?? this.defaultGroupId;
1673
+ if (this.runningConsumers.has(gid)) {
1674
+ throw new Error(
1675
+ `resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
1676
+ );
1677
+ }
1678
+ await this.ensureAdminConnected();
1679
+ const partitionOffsets = await this.admin.fetchTopicOffsets(topic2);
1680
+ const partitions = partitionOffsets.map(({ partition, low, high }) => ({
1681
+ partition,
1682
+ offset: position === "earliest" ? low : high
1683
+ }));
1684
+ await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
1685
+ this.logger.log(
1686
+ `Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
1687
+ );
1688
+ }
1346
1689
  /**
1347
1690
  * Query consumer group lag per partition.
1348
1691
  * Lag = broker high-watermark − last committed offset.
@@ -1393,15 +1736,45 @@ var KafkaClient = class {
1393
1736
  getClientId() {
1394
1737
  return this.clientId;
1395
1738
  }
1739
+ getMetrics(topic2) {
1740
+ if (topic2 !== void 0) {
1741
+ const m = this._topicMetrics.get(topic2);
1742
+ return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1743
+ }
1744
+ const agg = {
1745
+ processedCount: 0,
1746
+ retryCount: 0,
1747
+ dlqCount: 0,
1748
+ dedupCount: 0
1749
+ };
1750
+ for (const m of this._topicMetrics.values()) {
1751
+ agg.processedCount += m.processedCount;
1752
+ agg.retryCount += m.retryCount;
1753
+ agg.dlqCount += m.dlqCount;
1754
+ agg.dedupCount += m.dedupCount;
1755
+ }
1756
+ return agg;
1757
+ }
1758
+ resetMetrics(topic2) {
1759
+ if (topic2 !== void 0) {
1760
+ this._topicMetrics.delete(topic2);
1761
+ return;
1762
+ }
1763
+ this._topicMetrics.clear();
1764
+ }
1396
1765
  /** Gracefully disconnect producer, all consumers, and admin. */
1397
1766
  async disconnect(drainTimeoutMs = 3e4) {
1398
1767
  await this.waitForDrain(drainTimeoutMs);
1399
1768
  const tasks = [this.producer.disconnect()];
1400
1769
  if (this.txProducer) {
1401
1770
  tasks.push(this.txProducer.disconnect());
1771
+ _activeTransactionalIds.delete(this.txId);
1402
1772
  this.txProducer = void 0;
1403
1773
  this.txProducerInitPromise = void 0;
1404
1774
  }
1775
+ for (const txId of this.retryTxProducers.keys()) {
1776
+ _activeTransactionalIds.delete(txId);
1777
+ }
1405
1778
  for (const p of this.retryTxProducers.values()) {
1406
1779
  tasks.push(p.disconnect());
1407
1780
  }
@@ -1497,6 +1870,38 @@ var KafkaClient = class {
1497
1870
  }
1498
1871
  }
1499
1872
  }
1873
+ metricsFor(topic2) {
1874
+ let m = this._topicMetrics.get(topic2);
1875
+ if (!m) {
1876
+ m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1877
+ this._topicMetrics.set(topic2, m);
1878
+ }
1879
+ return m;
1880
+ }
1881
+ notifyRetry(envelope, attempt, maxRetries) {
1882
+ this.metricsFor(envelope.topic).retryCount++;
1883
+ for (const inst of this.instrumentation) {
1884
+ inst.onRetry?.(envelope, attempt, maxRetries);
1885
+ }
1886
+ }
1887
+ notifyDlq(envelope, reason) {
1888
+ this.metricsFor(envelope.topic).dlqCount++;
1889
+ for (const inst of this.instrumentation) {
1890
+ inst.onDlq?.(envelope, reason);
1891
+ }
1892
+ }
1893
+ notifyDuplicate(envelope, strategy) {
1894
+ this.metricsFor(envelope.topic).dedupCount++;
1895
+ for (const inst of this.instrumentation) {
1896
+ inst.onDuplicate?.(envelope, strategy);
1897
+ }
1898
+ }
1899
+ notifyMessage(envelope) {
1900
+ this.metricsFor(envelope.topic).processedCount++;
1901
+ for (const inst of this.instrumentation) {
1902
+ inst.onMessage?.(envelope);
1903
+ }
1904
+ }
1500
1905
  /**
1501
1906
  * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
1502
1907
  * The handler itself is not cancelled — the warning is diagnostic only.
@@ -1586,6 +1991,11 @@ var KafkaClient = class {
1586
1991
  * so Kafka can fence stale producers on restart without affecting other levels.
1587
1992
  */
1588
1993
  async createRetryTxProducer(transactionalId) {
1994
+ if (_activeTransactionalIds.has(transactionalId)) {
1995
+ this.logger.warn(
1996
+ `transactionalId "${transactionalId}" is already in use by another KafkaClient in this process. Kafka will fence one of the producers. Set a unique \`transactionalId\` (or distinct \`clientId\`) per instance.`
1997
+ );
1998
+ }
1589
1999
  const p = this.kafka.producer({
1590
2000
  kafkaJS: {
1591
2001
  acks: -1,
@@ -1595,6 +2005,7 @@ var KafkaClient = class {
1595
2005
  }
1596
2006
  });
1597
2007
  await p.connect();
2008
+ _activeTransactionalIds.add(transactionalId);
1598
2009
  this.retryTxProducers.set(transactionalId, p);
1599
2010
  return p;
1600
2011
  }
@@ -1715,7 +2126,11 @@ var KafkaClient = class {
1715
2126
  logger: this.logger,
1716
2127
  producer: this.producer,
1717
2128
  instrumentation: this.instrumentation,
1718
- onMessageLost: this.onMessageLost
2129
+ onMessageLost: this.onMessageLost,
2130
+ onRetry: this.notifyRetry.bind(this),
2131
+ onDlq: this.notifyDlq.bind(this),
2132
+ onDuplicate: this.notifyDuplicate.bind(this),
2133
+ onMessage: this.notifyMessage.bind(this)
1719
2134
  };
1720
2135
  }
1721
2136
  get retryTopicDeps() {
@@ -1724,6 +2139,9 @@ var KafkaClient = class {
1724
2139
  producer: this.producer,
1725
2140
  instrumentation: this.instrumentation,
1726
2141
  onMessageLost: this.onMessageLost,
2142
+ onRetry: this.notifyRetry.bind(this),
2143
+ onDlq: this.notifyDlq.bind(this),
2144
+ onMessage: this.notifyMessage.bind(this),
1727
2145
  ensureTopic: (t) => this.ensureTopic(t),
1728
2146
  getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
1729
2147
  runningConsumers: this.runningConsumers,