@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/index.js CHANGED
@@ -376,7 +376,10 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
376
376
  deps.logger.warn(`Message sent to DLQ: ${payload.topic}`);
377
377
  } catch (error) {
378
378
  const err = toError(error);
379
- deps.logger.error(`Failed to send message to DLQ ${payload.topic}:`, err.stack);
379
+ deps.logger.error(
380
+ `Failed to send message to DLQ ${payload.topic}:`,
381
+ err.stack
382
+ );
380
383
  await deps.onMessageLost?.({
381
384
  topic: topic2,
382
385
  error: err,
@@ -391,14 +394,9 @@ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
391
394
  var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
392
395
  function buildRetryTopicPayload(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders) {
393
396
  const retryTopic = `${originalTopic}.retry.${attempt}`;
397
+ const STRIP = /* @__PURE__ */ new Set([RETRY_HEADER_ATTEMPT, RETRY_HEADER_AFTER, RETRY_HEADER_MAX_RETRIES, RETRY_HEADER_ORIGINAL_TOPIC]);
394
398
  function buildHeaders(hdr) {
395
- const {
396
- [RETRY_HEADER_ATTEMPT]: _a,
397
- [RETRY_HEADER_AFTER]: _b,
398
- [RETRY_HEADER_MAX_RETRIES]: _c,
399
- [RETRY_HEADER_ORIGINAL_TOPIC]: _d,
400
- ...userHeaders
401
- } = hdr;
399
+ const userHeaders = Object.fromEntries(Object.entries(hdr).filter(([k]) => !STRIP.has(k)));
402
400
  return {
403
401
  ...userHeaders,
404
402
  [RETRY_HEADER_ATTEMPT]: String(attempt),
@@ -454,10 +452,18 @@ function buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, m
454
452
  "x-duplicate-incoming-clock": String(meta?.incomingClock ?? 0),
455
453
  "x-duplicate-last-processed-clock": String(meta?.lastProcessedClock ?? 0)
456
454
  };
457
- return { topic: destinationTopic, messages: [{ value: rawMessage, headers }] };
455
+ return {
456
+ topic: destinationTopic,
457
+ messages: [{ value: rawMessage, headers }]
458
+ };
458
459
  }
459
460
  async function sendToDuplicatesTopic(sourceTopic, rawMessage, destinationTopic, deps, meta) {
460
- const payload = buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, meta);
461
+ const payload = buildDuplicateTopicPayload(
462
+ sourceTopic,
463
+ rawMessage,
464
+ destinationTopic,
465
+ meta
466
+ );
461
467
  try {
462
468
  await deps.producer.send(payload);
463
469
  deps.logger.warn(`Duplicate message forwarded to ${destinationTopic}`);
@@ -549,7 +555,21 @@ async function executeWithRetry(fn, ctx, deps) {
549
555
  interceptors,
550
556
  deps.instrumentation
551
557
  );
552
- if (!error) return;
558
+ if (!error) {
559
+ if (deps.eosCommitOnSuccess) {
560
+ try {
561
+ await deps.eosCommitOnSuccess();
562
+ } catch (commitErr) {
563
+ deps.logger.error(
564
+ `EOS offset commit failed after successful handler \u2014 message will be redelivered:`,
565
+ toError(commitErr).stack
566
+ );
567
+ return;
568
+ }
569
+ }
570
+ for (const env of envelopes) deps.onMessage?.(env);
571
+ return;
572
+ }
553
573
  const isLastAttempt = attempt === maxAttempts;
554
574
  const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
555
575
  topic2,
@@ -565,15 +585,28 @@ async function executeWithRetry(fn, ctx, deps) {
565
585
  if (retryTopics && retry) {
566
586
  const cap = Math.min(backoffMs, maxBackoffMs);
567
587
  const delay = Math.floor(Math.random() * cap);
568
- await sendToRetryTopic(
569
- topic2,
570
- rawMessages,
571
- 1,
572
- retry.maxRetries,
573
- delay,
574
- isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
575
- deps
576
- );
588
+ if (deps.eosRouteToRetry) {
589
+ try {
590
+ await deps.eosRouteToRetry(rawMessages, envelopes, delay);
591
+ deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
592
+ } catch (txErr) {
593
+ deps.logger.error(
594
+ `EOS routing to retry topic failed \u2014 message will be redelivered:`,
595
+ toError(txErr).stack
596
+ );
597
+ }
598
+ } else {
599
+ await sendToRetryTopic(
600
+ topic2,
601
+ rawMessages,
602
+ 1,
603
+ retry.maxRetries,
604
+ delay,
605
+ isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
606
+ deps
607
+ );
608
+ deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
609
+ }
577
610
  } else if (isLastAttempt) {
578
611
  if (dlq) {
579
612
  for (let i = 0; i < rawMessages.length; i++) {
@@ -582,6 +615,7 @@ async function executeWithRetry(fn, ctx, deps) {
582
615
  attempt,
583
616
  originalHeaders: envelopes[i]?.headers
584
617
  });
618
+ deps.onDlq?.(envelopes[i] ?? envelopes[0], "handler-error");
585
619
  }
586
620
  } else {
587
621
  await deps.onMessageLost?.({
@@ -593,6 +627,7 @@ async function executeWithRetry(fn, ctx, deps) {
593
627
  }
594
628
  } else {
595
629
  const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
630
+ deps.onRetry?.(envelopes[0], attempt, maxAttempts - 1);
596
631
  await sleep(Math.floor(Math.random() * cap));
597
632
  }
598
633
  }
@@ -616,6 +651,7 @@ async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
616
651
  deps.logger.warn(
617
652
  `Duplicate message on ${envelope.topic}[${envelope.partition}]: clock=${incomingClock} <= last=${lastProcessedClock} \u2014 strategy=${strategy}`
618
653
  );
654
+ deps.onDuplicate?.(envelope, strategy);
619
655
  if (strategy === "dlq" && dlq) {
620
656
  const augmentedHeaders = {
621
657
  ...envelope.headers,
@@ -670,6 +706,43 @@ async function handleEachMessage(payload, opts, deps) {
670
706
  timeoutMs,
671
707
  wrapWithTimeout
672
708
  } = opts;
709
+ const eos = opts.eosMainContext;
710
+ const nextOffsetStr = (parseInt(message.offset, 10) + 1).toString();
711
+ const commitOffset = eos ? async () => {
712
+ await eos.consumer.commitOffsets([
713
+ { topic: topic2, partition, offset: nextOffsetStr }
714
+ ]);
715
+ } : void 0;
716
+ const eosRouteToRetry = eos && retry ? async (rawMsgs, envelopes, delay) => {
717
+ const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
718
+ topic2,
719
+ rawMsgs,
720
+ 1,
721
+ retry.maxRetries,
722
+ delay,
723
+ envelopes[0]?.headers ?? {}
724
+ );
725
+ const tx = await eos.txProducer.transaction();
726
+ try {
727
+ await tx.send({ topic: rtTopic, messages: rtMsgs });
728
+ await tx.sendOffsets({
729
+ consumer: eos.consumer,
730
+ topics: [
731
+ {
732
+ topic: topic2,
733
+ partitions: [{ partition, offset: nextOffsetStr }]
734
+ }
735
+ ]
736
+ });
737
+ await tx.commit();
738
+ } catch (txErr) {
739
+ try {
740
+ await tx.abort();
741
+ } catch {
742
+ }
743
+ throw txErr;
744
+ }
745
+ } : void 0;
673
746
  const envelope = await parseSingleMessage(
674
747
  message,
675
748
  topic2,
@@ -679,7 +752,10 @@ async function handleEachMessage(payload, opts, deps) {
679
752
  dlq,
680
753
  deps
681
754
  );
682
- if (envelope === null) return;
755
+ if (envelope === null) {
756
+ await commitOffset?.();
757
+ return;
758
+ }
683
759
  if (opts.deduplication) {
684
760
  const isDuplicate = await applyDeduplication(
685
761
  envelope,
@@ -688,7 +764,10 @@ async function handleEachMessage(payload, opts, deps) {
688
764
  dlq,
689
765
  deps
690
766
  );
691
- if (isDuplicate) return;
767
+ if (isDuplicate) {
768
+ await commitOffset?.();
769
+ return;
770
+ }
692
771
  }
693
772
  await executeWithRetry(
694
773
  () => {
@@ -709,7 +788,7 @@ async function handleEachMessage(payload, opts, deps) {
709
788
  retry,
710
789
  retryTopics
711
790
  },
712
- deps
791
+ { ...deps, eosRouteToRetry, eosCommitOnSuccess: commitOffset }
713
792
  );
714
793
  }
715
794
  async function handleEachBatch(payload, opts, deps) {
@@ -724,6 +803,50 @@ async function handleEachBatch(payload, opts, deps) {
724
803
  timeoutMs,
725
804
  wrapWithTimeout
726
805
  } = opts;
806
+ const eos = opts.eosMainContext;
807
+ const lastRawOffset = batch.messages.length > 0 ? batch.messages[batch.messages.length - 1].offset : void 0;
808
+ const batchNextOffsetStr = lastRawOffset ? (parseInt(lastRawOffset, 10) + 1).toString() : void 0;
809
+ const commitBatchOffset = eos && batchNextOffsetStr ? async () => {
810
+ await eos.consumer.commitOffsets([
811
+ {
812
+ topic: batch.topic,
813
+ partition: batch.partition,
814
+ offset: batchNextOffsetStr
815
+ }
816
+ ]);
817
+ } : void 0;
818
+ const eosRouteToRetry = eos && retry && batchNextOffsetStr ? async (rawMsgs, envelopes2, delay) => {
819
+ const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
820
+ batch.topic,
821
+ rawMsgs,
822
+ 1,
823
+ retry.maxRetries,
824
+ delay,
825
+ envelopes2.map((e) => e.headers)
826
+ );
827
+ const tx = await eos.txProducer.transaction();
828
+ try {
829
+ await tx.send({ topic: rtTopic, messages: rtMsgs });
830
+ await tx.sendOffsets({
831
+ consumer: eos.consumer,
832
+ topics: [
833
+ {
834
+ topic: batch.topic,
835
+ partitions: [
836
+ { partition: batch.partition, offset: batchNextOffsetStr }
837
+ ]
838
+ }
839
+ ]
840
+ });
841
+ await tx.commit();
842
+ } catch (txErr) {
843
+ try {
844
+ await tx.abort();
845
+ } catch {
846
+ }
847
+ throw txErr;
848
+ }
849
+ } : void 0;
727
850
  const envelopes = [];
728
851
  const rawMessages = [];
729
852
  for (const message of batch.messages) {
@@ -751,7 +874,10 @@ async function handleEachBatch(payload, opts, deps) {
751
874
  envelopes.push(envelope);
752
875
  rawMessages.push(message.value.toString());
753
876
  }
754
- if (envelopes.length === 0) return;
877
+ if (envelopes.length === 0) {
878
+ await commitBatchOffset?.();
879
+ return;
880
+ }
755
881
  const meta = {
756
882
  partition: batch.partition,
757
883
  highWatermark: batch.highWatermark,
@@ -773,7 +899,7 @@ async function handleEachBatch(payload, opts, deps) {
773
899
  isBatch: true,
774
900
  retryTopics
775
901
  },
776
- deps
902
+ { ...deps, eosRouteToRetry, eosCommitOnSuccess: commitBatchOffset }
777
903
  );
778
904
  }
779
905
 
@@ -819,6 +945,9 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
819
945
  producer,
820
946
  instrumentation,
821
947
  onMessageLost,
948
+ onRetry,
949
+ onDlq,
950
+ onMessage,
822
951
  ensureTopic,
823
952
  getOrCreateConsumer: getOrCreateConsumer2,
824
953
  runningConsumers,
@@ -900,6 +1029,7 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
900
1029
  instrumentation
901
1030
  );
902
1031
  if (!error) {
1032
+ onMessage?.(envelope);
903
1033
  await consumer.commitOffsets([nextOffset]);
904
1034
  return;
905
1035
  }
@@ -932,12 +1062,23 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
932
1062
  await tx.send({ topic: rtTopic, messages: rtMsgs });
933
1063
  await tx.sendOffsets({
934
1064
  consumer,
935
- topics: [{ topic: nextOffset.topic, partitions: [{ partition: nextOffset.partition, offset: nextOffset.offset }] }]
1065
+ topics: [
1066
+ {
1067
+ topic: nextOffset.topic,
1068
+ partitions: [
1069
+ {
1070
+ partition: nextOffset.partition,
1071
+ offset: nextOffset.offset
1072
+ }
1073
+ ]
1074
+ }
1075
+ ]
936
1076
  });
937
1077
  await tx.commit();
938
1078
  logger.warn(
939
1079
  `Message routed to ${rtTopic} (EOS, level ${nextLevel}/${currentMaxRetries})`
940
1080
  );
1081
+ onRetry?.(envelope, nextLevel, currentMaxRetries);
941
1082
  } catch (txErr) {
942
1083
  try {
943
1084
  await tx.abort();
@@ -965,10 +1106,21 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
965
1106
  await tx.send({ topic: dTopic, messages: dMsgs });
966
1107
  await tx.sendOffsets({
967
1108
  consumer,
968
- topics: [{ topic: nextOffset.topic, partitions: [{ partition: nextOffset.partition, offset: nextOffset.offset }] }]
1109
+ topics: [
1110
+ {
1111
+ topic: nextOffset.topic,
1112
+ partitions: [
1113
+ {
1114
+ partition: nextOffset.partition,
1115
+ offset: nextOffset.offset
1116
+ }
1117
+ ]
1118
+ }
1119
+ ]
969
1120
  });
970
1121
  await tx.commit();
971
1122
  logger.warn(`Message sent to DLQ: ${dTopic} (EOS)`);
1123
+ onDlq?.(envelope, "handler-error");
972
1124
  } catch (txErr) {
973
1125
  try {
974
1126
  await tx.abort();
@@ -992,7 +1144,12 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
992
1144
  }
993
1145
  });
994
1146
  runningConsumers.set(levelGroupId, "eachMessage");
995
- await waitForPartitionAssignment(consumer, levelTopics, logger, assignmentTimeoutMs);
1147
+ await waitForPartitionAssignment(
1148
+ consumer,
1149
+ levelTopics,
1150
+ logger,
1151
+ assignmentTimeoutMs
1152
+ );
996
1153
  logger.log(
997
1154
  `Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
998
1155
  );
@@ -1022,7 +1179,8 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
1022
1179
 
1023
1180
  // src/client/kafka.client/index.ts
1024
1181
  var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
1025
- var KafkaClient = class {
1182
+ var _activeTransactionalIds = /* @__PURE__ */ new Set();
1183
+ var KafkaClient = class _KafkaClient {
1026
1184
  kafka;
1027
1185
  producer;
1028
1186
  txProducer;
@@ -1047,6 +1205,10 @@ var KafkaClient = class {
1047
1205
  instrumentation;
1048
1206
  onMessageLost;
1049
1207
  onRebalance;
1208
+ /** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
1209
+ txId;
1210
+ /** Per-topic event counters, lazily created on first event. Aggregated by `getMetrics()`. */
1211
+ _topicMetrics = /* @__PURE__ */ new Map();
1050
1212
  /** Monotonically increasing Lamport clock stamped on every outgoing message. */
1051
1213
  _lamportClock = 0;
1052
1214
  /** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
@@ -1070,6 +1232,7 @@ var KafkaClient = class {
1070
1232
  this.instrumentation = options?.instrumentation ?? [];
1071
1233
  this.onMessageLost = options?.onMessageLost;
1072
1234
  this.onRebalance = options?.onRebalance;
1235
+ this.txId = options?.transactionalId ?? `${clientId}-tx`;
1073
1236
  this.kafka = new KafkaClass({
1074
1237
  kafkaJS: {
1075
1238
  clientId: this.clientId,
@@ -1106,16 +1269,22 @@ var KafkaClient = class {
1106
1269
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
1107
1270
  async transaction(fn) {
1108
1271
  if (!this.txProducerInitPromise) {
1272
+ if (_activeTransactionalIds.has(this.txId)) {
1273
+ this.logger.warn(
1274
+ `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.`
1275
+ );
1276
+ }
1109
1277
  const initPromise = (async () => {
1110
1278
  const p = this.kafka.producer({
1111
1279
  kafkaJS: {
1112
1280
  acks: -1,
1113
1281
  idempotent: true,
1114
- transactionalId: `${this.clientId}-tx`,
1282
+ transactionalId: this.txId,
1115
1283
  maxInFlightRequests: 1
1116
1284
  }
1117
1285
  });
1118
1286
  await p.connect();
1287
+ _activeTransactionalIds.add(this.txId);
1119
1288
  return p;
1120
1289
  })();
1121
1290
  this.txProducerInitPromise = initPromise.catch((err) => {
@@ -1183,10 +1352,20 @@ var KafkaClient = class {
1183
1352
  "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
1184
1353
  );
1185
1354
  }
1186
- const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
1355
+ const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
1356
+ const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", setupOptions);
1187
1357
  const deps = this.messageDeps;
1188
1358
  const timeoutMs = options.handlerTimeoutMs;
1189
- const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1359
+ const deduplication = this.resolveDeduplicationContext(
1360
+ gid,
1361
+ options.deduplication
1362
+ );
1363
+ let eosMainContext;
1364
+ if (options.retryTopics && retry) {
1365
+ const mainTxId = `${gid}-main-tx`;
1366
+ const txProducer = await this.createRetryTxProducer(mainTxId);
1367
+ eosMainContext = { txProducer, consumer };
1368
+ }
1190
1369
  await consumer.run({
1191
1370
  eachMessage: (payload) => this.trackInFlight(
1192
1371
  () => handleEachMessage(
@@ -1200,7 +1379,8 @@ var KafkaClient = class {
1200
1379
  retryTopics: options.retryTopics,
1201
1380
  timeoutMs,
1202
1381
  wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1203
- deduplication
1382
+ deduplication,
1383
+ eosMainContext
1204
1384
  },
1205
1385
  deps
1206
1386
  )
@@ -1232,15 +1412,26 @@ var KafkaClient = class {
1232
1412
  "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
1233
1413
  );
1234
1414
  }
1235
- if (options.autoCommit !== false) {
1415
+ if (options.retryTopics) {
1416
+ } else if (options.autoCommit !== false) {
1236
1417
  this.logger.debug?.(
1237
1418
  `startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
1238
1419
  );
1239
1420
  }
1240
- const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
1421
+ const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
1422
+ const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", setupOptions);
1241
1423
  const deps = this.messageDeps;
1242
1424
  const timeoutMs = options.handlerTimeoutMs;
1243
- const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1425
+ const deduplication = this.resolveDeduplicationContext(
1426
+ gid,
1427
+ options.deduplication
1428
+ );
1429
+ let eosMainContext;
1430
+ if (options.retryTopics && retry) {
1431
+ const mainTxId = `${gid}-main-tx`;
1432
+ const txProducer = await this.createRetryTxProducer(mainTxId);
1433
+ eosMainContext = { txProducer, consumer };
1434
+ }
1244
1435
  await consumer.run({
1245
1436
  eachBatch: (payload) => this.trackInFlight(
1246
1437
  () => handleEachBatch(
@@ -1254,7 +1445,8 @@ var KafkaClient = class {
1254
1445
  retryTopics: options.retryTopics,
1255
1446
  timeoutMs,
1256
1447
  wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1257
- deduplication
1448
+ deduplication,
1449
+ eosMainContext
1258
1450
  },
1259
1451
  deps
1260
1452
  )
@@ -1267,7 +1459,7 @@ var KafkaClient = class {
1267
1459
  }
1268
1460
  const handleMessageForRetry = (env) => handleBatch([env], {
1269
1461
  partition: env.partition,
1270
- highWatermark: env.offset,
1462
+ highWatermark: null,
1271
1463
  heartbeat: async () => {
1272
1464
  },
1273
1465
  resolveOffset: () => {
@@ -1311,6 +1503,18 @@ var KafkaClient = class {
1311
1503
  this.consumerCreationOptions.delete(groupId);
1312
1504
  this.dedupStates.delete(groupId);
1313
1505
  this.logger.log(`Consumer disconnected: group "${groupId}"`);
1506
+ const mainTxId = `${groupId}-main-tx`;
1507
+ const mainTxProducer = this.retryTxProducers.get(mainTxId);
1508
+ if (mainTxProducer) {
1509
+ await mainTxProducer.disconnect().catch(
1510
+ (e) => this.logger.warn(
1511
+ `Error disconnecting main tx producer "${mainTxId}":`,
1512
+ toError(e).message
1513
+ )
1514
+ );
1515
+ _activeTransactionalIds.delete(mainTxId);
1516
+ this.retryTxProducers.delete(mainTxId);
1517
+ }
1314
1518
  const companions = this.companionGroupIds.get(groupId) ?? [];
1315
1519
  for (const cGroupId of companions) {
1316
1520
  const cConsumer = this.consumers.get(cGroupId);
@@ -1335,6 +1539,7 @@ var KafkaClient = class {
1335
1539
  toError(e).message
1336
1540
  )
1337
1541
  );
1542
+ _activeTransactionalIds.delete(txId);
1338
1543
  this.retryTxProducers.delete(txId);
1339
1544
  }
1340
1545
  }
@@ -1360,6 +1565,144 @@ var KafkaClient = class {
1360
1565
  this.logger.log("All consumers disconnected");
1361
1566
  }
1362
1567
  }
1568
+ pauseConsumer(groupId, assignments) {
1569
+ const gid = groupId ?? this.defaultGroupId;
1570
+ const consumer = this.consumers.get(gid);
1571
+ if (!consumer) {
1572
+ this.logger.warn(`pauseConsumer: no active consumer for group "${gid}"`);
1573
+ return;
1574
+ }
1575
+ consumer.pause(
1576
+ assignments.flatMap(
1577
+ ({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
1578
+ )
1579
+ );
1580
+ }
1581
+ resumeConsumer(groupId, assignments) {
1582
+ const gid = groupId ?? this.defaultGroupId;
1583
+ const consumer = this.consumers.get(gid);
1584
+ if (!consumer) {
1585
+ this.logger.warn(`resumeConsumer: no active consumer for group "${gid}"`);
1586
+ return;
1587
+ }
1588
+ consumer.resume(
1589
+ assignments.flatMap(
1590
+ ({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
1591
+ )
1592
+ );
1593
+ }
1594
+ /** DLQ header keys added by `sendToDlq` — stripped before re-publishing. */
1595
+ static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
1596
+ "x-dlq-original-topic",
1597
+ "x-dlq-failed-at",
1598
+ "x-dlq-error-message",
1599
+ "x-dlq-error-stack",
1600
+ "x-dlq-attempt-count"
1601
+ ]);
1602
+ async replayDlq(topic2, options = {}) {
1603
+ const dlqTopic = `${topic2}.dlq`;
1604
+ await this.ensureAdminConnected();
1605
+ const partitionOffsets = await this.admin.fetchTopicOffsets(dlqTopic);
1606
+ const activePartitions = partitionOffsets.filter(
1607
+ (p) => parseInt(p.high, 10) > 0
1608
+ );
1609
+ if (activePartitions.length === 0) {
1610
+ this.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
1611
+ return { replayed: 0, skipped: 0 };
1612
+ }
1613
+ const highWatermarks = new Map(
1614
+ activePartitions.map(({ partition, high }) => [
1615
+ partition,
1616
+ parseInt(high, 10)
1617
+ ])
1618
+ );
1619
+ const processedOffsets = /* @__PURE__ */ new Map();
1620
+ let replayed = 0;
1621
+ let skipped = 0;
1622
+ const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
1623
+ await new Promise((resolve, reject) => {
1624
+ const consumer = getOrCreateConsumer(
1625
+ tempGroupId,
1626
+ true,
1627
+ true,
1628
+ this.consumerOpsDeps
1629
+ );
1630
+ const cleanup = () => {
1631
+ consumer.disconnect().catch(() => {
1632
+ }).finally(() => {
1633
+ this.consumers.delete(tempGroupId);
1634
+ this.runningConsumers.delete(tempGroupId);
1635
+ this.consumerCreationOptions.delete(tempGroupId);
1636
+ });
1637
+ };
1638
+ consumer.connect().then(
1639
+ () => subscribeWithRetry(consumer, [dlqTopic], this.logger)
1640
+ ).then(
1641
+ () => consumer.run({
1642
+ eachMessage: async ({ partition, message }) => {
1643
+ if (!message.value) return;
1644
+ const offset = parseInt(message.offset, 10);
1645
+ processedOffsets.set(partition, offset);
1646
+ const headers = decodeHeaders(message.headers);
1647
+ const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
1648
+ const originalHeaders = Object.fromEntries(
1649
+ Object.entries(headers).filter(
1650
+ ([k]) => !_KafkaClient.DLQ_HEADER_KEYS.has(k)
1651
+ )
1652
+ );
1653
+ const value = message.value.toString();
1654
+ const shouldProcess = !options.filter || options.filter(headers, value);
1655
+ if (!targetTopic || !shouldProcess) {
1656
+ skipped++;
1657
+ } else if (options.dryRun) {
1658
+ this.logger.log(
1659
+ `[DLQ replay dry-run] Would replay to "${targetTopic}"`
1660
+ );
1661
+ replayed++;
1662
+ } else {
1663
+ await this.producer.send({
1664
+ topic: targetTopic,
1665
+ messages: [{ value, headers: originalHeaders }]
1666
+ });
1667
+ replayed++;
1668
+ }
1669
+ const allDone = Array.from(highWatermarks.entries()).every(
1670
+ ([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
1671
+ );
1672
+ if (allDone) {
1673
+ cleanup();
1674
+ resolve();
1675
+ }
1676
+ }
1677
+ })
1678
+ ).catch((err) => {
1679
+ cleanup();
1680
+ reject(err);
1681
+ });
1682
+ });
1683
+ this.logger.log(
1684
+ `replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
1685
+ );
1686
+ return { replayed, skipped };
1687
+ }
1688
+ async resetOffsets(groupId, topic2, position) {
1689
+ const gid = groupId ?? this.defaultGroupId;
1690
+ if (this.runningConsumers.has(gid)) {
1691
+ throw new Error(
1692
+ `resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
1693
+ );
1694
+ }
1695
+ await this.ensureAdminConnected();
1696
+ const partitionOffsets = await this.admin.fetchTopicOffsets(topic2);
1697
+ const partitions = partitionOffsets.map(({ partition, low, high }) => ({
1698
+ partition,
1699
+ offset: position === "earliest" ? low : high
1700
+ }));
1701
+ await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
1702
+ this.logger.log(
1703
+ `Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
1704
+ );
1705
+ }
1363
1706
  /**
1364
1707
  * Query consumer group lag per partition.
1365
1708
  * Lag = broker high-watermark − last committed offset.
@@ -1410,15 +1753,45 @@ var KafkaClient = class {
1410
1753
  getClientId() {
1411
1754
  return this.clientId;
1412
1755
  }
1756
+ getMetrics(topic2) {
1757
+ if (topic2 !== void 0) {
1758
+ const m = this._topicMetrics.get(topic2);
1759
+ return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1760
+ }
1761
+ const agg = {
1762
+ processedCount: 0,
1763
+ retryCount: 0,
1764
+ dlqCount: 0,
1765
+ dedupCount: 0
1766
+ };
1767
+ for (const m of this._topicMetrics.values()) {
1768
+ agg.processedCount += m.processedCount;
1769
+ agg.retryCount += m.retryCount;
1770
+ agg.dlqCount += m.dlqCount;
1771
+ agg.dedupCount += m.dedupCount;
1772
+ }
1773
+ return agg;
1774
+ }
1775
+ resetMetrics(topic2) {
1776
+ if (topic2 !== void 0) {
1777
+ this._topicMetrics.delete(topic2);
1778
+ return;
1779
+ }
1780
+ this._topicMetrics.clear();
1781
+ }
1413
1782
  /** Gracefully disconnect producer, all consumers, and admin. */
1414
1783
  async disconnect(drainTimeoutMs = 3e4) {
1415
1784
  await this.waitForDrain(drainTimeoutMs);
1416
1785
  const tasks = [this.producer.disconnect()];
1417
1786
  if (this.txProducer) {
1418
1787
  tasks.push(this.txProducer.disconnect());
1788
+ _activeTransactionalIds.delete(this.txId);
1419
1789
  this.txProducer = void 0;
1420
1790
  this.txProducerInitPromise = void 0;
1421
1791
  }
1792
+ for (const txId of this.retryTxProducers.keys()) {
1793
+ _activeTransactionalIds.delete(txId);
1794
+ }
1422
1795
  for (const p of this.retryTxProducers.values()) {
1423
1796
  tasks.push(p.disconnect());
1424
1797
  }
@@ -1514,6 +1887,38 @@ var KafkaClient = class {
1514
1887
  }
1515
1888
  }
1516
1889
  }
1890
+ metricsFor(topic2) {
1891
+ let m = this._topicMetrics.get(topic2);
1892
+ if (!m) {
1893
+ m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1894
+ this._topicMetrics.set(topic2, m);
1895
+ }
1896
+ return m;
1897
+ }
1898
+ notifyRetry(envelope, attempt, maxRetries) {
1899
+ this.metricsFor(envelope.topic).retryCount++;
1900
+ for (const inst of this.instrumentation) {
1901
+ inst.onRetry?.(envelope, attempt, maxRetries);
1902
+ }
1903
+ }
1904
+ notifyDlq(envelope, reason) {
1905
+ this.metricsFor(envelope.topic).dlqCount++;
1906
+ for (const inst of this.instrumentation) {
1907
+ inst.onDlq?.(envelope, reason);
1908
+ }
1909
+ }
1910
+ notifyDuplicate(envelope, strategy) {
1911
+ this.metricsFor(envelope.topic).dedupCount++;
1912
+ for (const inst of this.instrumentation) {
1913
+ inst.onDuplicate?.(envelope, strategy);
1914
+ }
1915
+ }
1916
+ notifyMessage(envelope) {
1917
+ this.metricsFor(envelope.topic).processedCount++;
1918
+ for (const inst of this.instrumentation) {
1919
+ inst.onMessage?.(envelope);
1920
+ }
1921
+ }
1517
1922
  /**
1518
1923
  * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
1519
1924
  * The handler itself is not cancelled — the warning is diagnostic only.
@@ -1603,6 +2008,11 @@ var KafkaClient = class {
1603
2008
  * so Kafka can fence stale producers on restart without affecting other levels.
1604
2009
  */
1605
2010
  async createRetryTxProducer(transactionalId) {
2011
+ if (_activeTransactionalIds.has(transactionalId)) {
2012
+ this.logger.warn(
2013
+ `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.`
2014
+ );
2015
+ }
1606
2016
  const p = this.kafka.producer({
1607
2017
  kafkaJS: {
1608
2018
  acks: -1,
@@ -1612,6 +2022,7 @@ var KafkaClient = class {
1612
2022
  }
1613
2023
  });
1614
2024
  await p.connect();
2025
+ _activeTransactionalIds.add(transactionalId);
1615
2026
  this.retryTxProducers.set(transactionalId, p);
1616
2027
  return p;
1617
2028
  }
@@ -1732,7 +2143,11 @@ var KafkaClient = class {
1732
2143
  logger: this.logger,
1733
2144
  producer: this.producer,
1734
2145
  instrumentation: this.instrumentation,
1735
- onMessageLost: this.onMessageLost
2146
+ onMessageLost: this.onMessageLost,
2147
+ onRetry: this.notifyRetry.bind(this),
2148
+ onDlq: this.notifyDlq.bind(this),
2149
+ onDuplicate: this.notifyDuplicate.bind(this),
2150
+ onMessage: this.notifyMessage.bind(this)
1736
2151
  };
1737
2152
  }
1738
2153
  get retryTopicDeps() {
@@ -1741,6 +2156,9 @@ var KafkaClient = class {
1741
2156
  producer: this.producer,
1742
2157
  instrumentation: this.instrumentation,
1743
2158
  onMessageLost: this.onMessageLost,
2159
+ onRetry: this.notifyRetry.bind(this),
2160
+ onDlq: this.notifyDlq.bind(this),
2161
+ onMessage: this.notifyMessage.bind(this),
1744
2162
  ensureTopic: (t) => this.ensureTopic(t),
1745
2163
  getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
1746
2164
  runningConsumers: this.runningConsumers,