@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 +84 -2
- package/dist/{chunk-RGRKN4E5.mjs → chunk-KCUKXR6B.mjs} +244 -39
- package/dist/chunk-KCUKXR6B.mjs.map +1 -0
- package/dist/core.d.mts +37 -3
- package/dist/core.d.ts +37 -3
- package/dist/core.js +244 -38
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +3 -1
- package/dist/index.d.mts +2 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +246 -57
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5 -20
- package/dist/index.mjs.map +1 -1
- package/dist/otel.d.mts +1 -1
- package/dist/otel.d.ts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{types-zFbQH_Cy.d.mts → types-CTwLrJVU.d.mts} +44 -1
- package/dist/{types-zFbQH_Cy.d.ts → types-CTwLrJVU.d.ts} +44 -1
- package/package.json +1 -1
- package/dist/chunk-RGRKN4E5.mjs.map +0 -1
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
|
|
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 `
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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.
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
1153
|
-
(
|
|
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.
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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-
|
|
1728
|
+
//# sourceMappingURL=chunk-KCUKXR6B.mjs.map
|