@drarzter/kafka-client 0.6.3 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ Type-safe Kafka client for Node.js. Framework-agnostic core with a first-class N
30
30
  - [Instrumentation](#instrumentation)
31
31
  - [Options reference](#options-reference)
32
32
  - [Error classes](#error-classes)
33
+ - [Deduplication (Lamport Clock)](#deduplication-lamport-clock)
33
34
  - [Retry topic chain](#retry-topic-chain)
34
35
  - [stopConsumer](#stopconsumer)
35
36
  - [Graceful shutdown](#graceful-shutdown)
@@ -65,6 +66,7 @@ Safe by default. Configurable when you need it. Escape hatches for when you know
65
66
  - **Topic descriptors** — `topic()` DX sugar lets you define topics as standalone typed objects instead of string keys
66
67
  - **Framework-agnostic** — use standalone or with NestJS (`register()` / `registerAsync()`, DI, lifecycle hooks)
67
68
  - **Idempotent producer** — `acks: -1`, `idempotent: true` by default
69
+ - **Lamport Clock deduplication** — every outgoing message is stamped with a monotonically increasing `x-lamport-clock` header; the consumer tracks the last processed value per `topic:partition` and silently drops (or routes to DLQ / a dedicated topic) any message whose clock is not strictly greater than the last seen value
68
70
  - **Retry + DLQ** — exponential backoff with full jitter; dead letter queue with error metadata headers (original topic, error message, stack, attempt count)
69
71
  - **Batch sending** — send multiple messages in a single request
70
72
  - **Batch consuming** — `startBatchConsumer()` for high-throughput `eachBatch` processing
@@ -441,11 +443,14 @@ await kafka.startConsumer(['orders'], auditHandler, { groupId: 'orders-audit' })
441
443
  async auditOrders(envelope) { ... }
442
444
  ```
443
445
 
444
- **Important:** You cannot mix `eachMessage` and `eachBatch` consumers on the same `groupId`. The library throws a clear error if you try:
446
+ **Important:** You cannot mix `eachMessage` and `eachBatch` consumers on the same `groupId`, and you cannot call `startConsumer` (or `startBatchConsumer`) twice on the same `groupId` without stopping it first. The library throws a clear error in both cases:
445
447
 
446
448
  ```text
447
449
  Cannot use eachBatch on consumer group "my-group" — it is already running with eachMessage.
448
450
  Use a different groupId for this consumer.
451
+
452
+ startConsumer("my-group") called twice — this group is already consuming.
453
+ Call stopConsumer("my-group") first or pass a different groupId.
449
454
  ```
450
455
 
451
456
  ### Named clients
@@ -583,7 +588,7 @@ await this.kafka.startBatchConsumer(
583
588
  );
584
589
  ```
585
590
 
586
- > **Note:** If your handler calls `resolveOffset()` or `commitOffsetsIfNecessary()` without setting `autoCommit: false`, a `warn` is logged at consumer-start time — mixing autoCommit with manual offset control causes offset conflicts. Set `autoCommit: false` to suppress the warning and take full control of offset management.
591
+ > **Note:** If your handler calls `resolveOffset()` or `commitOffsetsIfNecessary()` without setting `autoCommit: false`, a `debug` message is logged at consumer-start time — mixing autoCommit with manual offset control causes offset conflicts. Set `autoCommit: false` to suppress the message and take full control of offset management.
587
592
 
588
593
  With `@SubscribeTo()`:
589
594
 
@@ -769,6 +774,8 @@ Options for `sendMessage()` — the third argument:
769
774
  | `interceptors` | `[]` | Array of before/after/onError hooks |
770
775
  | `retryTopicAssignmentTimeoutMs` | `10000` | Timeout (ms) to wait for each retry level consumer to receive partition assignments after connecting; increase for slow brokers |
771
776
  | `handlerTimeoutMs` | — | Log a warning if the handler hasn't resolved within this window (ms) — does not cancel the handler |
777
+ | `deduplication.strategy` | `'drop'` | What to do with duplicate messages: `'drop'` silently discards, `'dlq'` forwards to `{topic}.dlq` (requires `dlq: true`), `'topic'` forwards to `{topic}.duplicates` |
778
+ | `deduplication.duplicatesTopic` | `{topic}.duplicates` | Custom destination for `strategy: 'topic'` |
772
779
  | `batch` | `false` | (decorator only) Use `startBatchConsumer` instead of `startConsumer` |
773
780
  | `subscribeRetry.retries` | `5` | Max attempts for `consumer.subscribe()` when topic doesn't exist yet |
774
781
  | `subscribeRetry.backoffMs` | `5000` | Delay between subscribe retry attempts (ms) |
@@ -877,6 +884,81 @@ const interceptor: ConsumerInterceptor<MyTopics> = {
877
884
  };
878
885
  ```
879
886
 
887
+ ## Deduplication (Lamport Clock)
888
+
889
+ Every outgoing message produced by this library is stamped with a monotonically increasing logical clock — the `x-lamport-clock` header. The counter lives in the `KafkaClient` instance and increments by one per message (including individual messages inside `sendBatch` and `transaction`).
890
+
891
+ On the consumer side, enable deduplication by passing `deduplication` to `startConsumer` or `startBatchConsumer`. The library checks the incoming clock against the last processed value for that `topic:partition` combination and skips any message whose clock is not strictly greater.
892
+
893
+ ```typescript
894
+ await kafka.startConsumer(['orders.created'], handler, {
895
+ deduplication: {}, // 'drop' strategy — silently discard duplicates
896
+ });
897
+ ```
898
+
899
+ ### How duplicates happen
900
+
901
+ The most common scenario: a producer service restarts. Its in-memory clock resets to `0`. The consumer already processed messages with clocks `1…N`. All new messages from the restarted producer (clocks `1`, `2`, `3`, …) have clocks ≤ `N` and are treated as duplicates.
902
+
903
+ ```text
904
+ Producer A (running): sends clock 1, 2, 3, 4, 5 → consumer processes all 5
905
+ Producer A (restarts): sends clock 1, 2, 3 → consumer sees 1 ≤ 5 — duplicate!
906
+ ```
907
+
908
+ ### Strategies
909
+
910
+ | Strategy | Behaviour |
911
+ | -------- | --------- |
912
+ | `'drop'` *(default)* | Log a warning and silently discard the message |
913
+ | `'dlq'` | Forward to `{topic}.dlq` with reason metadata headers (`x-dlq-reason`, `x-dlq-duplicate-incoming-clock`, `x-dlq-duplicate-last-processed-clock`). Requires `dlq: true` |
914
+ | `'topic'` | Forward to `{topic}.duplicates` (or `duplicatesTopic` if set) with reason metadata headers (`x-duplicate-reason`, `x-duplicate-incoming-clock`, `x-duplicate-last-processed-clock`, `x-duplicate-detected-at`) |
915
+
916
+ ```typescript
917
+ // Strategy: drop (default)
918
+ await kafka.startConsumer(['orders'], handler, {
919
+ deduplication: {},
920
+ });
921
+
922
+ // Strategy: DLQ — inspect duplicates from {topic}.dlq
923
+ await kafka.startConsumer(['orders'], handler, {
924
+ dlq: true,
925
+ deduplication: { strategy: 'dlq' },
926
+ });
927
+
928
+ // Strategy: dedicated topic — consume from {topic}.duplicates
929
+ await kafka.startConsumer(['orders'], handler, {
930
+ deduplication: { strategy: 'topic' },
931
+ });
932
+
933
+ // Strategy: custom topic name
934
+ await kafka.startConsumer(['orders'], handler, {
935
+ deduplication: {
936
+ strategy: 'topic',
937
+ duplicatesTopic: 'ops.orders.duplicates',
938
+ },
939
+ });
940
+ ```
941
+
942
+ ### Startup validation
943
+
944
+ When `autoCreateTopics: false` and `strategy: 'topic'`, `startConsumer` / `startBatchConsumer` validates that the destination topic (`{topic}.duplicates` or `duplicatesTopic`) exists before starting the consumer. A clear error is thrown at startup listing every missing topic, rather than silently failing on the first duplicate.
945
+
946
+ With `autoCreateTopics: true` the check is skipped — the topic is created automatically instead.
947
+
948
+ ### Backwards compatibility
949
+
950
+ Messages without an `x-lamport-clock` header pass through unchanged. Producers not using this library are unaffected.
951
+
952
+ ### Limitations
953
+
954
+ Deduplication state is **in-memory and per-consumer-instance**. Understand what that means:
955
+
956
+ - **Consumer restart** — state is cleared on restart. The first batch of messages after restart is accepted regardless of their clock values, so duplicates spanning a restart window are not caught.
957
+ - **Multiple consumer instances** (same group, different machines) — each instance tracks its own partition subset. Partitions are reassigned on rebalance, so a rebalance can reset the state for moved partitions.
958
+ - **Cross-session duplicates** — this guards against duplicates from a **producer that restarted within the same consumer session**. For durable, cross-restart deduplication, persist the clock state externally (Redis, database) and implement idempotent handlers.
959
+
960
+ Use this feature as a lightweight first line of defence — not as a substitute for idempotent business logic.
961
+
880
962
  ## Retry topic chain
881
963
 
882
964
  > **tl;dr — recommended production setup:**
@@ -9,6 +9,7 @@ var HEADER_CORRELATION_ID = "x-correlation-id";
9
9
  var HEADER_TIMESTAMP = "x-timestamp";
10
10
  var HEADER_SCHEMA_VERSION = "x-schema-version";
11
11
  var HEADER_TRACEPARENT = "traceparent";
12
+ var HEADER_LAMPORT_CLOCK = "x-lamport-clock";
12
13
  var envelopeStorage = new AsyncLocalStorage();
13
14
  function getEnvelopeContext() {
14
15
  return envelopeStorage.getStore();
@@ -149,6 +150,9 @@ async function buildSendPayload(topicOrDesc, messages, deps) {
149
150
  eventId: m.eventId,
150
151
  headers: m.headers
151
152
  });
153
+ if (deps.nextLamportClock) {
154
+ envelopeHeaders[HEADER_LAMPORT_CLOCK] = String(deps.nextLamportClock());
155
+ }
152
156
  for (const inst of deps.instrumentation) {
153
157
  inst.beforeSend?.(topic2, envelopeHeaders);
154
158
  }
@@ -286,6 +290,9 @@ async function validateWithSchema(message, raw, topic2, schemaMap, interceptors,
286
290
  -1,
287
291
  ""
288
292
  );
293
+ for (const inst of deps.instrumentation ?? []) {
294
+ inst.onConsumeError?.(errorEnvelope, validationError);
295
+ }
289
296
  for (const interceptor of interceptors) {
290
297
  await interceptor.onError?.(errorEnvelope, validationError);
291
298
  }
@@ -380,6 +387,29 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
380
387
  });
381
388
  }
382
389
  }
390
+ function buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, meta) {
391
+ const headers = {
392
+ ...meta?.originalHeaders ?? {},
393
+ "x-duplicate-original-topic": sourceTopic,
394
+ "x-duplicate-detected-at": (/* @__PURE__ */ new Date()).toISOString(),
395
+ "x-duplicate-reason": "lamport-clock-duplicate",
396
+ "x-duplicate-incoming-clock": String(meta?.incomingClock ?? 0),
397
+ "x-duplicate-last-processed-clock": String(meta?.lastProcessedClock ?? 0)
398
+ };
399
+ return { topic: destinationTopic, messages: [{ value: rawMessage, headers }] };
400
+ }
401
+ async function sendToDuplicatesTopic(sourceTopic, rawMessage, destinationTopic, deps, meta) {
402
+ const payload = buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, meta);
403
+ try {
404
+ await deps.producer.send(payload);
405
+ deps.logger.warn(`Duplicate message forwarded to ${destinationTopic}`);
406
+ } catch (error) {
407
+ deps.logger.error(
408
+ `Failed to forward duplicate to ${destinationTopic}:`,
409
+ toError(error).stack
410
+ );
411
+ }
412
+ }
383
413
  async function broadcastToInterceptors(envelopes, interceptors, cb) {
384
414
  for (const env of envelopes) {
385
415
  for (const interceptor of interceptors) {
@@ -488,13 +518,12 @@ async function executeWithRetry(fn, ctx, deps) {
488
518
  );
489
519
  } else if (isLastAttempt) {
490
520
  if (dlq) {
491
- const dlqMeta = {
492
- error,
493
- attempt,
494
- originalHeaders: envelopes[0]?.headers
495
- };
496
- for (const raw of rawMessages) {
497
- await sendToDlq(topic2, raw, deps, dlqMeta);
521
+ for (let i = 0; i < rawMessages.length; i++) {
522
+ await sendToDlq(topic2, rawMessages[i], deps, {
523
+ error,
524
+ attempt,
525
+ originalHeaders: envelopes[i]?.headers
526
+ });
498
527
  }
499
528
  } else {
500
529
  await deps.onMessageLost?.({
@@ -506,12 +535,50 @@ async function executeWithRetry(fn, ctx, deps) {
506
535
  }
507
536
  } else {
508
537
  const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
509
- await sleep(Math.random() * cap);
538
+ await sleep(Math.floor(Math.random() * cap));
510
539
  }
511
540
  }
512
541
  }
513
542
 
514
543
  // src/client/kafka.client/message-handler.ts
544
+ async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
545
+ const clockRaw = envelope.headers[HEADER_LAMPORT_CLOCK];
546
+ if (clockRaw === void 0) return false;
547
+ const incomingClock = Number(clockRaw);
548
+ if (Number.isNaN(incomingClock)) return false;
549
+ const stateKey = `${envelope.topic}:${envelope.partition}`;
550
+ const lastProcessedClock = dedup.state.get(stateKey) ?? -1;
551
+ if (incomingClock <= lastProcessedClock) {
552
+ const meta = {
553
+ incomingClock,
554
+ lastProcessedClock,
555
+ originalHeaders: envelope.headers
556
+ };
557
+ const strategy = dedup.options.strategy ?? "drop";
558
+ deps.logger.warn(
559
+ `Duplicate message on ${envelope.topic}[${envelope.partition}]: clock=${incomingClock} <= last=${lastProcessedClock} \u2014 strategy=${strategy}`
560
+ );
561
+ if (strategy === "dlq" && dlq) {
562
+ const augmentedHeaders = {
563
+ ...envelope.headers,
564
+ "x-dlq-reason": "lamport-clock-duplicate",
565
+ "x-dlq-duplicate-incoming-clock": String(incomingClock),
566
+ "x-dlq-duplicate-last-processed-clock": String(lastProcessedClock)
567
+ };
568
+ await sendToDlq(envelope.topic, raw, deps, {
569
+ error: new Error("Lamport Clock duplicate detected"),
570
+ attempt: 0,
571
+ originalHeaders: augmentedHeaders
572
+ });
573
+ } else if (strategy === "topic") {
574
+ const destination = dedup.options.duplicatesTopic ?? `${envelope.topic}.duplicates`;
575
+ await sendToDuplicatesTopic(envelope.topic, raw, destination, deps, meta);
576
+ }
577
+ return true;
578
+ }
579
+ dedup.state.set(stateKey, incomingClock);
580
+ return false;
581
+ }
515
582
  async function parseSingleMessage(message, topic2, partition, schemaMap, interceptors, dlq, deps) {
516
583
  if (!message.value) {
517
584
  deps.logger.warn(`Received empty message from topic ${topic2}`);
@@ -555,6 +622,16 @@ async function handleEachMessage(payload, opts, deps) {
555
622
  deps
556
623
  );
557
624
  if (envelope === null) return;
625
+ if (opts.deduplication) {
626
+ const isDuplicate = await applyDeduplication(
627
+ envelope,
628
+ message.value.toString(),
629
+ opts.deduplication,
630
+ dlq,
631
+ deps
632
+ );
633
+ if (isDuplicate) return;
634
+ }
558
635
  await executeWithRetry(
559
636
  () => {
560
637
  const fn = () => runWithEnvelopeContext(
@@ -602,6 +679,17 @@ async function handleEachBatch(payload, opts, deps) {
602
679
  deps
603
680
  );
604
681
  if (envelope === null) continue;
682
+ if (opts.deduplication) {
683
+ const raw = message.value.toString();
684
+ const isDuplicate = await applyDeduplication(
685
+ envelope,
686
+ raw,
687
+ opts.deduplication,
688
+ dlq,
689
+ deps
690
+ );
691
+ if (isDuplicate) continue;
692
+ }
605
693
  envelopes.push(envelope);
606
694
  rawMessages.push(message.value.toString());
607
695
  }
@@ -880,7 +968,9 @@ var KafkaClient = class {
880
968
  kafka;
881
969
  producer;
882
970
  txProducer;
883
- retryTxProducers = /* @__PURE__ */ new Set();
971
+ txProducerInitPromise;
972
+ /** Maps transactionalId → Producer for each active retry level consumer. */
973
+ retryTxProducers = /* @__PURE__ */ new Map();
884
974
  consumers = /* @__PURE__ */ new Map();
885
975
  admin;
886
976
  logger;
@@ -888,6 +978,8 @@ var KafkaClient = class {
888
978
  strictSchemasEnabled;
889
979
  numPartitions;
890
980
  ensuredTopics = /* @__PURE__ */ new Set();
981
+ /** Pending topic-creation promises keyed by topic name. Prevents duplicate createTopics calls. */
982
+ ensureTopicPromises = /* @__PURE__ */ new Map();
891
983
  defaultGroupId;
892
984
  schemaRegistry = /* @__PURE__ */ new Map();
893
985
  runningConsumers = /* @__PURE__ */ new Map();
@@ -897,6 +989,10 @@ var KafkaClient = class {
897
989
  instrumentation;
898
990
  onMessageLost;
899
991
  onRebalance;
992
+ /** Monotonically increasing Lamport clock stamped on every outgoing message. */
993
+ _lamportClock = 0;
994
+ /** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
995
+ dedupStates = /* @__PURE__ */ new Map();
900
996
  isAdminConnected = false;
901
997
  inFlightTotal = 0;
902
998
  drainResolvers = [];
@@ -951,18 +1047,25 @@ var KafkaClient = class {
951
1047
  }
952
1048
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
953
1049
  async transaction(fn) {
954
- if (!this.txProducer) {
955
- const p = this.kafka.producer({
956
- kafkaJS: {
957
- acks: -1,
958
- idempotent: true,
959
- transactionalId: `${this.clientId}-tx`,
960
- maxInFlightRequests: 1
961
- }
1050
+ if (!this.txProducerInitPromise) {
1051
+ const initPromise = (async () => {
1052
+ const p = this.kafka.producer({
1053
+ kafkaJS: {
1054
+ acks: -1,
1055
+ idempotent: true,
1056
+ transactionalId: `${this.clientId}-tx`,
1057
+ maxInFlightRequests: 1
1058
+ }
1059
+ });
1060
+ await p.connect();
1061
+ return p;
1062
+ })();
1063
+ this.txProducerInitPromise = initPromise.catch((err) => {
1064
+ this.txProducerInitPromise = void 0;
1065
+ throw err;
962
1066
  });
963
- await p.connect();
964
- this.txProducer = p;
965
1067
  }
1068
+ this.txProducer = await this.txProducerInitPromise;
966
1069
  const tx = await this.txProducer.transaction();
967
1070
  try {
968
1071
  const ctx = {
@@ -1001,11 +1104,17 @@ var KafkaClient = class {
1001
1104
  }
1002
1105
  }
1003
1106
  // ── Producer lifecycle ───────────────────────────────────────────
1004
- /** Connect the idempotent producer. Called automatically by `KafkaModule.register()`. */
1107
+ /**
1108
+ * Connect the idempotent producer. Called automatically by `KafkaModule.register()`.
1109
+ * @internal Not part of `IKafkaClient` — use `disconnect()` for full teardown.
1110
+ */
1005
1111
  async connectProducer() {
1006
1112
  await this.producer.connect();
1007
1113
  this.logger.log("Producer connected");
1008
1114
  }
1115
+ /**
1116
+ * @internal Not part of `IKafkaClient` — use `disconnect()` for full teardown.
1117
+ */
1009
1118
  async disconnectProducer() {
1010
1119
  await this.producer.disconnect();
1011
1120
  this.logger.log("Producer disconnected");
@@ -1019,6 +1128,7 @@ var KafkaClient = class {
1019
1128
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
1020
1129
  const deps = this.messageDeps;
1021
1130
  const timeoutMs = options.handlerTimeoutMs;
1131
+ const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1022
1132
  await consumer.run({
1023
1133
  eachMessage: (payload) => this.trackInFlight(
1024
1134
  () => handleEachMessage(
@@ -1031,7 +1141,8 @@ var KafkaClient = class {
1031
1141
  retry,
1032
1142
  retryTopics: options.retryTopics,
1033
1143
  timeoutMs,
1034
- wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
1144
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1145
+ deduplication
1035
1146
  },
1036
1147
  deps
1037
1148
  )
@@ -1071,6 +1182,7 @@ var KafkaClient = class {
1071
1182
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
1072
1183
  const deps = this.messageDeps;
1073
1184
  const timeoutMs = options.handlerTimeoutMs;
1185
+ const deduplication = this.resolveDeduplicationContext(gid, options.deduplication);
1074
1186
  await consumer.run({
1075
1187
  eachBatch: (payload) => this.trackInFlight(
1076
1188
  () => handleEachBatch(
@@ -1083,7 +1195,8 @@ var KafkaClient = class {
1083
1195
  retry,
1084
1196
  retryTopics: options.retryTopics,
1085
1197
  timeoutMs,
1086
- wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
1198
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1199
+ deduplication
1087
1200
  },
1088
1201
  deps
1089
1202
  )
@@ -1129,35 +1242,63 @@ var KafkaClient = class {
1129
1242
  );
1130
1243
  return;
1131
1244
  }
1132
- await consumer.disconnect().catch(() => {
1133
- });
1245
+ await consumer.disconnect().catch(
1246
+ (e) => this.logger.warn(
1247
+ `Error disconnecting consumer "${groupId}":`,
1248
+ toError(e).message
1249
+ )
1250
+ );
1134
1251
  this.consumers.delete(groupId);
1135
1252
  this.runningConsumers.delete(groupId);
1136
1253
  this.consumerCreationOptions.delete(groupId);
1254
+ this.dedupStates.delete(groupId);
1137
1255
  this.logger.log(`Consumer disconnected: group "${groupId}"`);
1138
1256
  const companions = this.companionGroupIds.get(groupId) ?? [];
1139
1257
  for (const cGroupId of companions) {
1140
1258
  const cConsumer = this.consumers.get(cGroupId);
1141
1259
  if (cConsumer) {
1142
- await cConsumer.disconnect().catch(() => {
1143
- });
1260
+ await cConsumer.disconnect().catch(
1261
+ (e) => this.logger.warn(
1262
+ `Error disconnecting retry consumer "${cGroupId}":`,
1263
+ toError(e).message
1264
+ )
1265
+ );
1144
1266
  this.consumers.delete(cGroupId);
1145
1267
  this.runningConsumers.delete(cGroupId);
1146
1268
  this.consumerCreationOptions.delete(cGroupId);
1147
1269
  this.logger.log(`Retry consumer disconnected: group "${cGroupId}"`);
1148
1270
  }
1271
+ const txId = `${cGroupId}-tx`;
1272
+ const txProducer = this.retryTxProducers.get(txId);
1273
+ if (txProducer) {
1274
+ await txProducer.disconnect().catch(
1275
+ (e) => this.logger.warn(
1276
+ `Error disconnecting retry tx producer "${txId}":`,
1277
+ toError(e).message
1278
+ )
1279
+ );
1280
+ this.retryTxProducers.delete(txId);
1281
+ }
1149
1282
  }
1150
1283
  this.companionGroupIds.delete(groupId);
1151
1284
  } else {
1152
- const tasks = Array.from(this.consumers.values()).map(
1153
- (c) => c.disconnect().catch(() => {
1154
- })
1155
- );
1285
+ const tasks = [
1286
+ ...Array.from(this.consumers.values()).map(
1287
+ (c) => c.disconnect().catch(() => {
1288
+ })
1289
+ ),
1290
+ ...Array.from(this.retryTxProducers.values()).map(
1291
+ (p) => p.disconnect().catch(() => {
1292
+ })
1293
+ )
1294
+ ];
1156
1295
  await Promise.allSettled(tasks);
1157
1296
  this.consumers.clear();
1158
1297
  this.runningConsumers.clear();
1159
1298
  this.consumerCreationOptions.clear();
1160
1299
  this.companionGroupIds.clear();
1300
+ this.retryTxProducers.clear();
1301
+ this.dedupStates.clear();
1161
1302
  this.logger.log("All consumers disconnected");
1162
1303
  }
1163
1304
  }
@@ -1165,6 +1306,12 @@ var KafkaClient = class {
1165
1306
  * Query consumer group lag per partition.
1166
1307
  * Lag = broker high-watermark − last committed offset.
1167
1308
  * A committed offset of -1 (nothing committed yet) counts as full lag.
1309
+ *
1310
+ * Returns an empty array when the consumer group has never committed any
1311
+ * offsets (freshly created group, `autoCommit: false` with no manual commits,
1312
+ * or group not yet assigned). This is a Kafka protocol limitation:
1313
+ * `fetchOffsets` only returns data for topic-partitions that have at least one
1314
+ * committed offset. Use `checkStatus()` to verify broker connectivity in that case.
1168
1315
  */
1169
1316
  async getConsumerLag(groupId) {
1170
1317
  const gid = groupId ?? this.defaultGroupId;
@@ -1212,8 +1359,9 @@ var KafkaClient = class {
1212
1359
  if (this.txProducer) {
1213
1360
  tasks.push(this.txProducer.disconnect());
1214
1361
  this.txProducer = void 0;
1362
+ this.txProducerInitPromise = void 0;
1215
1363
  }
1216
- for (const p of this.retryTxProducers) {
1364
+ for (const p of this.retryTxProducers.values()) {
1217
1365
  tasks.push(p.disconnect());
1218
1366
  }
1219
1367
  this.retryTxProducers.clear();
@@ -1232,6 +1380,14 @@ var KafkaClient = class {
1232
1380
  this.logger.log("All connections closed");
1233
1381
  }
1234
1382
  // ── Graceful shutdown ────────────────────────────────────────────
1383
+ /**
1384
+ * NestJS lifecycle hook — called automatically when the host module is torn down.
1385
+ * Drains in-flight handlers and disconnects all producers, consumers, and admin.
1386
+ * `KafkaModule` relies on this method; no separate destroy provider is needed.
1387
+ */
1388
+ async onModuleDestroy() {
1389
+ await this.disconnect();
1390
+ }
1235
1391
  /**
1236
1392
  * Register SIGTERM / SIGINT handlers that drain in-flight messages before
1237
1393
  * disconnecting. Call this once after constructing the client in non-NestJS apps.
@@ -1352,6 +1508,22 @@ var KafkaClient = class {
1352
1508
  );
1353
1509
  }
1354
1510
  }
1511
+ /**
1512
+ * When `deduplication.strategy: 'topic'` and `autoCreateTopics: false`, verify
1513
+ * that every `<topic>.duplicates` destination topic already exists. Throws a
1514
+ * clear error at startup rather than silently dropping duplicates on first hit.
1515
+ */
1516
+ async validateDuplicatesTopicsExist(topicNames, customDestination) {
1517
+ await this.ensureAdminConnected();
1518
+ const existing = new Set(await this.admin.listTopics());
1519
+ const toCheck = customDestination ? [customDestination] : topicNames.map((t) => `${t}.duplicates`);
1520
+ const missing = toCheck.filter((t) => !existing.has(t));
1521
+ if (missing.length > 0) {
1522
+ throw new Error(
1523
+ `deduplication.strategy: 'topic' but the following duplicate-routing topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
1524
+ );
1525
+ }
1526
+ }
1355
1527
  /**
1356
1528
  * Connect the admin client if not already connected.
1357
1529
  * The flag is only set to `true` after a successful connect — if `admin.connect()`
@@ -1382,16 +1554,23 @@ var KafkaClient = class {
1382
1554
  }
1383
1555
  });
1384
1556
  await p.connect();
1385
- this.retryTxProducers.add(p);
1557
+ this.retryTxProducers.set(transactionalId, p);
1386
1558
  return p;
1387
1559
  }
1388
1560
  async ensureTopic(topic2) {
1389
1561
  if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
1390
- await this.ensureAdminConnected();
1391
- await this.admin.createTopics({
1392
- topics: [{ topic: topic2, numPartitions: this.numPartitions }]
1393
- });
1394
- this.ensuredTopics.add(topic2);
1562
+ let p = this.ensureTopicPromises.get(topic2);
1563
+ if (!p) {
1564
+ p = (async () => {
1565
+ await this.ensureAdminConnected();
1566
+ await this.admin.createTopics({
1567
+ topics: [{ topic: topic2, numPartitions: this.numPartitions }]
1568
+ });
1569
+ this.ensuredTopics.add(topic2);
1570
+ })().finally(() => this.ensureTopicPromises.delete(topic2));
1571
+ this.ensureTopicPromises.set(topic2, p);
1572
+ }
1573
+ await p;
1395
1574
  }
1396
1575
  /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
1397
1576
  async setupConsumer(topics, mode, options) {
@@ -1411,6 +1590,12 @@ var KafkaClient = class {
1411
1590
  `Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
1412
1591
  );
1413
1592
  }
1593
+ if (existingMode === mode) {
1594
+ const callerName = mode === "eachMessage" ? "startConsumer" : "startBatchConsumer";
1595
+ throw new Error(
1596
+ `${callerName}("${gid}") called twice \u2014 this group is already consuming. Call stopConsumer("${gid}") first or pass a different groupId.`
1597
+ );
1598
+ }
1414
1599
  const consumer = getOrCreateConsumer(
1415
1600
  gid,
1416
1601
  fromBeginning,
@@ -1435,6 +1620,16 @@ var KafkaClient = class {
1435
1620
  await this.validateDlqTopicsExist(topicNames);
1436
1621
  }
1437
1622
  }
1623
+ if (options.deduplication?.strategy === "topic") {
1624
+ const dest = options.deduplication.duplicatesTopic;
1625
+ if (this.autoCreateTopicsEnabled) {
1626
+ for (const t of topicNames) {
1627
+ await this.ensureTopic(dest ?? `${t}.duplicates`);
1628
+ }
1629
+ } else {
1630
+ await this.validateDuplicatesTopicsExist(topicNames, dest);
1631
+ }
1632
+ }
1438
1633
  await consumer.connect();
1439
1634
  await subscribeWithRetry(
1440
1635
  consumer,
@@ -1447,13 +1642,22 @@ var KafkaClient = class {
1447
1642
  );
1448
1643
  return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
1449
1644
  }
1645
+ /** Create or retrieve the deduplication context for a consumer group. */
1646
+ resolveDeduplicationContext(groupId, options) {
1647
+ if (!options) return void 0;
1648
+ if (!this.dedupStates.has(groupId)) {
1649
+ this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
1650
+ }
1651
+ return { options, state: this.dedupStates.get(groupId) };
1652
+ }
1450
1653
  // ── Deps object getters ──────────────────────────────────────────
1451
1654
  get producerOpsDeps() {
1452
1655
  return {
1453
1656
  schemaRegistry: this.schemaRegistry,
1454
1657
  strictSchemasEnabled: this.strictSchemasEnabled,
1455
1658
  instrumentation: this.instrumentation,
1456
- logger: this.logger
1659
+ logger: this.logger,
1660
+ nextLamportClock: () => ++this._lamportClock
1457
1661
  };
1458
1662
  }
1459
1663
  get consumerOpsDeps() {
@@ -1509,6 +1713,7 @@ export {
1509
1713
  HEADER_TIMESTAMP,
1510
1714
  HEADER_SCHEMA_VERSION,
1511
1715
  HEADER_TRACEPARENT,
1716
+ HEADER_LAMPORT_CLOCK,
1512
1717
  getEnvelopeContext,
1513
1718
  runWithEnvelopeContext,
1514
1719
  buildEnvelopeHeaders,
@@ -1520,4 +1725,4 @@ export {
1520
1725
  KafkaClient,
1521
1726
  topic
1522
1727
  };
1523
- //# sourceMappingURL=chunk-RGRKN4E5.mjs.map
1728
+ //# sourceMappingURL=chunk-KCUKXR6B.mjs.map