@drarzter/kafka-client 0.6.4 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -618,7 +620,7 @@ await kafka.startBatchConsumer(
618
620
  | Property/Method | Description |
619
621
  | --------------- | ----------- |
620
622
  | `partition` | Partition number for this batch |
621
- | `highWatermark` | Latest offset in the partition (lag indicator) |
623
+ | `highWatermark` | Latest offset in the partition (`string`). `null` when the message is replayed via a retry topic consumer — in that path the broker high-watermark is not available. Guard against `null` before computing lag |
622
624
  | `heartbeat()` | Send a heartbeat to keep the consumer session alive — call during long processing loops |
623
625
  | `resolveOffset(offset)` | Mark offset as processed (required before `commitOffsetsIfNecessary`) |
624
626
  | `commitOffsetsIfNecessary()` | Commit resolved offsets; respects `autoCommit` setting |
@@ -741,6 +743,47 @@ type BeforeConsumeResult =
741
743
 
742
744
  When multiple instrumentations each provide a `wrap`, they compose in declaration order — the first instrumentation's `wrap` is the outermost.
743
745
 
746
+ ### Lifecycle event hooks
747
+
748
+ Three additional hooks fire for specific events in the consume pipeline:
749
+
750
+ | Hook | When called | Arguments |
751
+ | ---- | ----------- | --------- |
752
+ | `onMessage` | Handler successfully processed a message | `(envelope)` — use as a success counter for error-rate calculations |
753
+ | `onRetry` | A message is queued for another attempt (in-process backoff or routed to a retry topic) | `(envelope, attempt, maxRetries)` |
754
+ | `onDlq` | A message is routed to the dead letter queue | `(envelope, reason)` — reason is `'handler-error'`, `'validation-error'`, or `'lamport-clock-duplicate'` |
755
+ | `onDuplicate` | A duplicate is detected via Lamport Clock | `(envelope, strategy)` — strategy is `'drop'`, `'dlq'`, or `'topic'` |
756
+
757
+ ```typescript
758
+ const myInstrumentation: KafkaInstrumentation = {
759
+ onMessage(envelope) {
760
+ metrics.increment('kafka.processed', { topic: envelope.topic });
761
+ },
762
+ onRetry(envelope, attempt, maxRetries) {
763
+ console.warn(`Retrying ${envelope.topic} — attempt ${attempt}/${maxRetries}`);
764
+ },
765
+ onDlq(envelope, reason) {
766
+ alertingSystem.send({ topic: envelope.topic, reason });
767
+ },
768
+ onDuplicate(envelope, strategy) {
769
+ metrics.increment('kafka.duplicate', { topic: envelope.topic, strategy });
770
+ },
771
+ };
772
+ ```
773
+
774
+ ### Built-in metrics
775
+
776
+ `KafkaClient` maintains lightweight in-process event counters independently of any instrumentation:
777
+
778
+ ```typescript
779
+ const snapshot = kafka.getMetrics();
780
+ // { processedCount: number; retryCount: number; dlqCount: number; dedupCount: number }
781
+
782
+ kafka.resetMetrics(); // reset all counters to zero
783
+ ```
784
+
785
+ Counters are incremented in the same code paths that fire the corresponding hooks — they are always active regardless of whether any instrumentation is configured.
786
+
744
787
  ## Options reference
745
788
 
746
789
  ### Send options
@@ -772,6 +815,8 @@ Options for `sendMessage()` — the third argument:
772
815
  | `interceptors` | `[]` | Array of before/after/onError hooks |
773
816
  | `retryTopicAssignmentTimeoutMs` | `10000` | Timeout (ms) to wait for each retry level consumer to receive partition assignments after connecting; increase for slow brokers |
774
817
  | `handlerTimeoutMs` | — | Log a warning if the handler hasn't resolved within this window (ms) — does not cancel the handler |
818
+ | `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` |
819
+ | `deduplication.duplicatesTopic` | `{topic}.duplicates` | Custom destination for `strategy: 'topic'` |
775
820
  | `batch` | `false` | (decorator only) Use `startBatchConsumer` instead of `startConsumer` |
776
821
  | `subscribeRetry.retries` | `5` | Max attempts for `consumer.subscribe()` when topic doesn't exist yet |
777
822
  | `subscribeRetry.backoffMs` | `5000` | Delay between subscribe retry attempts (ms) |
@@ -791,6 +836,7 @@ Passed to `KafkaModule.register()` or returned from `registerAsync()` factory:
791
836
  | `numPartitions` | `1` | Number of partitions for auto-created topics |
792
837
  | `strictSchemas` | `true` | Validate string topic keys against schemas registered via TopicDescriptor |
793
838
  | `instrumentation` | `[]` | Client-wide instrumentation hooks (e.g. OTel). Applied to both send and consume paths |
839
+ | `transactionalId` | `${clientId}-tx` | Transactional producer ID for `transaction()` calls. Must be unique per producer instance across the cluster — two instances sharing the same ID will be fenced by Kafka. The client logs a warning when the same ID is registered twice within one process |
794
840
  | `onMessageLost` | — | Called when a message is silently dropped without DLQ — use to alert, log to external systems, or trigger fallback logic |
795
841
  | `onRebalance` | — | Called on every partition assign/revoke event across all consumers created by this client |
796
842
 
@@ -880,6 +926,81 @@ const interceptor: ConsumerInterceptor<MyTopics> = {
880
926
  };
881
927
  ```
882
928
 
929
+ ## Deduplication (Lamport Clock)
930
+
931
+ 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`).
932
+
933
+ 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.
934
+
935
+ ```typescript
936
+ await kafka.startConsumer(['orders.created'], handler, {
937
+ deduplication: {}, // 'drop' strategy — silently discard duplicates
938
+ });
939
+ ```
940
+
941
+ ### How duplicates happen
942
+
943
+ 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.
944
+
945
+ ```text
946
+ Producer A (running): sends clock 1, 2, 3, 4, 5 → consumer processes all 5
947
+ Producer A (restarts): sends clock 1, 2, 3 → consumer sees 1 ≤ 5 — duplicate!
948
+ ```
949
+
950
+ ### Strategies
951
+
952
+ | Strategy | Behaviour |
953
+ | -------- | --------- |
954
+ | `'drop'` *(default)* | Log a warning and silently discard the message |
955
+ | `'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` |
956
+ | `'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`) |
957
+
958
+ ```typescript
959
+ // Strategy: drop (default)
960
+ await kafka.startConsumer(['orders'], handler, {
961
+ deduplication: {},
962
+ });
963
+
964
+ // Strategy: DLQ — inspect duplicates from {topic}.dlq
965
+ await kafka.startConsumer(['orders'], handler, {
966
+ dlq: true,
967
+ deduplication: { strategy: 'dlq' },
968
+ });
969
+
970
+ // Strategy: dedicated topic — consume from {topic}.duplicates
971
+ await kafka.startConsumer(['orders'], handler, {
972
+ deduplication: { strategy: 'topic' },
973
+ });
974
+
975
+ // Strategy: custom topic name
976
+ await kafka.startConsumer(['orders'], handler, {
977
+ deduplication: {
978
+ strategy: 'topic',
979
+ duplicatesTopic: 'ops.orders.duplicates',
980
+ },
981
+ });
982
+ ```
983
+
984
+ ### Startup validation
985
+
986
+ 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.
987
+
988
+ With `autoCreateTopics: true` the check is skipped — the topic is created automatically instead.
989
+
990
+ ### Backwards compatibility
991
+
992
+ Messages without an `x-lamport-clock` header pass through unchanged. Producers not using this library are unaffected.
993
+
994
+ ### Limitations
995
+
996
+ Deduplication state is **in-memory and per-consumer-instance**. Understand what that means:
997
+
998
+ - **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.
999
+ - **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.
1000
+ - **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.
1001
+
1002
+ Use this feature as a lightweight first line of defence — not as a substitute for idempotent business logic.
1003
+
883
1004
  ## Retry topic chain
884
1005
 
885
1006
  > **tl;dr — recommended production setup:**