@drarzter/kafka-client 0.6.9 → 0.7.1

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
@@ -20,6 +20,7 @@ Type-safe Kafka client for Node.js. Framework-agnostic core with a first-class N
20
20
  - [Consuming messages](#consuming-messages)
21
21
  - [Declarative: @SubscribeTo()](#declarative-subscribeto)
22
22
  - [Imperative: startConsumer()](#imperative-startconsumer)
23
+ - [Iterator: consume()](#iterator-consume)
23
24
  - [Multiple consumer groups](#multiple-consumer-groups)
24
25
  - [Partition key](#partition-key)
25
26
  - [Message headers](#message-headers)
@@ -34,11 +35,17 @@ Type-safe Kafka client for Node.js. Framework-agnostic core with a first-class N
34
35
  - [Retry topic chain](#retry-topic-chain)
35
36
  - [stopConsumer](#stopconsumer)
36
37
  - [Pause and resume](#pause-and-resume)
38
+ - [Circuit breaker](#circuit-breaker)
39
+ - [getCircuitState](#getcircuitstate)
37
40
  - [Reset consumer offsets](#reset-consumer-offsets)
41
+ - [Seek to offset](#seek-to-offset)
42
+ - [Seek to timestamp](#seek-to-timestamp)
43
+ - [Message TTL](#message-ttl)
38
44
  - [DLQ replay](#dlq-replay)
39
45
  - [Graceful shutdown](#graceful-shutdown)
40
46
  - [Consumer handles](#consumer-handles)
41
47
  - [onMessageLost](#onmessagelost)
48
+ - [onTtlExpired](#onttlexpired)
42
49
  - [onRebalance](#onrebalance)
43
50
  - [Consumer lag](#consumer-lag)
44
51
  - [Handler timeout warning](#handler-timeout-warning)
@@ -86,6 +93,10 @@ Safe by default. Configurable when you need it. Escape hatches for when you know
86
93
  - **Health check** — built-in health indicator for monitoring
87
94
  - **Multiple consumer groups** — named clients for different bounded contexts
88
95
  - **Declarative & imperative** — use `@SubscribeTo()` decorator or `startConsumer()` directly
96
+ - **Async iterator** — `consume<K>()` returns an `AsyncIterableIterator<EventEnvelope<T[K]>>` for `for await` consumption; breaking out of the loop stops the consumer automatically
97
+ - **Message TTL** — `messageTtlMs` drops or DLQs messages older than a configurable threshold, preventing stale events from poisoning downstream systems after a lag spike
98
+ - **Circuit breaker** — `circuitBreaker` option applies a sliding-window breaker per topic-partition; pauses delivery on repeated DLQ failures and resumes after a configurable recovery window
99
+ - **Seek to offset** — `seekToOffset(groupId, assignments)` seeks individual partitions to explicit offsets for fine-grained replay
89
100
 
90
101
  See the [Roadmap](./ROADMAP.md) for upcoming features and version history.
91
102
 
@@ -379,7 +390,7 @@ export class OrdersService {
379
390
 
380
391
  ## Consuming messages
381
392
 
382
- Two ways — choose what fits your style.
393
+ Three ways — choose what fits your style.
383
394
 
384
395
  ### Declarative: @SubscribeTo()
385
396
 
@@ -428,6 +439,49 @@ export class OrdersService implements OnModuleInit {
428
439
  }
429
440
  ```
430
441
 
442
+ ### Iterator: consume()
443
+
444
+ Stream messages from a single topic as an `AsyncIterableIterator` — useful for scripts, one-off tasks, or any context where you prefer `for await` over a callback:
445
+
446
+ ```typescript
447
+ for await (const envelope of kafka.consume('order.created')) {
448
+ console.log('Order:', envelope.payload.orderId);
449
+ }
450
+
451
+ // Breaking out of the loop stops the consumer automatically
452
+ for await (const envelope of kafka.consume('order.created')) {
453
+ if (envelope.payload.orderId === targetId) break;
454
+ }
455
+ ```
456
+
457
+ `consume()` accepts the same `ConsumerOptions` as `startConsumer()`:
458
+
459
+ ```typescript
460
+ for await (const envelope of kafka.consume('orders', {
461
+ retry: { maxRetries: 3 },
462
+ dlq: true,
463
+ messageTtlMs: 60_000,
464
+ })) {
465
+ await processOrder(envelope.payload);
466
+ }
467
+ ```
468
+
469
+ `break`, `return`, or any early exit from the loop calls the iterator's `return()` method, which closes the internal queue and calls `handle.stop()` on the background consumer.
470
+
471
+ **Backpressure** — use `queueHighWaterMark` to prevent unbounded queue growth when processing is slower than the message rate:
472
+
473
+ ```typescript
474
+ for await (const envelope of kafka.consume('orders', {
475
+ queueHighWaterMark: 100, // pause partition when queue reaches 100 messages
476
+ })) {
477
+ await slowProcessing(envelope.payload); // resumes when queue drains below 50
478
+ }
479
+ ```
480
+
481
+ The partition is paused when the internal queue reaches `queueHighWaterMark` and automatically resumed when it drains below 50%. Without this option the queue is unbounded.
482
+
483
+ **Error propagation** — if the consumer fails to start (e.g. broker unreachable), the error surfaces on the next `next()` / `for await` iteration rather than being silently swallowed.
484
+
431
485
  ## Multiple consumer groups
432
486
 
433
487
  ### Per-consumer groupId
@@ -828,6 +882,13 @@ Options for `sendMessage()` — the third argument:
828
882
  | `handlerTimeoutMs` | — | Log a warning if the handler hasn't resolved within this window (ms) — does not cancel the handler |
829
883
  | `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` |
830
884
  | `deduplication.duplicatesTopic` | `{topic}.duplicates` | Custom destination for `strategy: 'topic'` |
885
+ | `messageTtlMs` | — | Drop (or DLQ) messages older than this many milliseconds at consumption time; evaluated against the `x-timestamp` header; see [Message TTL](#message-ttl) |
886
+ | `circuitBreaker` | — | Enable circuit breaker with `{}` for zero-config defaults; requires `dlq: true`; see [Circuit breaker](#circuit-breaker) |
887
+ | `circuitBreaker.threshold` | `5` | DLQ failures within `windowSize` that opens the circuit |
888
+ | `circuitBreaker.recoveryMs` | `30_000` | Milliseconds to wait in OPEN state before entering HALF_OPEN |
889
+ | `circuitBreaker.windowSize` | `threshold × 2, min 10` | Sliding window size in messages |
890
+ | `circuitBreaker.halfOpenSuccesses` | `1` | Consecutive successes in HALF_OPEN required to close the circuit |
891
+ | `queueHighWaterMark` | unbounded | Max messages buffered in the `consume()` iterator queue before the partition is paused; resumes at 50% drain. Only applies to `consume()` |
831
892
  | `batch` | `false` | (decorator only) Use `startBatchConsumer` instead of `startConsumer` |
832
893
  | `subscribeRetry.retries` | `5` | Max attempts for `consumer.subscribe()` when topic doesn't exist yet |
833
894
  | `subscribeRetry.backoffMs` | `5000` | Delay between subscribe retry attempts (ms) |
@@ -849,6 +910,7 @@ Passed to `KafkaModule.register()` or returned from `registerAsync()` factory:
849
910
  | `instrumentation` | `[]` | Client-wide instrumentation hooks (e.g. OTel). Applied to both send and consume paths |
850
911
  | `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 |
851
912
  | `onMessageLost` | — | Called when a message is silently dropped without DLQ — use to alert, log to external systems, or trigger fallback logic |
913
+ | `onTtlExpired` | — | Called when a message is dropped due to TTL expiration (`messageTtlMs`) and `dlq` is not enabled; receives `{ topic, ageMs, messageTtlMs, headers }` |
852
914
  | `onRebalance` | — | Called on every partition assign/revoke event across all consumers created by this client |
853
915
 
854
916
  **Module-scoped** (default) — import `KafkaModule` in each module that needs it:
@@ -1109,6 +1171,89 @@ The first argument is the consumer group ID — pass `undefined` to target the d
1109
1171
 
1110
1172
  Pausing is non-destructive: the consumer stays connected and Kafka preserves the partition assignment for as long as the group session is alive. Messages accumulate in the topic and are delivered once the consumer resumes. Typical use: apply backpressure when a downstream dependency (e.g. a database) is temporarily overloaded.
1111
1173
 
1174
+ ## Circuit breaker
1175
+
1176
+ Automatically pause delivery from a topic-partition when its DLQ error rate exceeds a threshold. After a recovery window the partition is resumed automatically.
1177
+
1178
+ **`dlq: true` is required** — the breaker counts DLQ events as failures. Without it no failures are recorded and the circuit never opens.
1179
+
1180
+ Zero-config start — all options have sensible defaults:
1181
+
1182
+ ```typescript
1183
+ await kafka.startConsumer(['orders'], handler, {
1184
+ dlq: true,
1185
+ circuitBreaker: {},
1186
+ });
1187
+ ```
1188
+
1189
+ Full config for fine-tuning:
1190
+
1191
+ ```typescript
1192
+ await kafka.startConsumer(['orders'], handler, {
1193
+ dlq: true,
1194
+ circuitBreaker: {
1195
+ threshold: 10, // open after 10 failures (default: 5)
1196
+ recoveryMs: 60_000, // wait 60 s before probing (default: 30 s)
1197
+ windowSize: 50, // track last 50 messages (default: threshold × 2, min 10)
1198
+ halfOpenSuccesses: 3, // 3 successes to close (default: 1)
1199
+ },
1200
+ });
1201
+ ```
1202
+
1203
+ State machine per `${groupId}:${topic}:${partition}`:
1204
+
1205
+ | State | Behaviour |
1206
+ | ----- | --------- |
1207
+ | **CLOSED** (normal) | Messages delivered. Failures recorded in sliding window. Opens when `failures ≥ threshold`. |
1208
+ | **OPEN** | Partition paused via `pauseConsumer`. After `recoveryMs` ms transitions to HALF_OPEN. |
1209
+ | **HALF_OPEN** | Partition resumed. After `halfOpenSuccesses` consecutive successes the circuit closes. Any single failure immediately re-opens it. |
1210
+
1211
+ Successful `onMessage` completions count as successes. The retry topic path is not subject to the breaker — it has its own backoff and EOS guarantees.
1212
+
1213
+ Options:
1214
+
1215
+ | Option | Default | Description |
1216
+ | ------ | ------- | ----------- |
1217
+ | `threshold` | `5` | DLQ failures within `windowSize` that opens the circuit |
1218
+ | `recoveryMs` | `30_000` | Milliseconds to wait in OPEN state before entering HALF_OPEN |
1219
+ | `windowSize` | `threshold × 2, min 10` | Sliding window size in messages |
1220
+ | `halfOpenSuccesses` | `1` | Consecutive successes in HALF_OPEN required to close the circuit |
1221
+
1222
+ ### getCircuitState
1223
+
1224
+ Inspect the current circuit breaker state for a partition — useful for health endpoints and dashboards:
1225
+
1226
+ ```typescript
1227
+ const state = kafka.getCircuitState('orders', 0);
1228
+ // undefined — circuit not configured or never tripped
1229
+ // { status: 'closed', failures: 2, windowSize: 10 }
1230
+ // { status: 'open', failures: 5, windowSize: 10 }
1231
+ // { status: 'half-open', failures: 0, windowSize: 0 }
1232
+
1233
+ // With explicit group ID:
1234
+ const state = kafka.getCircuitState('orders', 0, 'payments-group');
1235
+ ```
1236
+
1237
+ Returns `undefined` when `circuitBreaker` is not configured for the group or the circuit has never been tripped (state is lazily initialised on the first DLQ event).
1238
+
1239
+ **Instrumentation hooks** — react to state transitions via `KafkaInstrumentation`:
1240
+
1241
+ ```typescript
1242
+ const kafka = new KafkaClient('svc', 'group', brokers, {
1243
+ instrumentation: [{
1244
+ onCircuitOpen(topic, partition) {
1245
+ metrics.increment('circuit_open', { topic, partition });
1246
+ },
1247
+ onCircuitHalfOpen(topic, partition) {
1248
+ logger.log(`Circuit probing ${topic}[${partition}]`);
1249
+ },
1250
+ onCircuitClose(topic, partition) {
1251
+ metrics.increment('circuit_close', { topic, partition });
1252
+ },
1253
+ }],
1254
+ });
1255
+ ```
1256
+
1112
1257
  ## Reset consumer offsets
1113
1258
 
1114
1259
  Seek a consumer group's committed offsets to the beginning or end of a topic:
@@ -1126,6 +1271,69 @@ await kafka.resetOffsets('payments-group', 'orders', 'earliest');
1126
1271
 
1127
1272
  **Important:** the consumer for the specified group must be stopped before calling `resetOffsets`. An error is thrown if the group is currently running — this prevents the reset from racing with an active offset commit.
1128
1273
 
1274
+ ## Seek to offset
1275
+
1276
+ Seek individual topic-partitions to explicit offsets — useful when `resetOffsets` is too coarse and you need per-partition control:
1277
+
1278
+ ```typescript
1279
+ // Seek partition 0 of 'orders' to offset 100, partition 1 to offset 200
1280
+ await kafka.seekToOffset(undefined, [
1281
+ { topic: 'orders', partition: 0, offset: '100' },
1282
+ { topic: 'orders', partition: 1, offset: '200' },
1283
+ ]);
1284
+
1285
+ // Multiple topics in one call
1286
+ await kafka.seekToOffset('payments-group', [
1287
+ { topic: 'payments', partition: 0, offset: '0' },
1288
+ { topic: 'refunds', partition: 0, offset: '500' },
1289
+ ]);
1290
+ ```
1291
+
1292
+ The first argument is the consumer group ID — pass `undefined` to target the default group. Assignments are grouped by topic internally so each `admin.setOffsets` call covers all partitions of one topic.
1293
+
1294
+ **Important:** the consumer for the specified group must be stopped before calling `seekToOffset`. An error is thrown if the group is currently running.
1295
+
1296
+ ## Seek to timestamp
1297
+
1298
+ Seek partitions to the offset nearest to a specific point in time — useful for replaying events that occurred after a known incident or deployment:
1299
+
1300
+ ```typescript
1301
+ const ts = new Date('2024-06-01T12:00:00Z').getTime(); // Unix ms
1302
+
1303
+ await kafka.seekToTimestamp(undefined, [
1304
+ { topic: 'orders', partition: 0, timestamp: ts },
1305
+ { topic: 'orders', partition: 1, timestamp: ts },
1306
+ ]);
1307
+
1308
+ // Multiple topics in one call
1309
+ await kafka.seekToTimestamp('payments-group', [
1310
+ { topic: 'payments', partition: 0, timestamp: ts },
1311
+ { topic: 'refunds', partition: 0, timestamp: ts },
1312
+ ]);
1313
+ ```
1314
+
1315
+ Uses `admin.fetchTopicOffsetsByTime` under the hood. If no offset exists at the requested timestamp (e.g. the partition is empty or the timestamp is in the future), the partition falls back to `-1` (end of topic — new messages only).
1316
+
1317
+ **Important:** the consumer group must be stopped before seeking. Assignments for the same topic are batched into a single `admin.setOffsets` call.
1318
+
1319
+ ## Message TTL
1320
+
1321
+ Drop or route expired messages using `messageTtlMs` in `ConsumerOptions`:
1322
+
1323
+ ```typescript
1324
+ await kafka.startConsumer(['orders'], handler, {
1325
+ messageTtlMs: 60_000, // drop messages older than 60 s
1326
+ dlq: true, // route expired messages to DLQ instead of dropping
1327
+ });
1328
+ ```
1329
+
1330
+ The TTL is evaluated against the `x-timestamp` header stamped on every outgoing message by the producer. Messages whose age at consumption time exceeds `messageTtlMs` are:
1331
+
1332
+ - **Routed to DLQ** with `x-dlq-reason: ttl-expired` when `dlq: true`
1333
+ - **Dropped** (calling `onTtlExpired` if configured) otherwise
1334
+
1335
+ Typical use: prevent stale events from poisoning downstream systems after a consumer lag spike — e.g. discard order events or push notifications that are no longer actionable.
1336
+
1129
1337
  ## DLQ replay
1130
1338
 
1131
1339
  Re-publish messages from a dead letter queue back to the original topic:
@@ -1230,6 +1438,28 @@ const kafka = new KafkaClient('my-app', 'my-group', ['localhost:9092'], {
1230
1438
 
1231
1439
  In the normal case (`dlq: true`, DLQ send succeeds), `onMessageLost` does NOT fire — the message is preserved in `{topic}.dlq`.
1232
1440
 
1441
+ > **Note:** TTL-expired messages do **not** trigger `onMessageLost`. Use `onTtlExpired` to observe them separately.
1442
+
1443
+ ## onTtlExpired
1444
+
1445
+ Called when a message is dropped because it exceeded `messageTtlMs` and `dlq` is not enabled. Fires instead of `onMessageLost` for expired messages:
1446
+
1447
+ ```typescript
1448
+ import { KafkaClient, TtlExpiredContext } from '@drarzter/kafka-client/core';
1449
+
1450
+ const kafka = new KafkaClient('my-app', 'my-group', ['localhost:9092'], {
1451
+ onTtlExpired: (ctx: TtlExpiredContext) => {
1452
+ // ctx.topic — topic the message came from
1453
+ // ctx.ageMs — actual age of the message at drop time
1454
+ // ctx.messageTtlMs — the configured threshold
1455
+ // ctx.headers — original message headers
1456
+ logger.warn(`Stale message on ${ctx.topic}: ${ctx.ageMs}ms old (limit ${ctx.messageTtlMs}ms)`);
1457
+ },
1458
+ });
1459
+ ```
1460
+
1461
+ When `dlq: true`, expired messages are routed to DLQ instead and `onTtlExpired` is **not** called.
1462
+
1233
1463
  ## onRebalance
1234
1464
 
1235
1465
  React to partition rebalance events without patching the consumer. Useful for flushing in-flight state before partitions are revoked, or for logging/metrics: