@drarzter/kafka-client 0.7.0 → 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
@@ -36,13 +36,16 @@ Type-safe Kafka client for Node.js. Framework-agnostic core with a first-class N
36
36
  - [stopConsumer](#stopconsumer)
37
37
  - [Pause and resume](#pause-and-resume)
38
38
  - [Circuit breaker](#circuit-breaker)
39
+ - [getCircuitState](#getcircuitstate)
39
40
  - [Reset consumer offsets](#reset-consumer-offsets)
40
41
  - [Seek to offset](#seek-to-offset)
42
+ - [Seek to timestamp](#seek-to-timestamp)
41
43
  - [Message TTL](#message-ttl)
42
44
  - [DLQ replay](#dlq-replay)
43
45
  - [Graceful shutdown](#graceful-shutdown)
44
46
  - [Consumer handles](#consumer-handles)
45
47
  - [onMessageLost](#onmessagelost)
48
+ - [onTtlExpired](#onttlexpired)
46
49
  - [onRebalance](#onrebalance)
47
50
  - [Consumer lag](#consumer-lag)
48
51
  - [Handler timeout warning](#handler-timeout-warning)
@@ -465,6 +468,20 @@ for await (const envelope of kafka.consume('orders', {
465
468
 
466
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.
467
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
+
468
485
  ## Multiple consumer groups
469
486
 
470
487
  ### Per-consumer groupId
@@ -871,6 +888,7 @@ Options for `sendMessage()` — the third argument:
871
888
  | `circuitBreaker.recoveryMs` | `30_000` | Milliseconds to wait in OPEN state before entering HALF_OPEN |
872
889
  | `circuitBreaker.windowSize` | `threshold × 2, min 10` | Sliding window size in messages |
873
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()` |
874
892
  | `batch` | `false` | (decorator only) Use `startBatchConsumer` instead of `startConsumer` |
875
893
  | `subscribeRetry.retries` | `5` | Max attempts for `consumer.subscribe()` when topic doesn't exist yet |
876
894
  | `subscribeRetry.backoffMs` | `5000` | Delay between subscribe retry attempts (ms) |
@@ -892,6 +910,7 @@ Passed to `KafkaModule.register()` or returned from `registerAsync()` factory:
892
910
  | `instrumentation` | `[]` | Client-wide instrumentation hooks (e.g. OTel). Applied to both send and consume paths |
893
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 |
894
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 }` |
895
914
  | `onRebalance` | — | Called on every partition assign/revoke event across all consumers created by this client |
896
915
 
897
916
  **Module-scoped** (default) — import `KafkaModule` in each module that needs it:
@@ -1200,6 +1219,41 @@ Options:
1200
1219
  | `windowSize` | `threshold × 2, min 10` | Sliding window size in messages |
1201
1220
  | `halfOpenSuccesses` | `1` | Consecutive successes in HALF_OPEN required to close the circuit |
1202
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
+
1203
1257
  ## Reset consumer offsets
1204
1258
 
1205
1259
  Seek a consumer group's committed offsets to the beginning or end of a topic:
@@ -1239,6 +1293,29 @@ The first argument is the consumer group ID — pass `undefined` to target the d
1239
1293
 
1240
1294
  **Important:** the consumer for the specified group must be stopped before calling `seekToOffset`. An error is thrown if the group is currently running.
1241
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
+
1242
1319
  ## Message TTL
1243
1320
 
1244
1321
  Drop or route expired messages using `messageTtlMs` in `ConsumerOptions`:
@@ -1253,7 +1330,7 @@ await kafka.startConsumer(['orders'], handler, {
1253
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:
1254
1331
 
1255
1332
  - **Routed to DLQ** with `x-dlq-reason: ttl-expired` when `dlq: true`
1256
- - **Dropped** (calling `onMessageLost`) otherwise
1333
+ - **Dropped** (calling `onTtlExpired` if configured) otherwise
1257
1334
 
1258
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.
1259
1336
 
@@ -1361,6 +1438,28 @@ const kafka = new KafkaClient('my-app', 'my-group', ['localhost:9092'], {
1361
1438
 
1362
1439
  In the normal case (`dlq: true`, DLQ send succeeds), `onMessageLost` does NOT fire — the message is preserved in `{topic}.dlq`.
1363
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
+
1364
1463
  ## onRebalance
1365
1464
 
1366
1465
  React to partition rebalance events without patching the consumer. Useful for flushing in-flight state before partitions are revoked, or for logging/metrics:
@@ -732,10 +732,10 @@ async function handleEachMessage(payload, opts, deps) {
732
732
  });
733
733
  deps.onDlq?.(envelope, "ttl-expired");
734
734
  } else {
735
- await deps.onMessageLost?.({
735
+ await deps.onTtlExpired?.({
736
736
  topic: topic2,
737
- error: new Error(`TTL expired: ${ageMs}ms`),
738
- attempt: 0,
737
+ ageMs,
738
+ messageTtlMs: opts.messageTtlMs,
739
739
  headers: envelope.headers
740
740
  });
741
741
  }
@@ -859,10 +859,10 @@ async function handleEachBatch(payload, opts, deps) {
859
859
  });
860
860
  deps.onDlq?.(envelope, "ttl-expired");
861
861
  } else {
862
- await deps.onMessageLost?.({
862
+ await deps.onTtlExpired?.({
863
863
  topic: batch.topic,
864
- error: new Error(`TTL expired: ${ageMs}ms`),
865
- attempt: 0,
864
+ ageMs,
865
+ messageTtlMs: opts.messageTtlMs,
866
866
  headers: envelope.headers
867
867
  });
868
868
  }
@@ -1179,28 +1179,51 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
1179
1179
  var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS;
1180
1180
  var _activeTransactionalIds = /* @__PURE__ */ new Set();
1181
1181
  var AsyncQueue = class {
1182
+ constructor(highWaterMark = Infinity, onFull = () => {
1183
+ }, onDrained = () => {
1184
+ }) {
1185
+ this.highWaterMark = highWaterMark;
1186
+ this.onFull = onFull;
1187
+ this.onDrained = onDrained;
1188
+ }
1182
1189
  items = [];
1183
1190
  waiting = [];
1184
1191
  closed = false;
1192
+ error;
1193
+ paused = false;
1185
1194
  push(item) {
1186
1195
  if (this.waiting.length > 0) {
1187
- this.waiting.shift()({ value: item, done: false });
1196
+ this.waiting.shift().resolve({ value: item, done: false });
1188
1197
  } else {
1189
1198
  this.items.push(item);
1199
+ if (!this.paused && this.items.length >= this.highWaterMark) {
1200
+ this.paused = true;
1201
+ this.onFull();
1202
+ }
1190
1203
  }
1191
1204
  }
1205
+ fail(err) {
1206
+ this.closed = true;
1207
+ this.error = err;
1208
+ for (const { reject } of this.waiting.splice(0)) reject(err);
1209
+ }
1192
1210
  close() {
1193
1211
  this.closed = true;
1194
- for (const r of this.waiting.splice(0)) {
1195
- r({ value: void 0, done: true });
1196
- }
1212
+ for (const { resolve } of this.waiting.splice(0))
1213
+ resolve({ value: void 0, done: true });
1197
1214
  }
1198
1215
  next() {
1199
- if (this.items.length > 0)
1200
- return Promise.resolve({ value: this.items.shift(), done: false });
1201
- if (this.closed)
1202
- return Promise.resolve({ value: void 0, done: true });
1203
- return new Promise((r) => this.waiting.push(r));
1216
+ if (this.error) return Promise.reject(this.error);
1217
+ if (this.items.length > 0) {
1218
+ const value = this.items.shift();
1219
+ if (this.paused && this.items.length <= Math.floor(this.highWaterMark / 2)) {
1220
+ this.paused = false;
1221
+ this.onDrained();
1222
+ }
1223
+ return Promise.resolve({ value, done: false });
1224
+ }
1225
+ if (this.closed) return Promise.resolve({ value: void 0, done: true });
1226
+ return new Promise((resolve, reject) => this.waiting.push({ resolve, reject }));
1204
1227
  }
1205
1228
  };
1206
1229
  var KafkaClient = class _KafkaClient {
@@ -1227,6 +1250,7 @@ var KafkaClient = class _KafkaClient {
1227
1250
  companionGroupIds = /* @__PURE__ */ new Map();
1228
1251
  instrumentation;
1229
1252
  onMessageLost;
1253
+ onTtlExpired;
1230
1254
  onRebalance;
1231
1255
  /** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
1232
1256
  txId;
@@ -1258,6 +1282,7 @@ var KafkaClient = class _KafkaClient {
1258
1282
  this.numPartitions = options?.numPartitions ?? 1;
1259
1283
  this.instrumentation = options?.instrumentation ?? [];
1260
1284
  this.onMessageLost = options?.onMessageLost;
1285
+ this.onTtlExpired = options?.onTtlExpired;
1261
1286
  this.onRebalance = options?.onRebalance;
1262
1287
  this.txId = options?.transactionalId ?? `${clientId}-tx`;
1263
1288
  this.kafka = new KafkaClass({
@@ -1525,7 +1550,12 @@ var KafkaClient = class _KafkaClient {
1525
1550
  * }
1526
1551
  */
1527
1552
  consume(topic2, options) {
1528
- const queue = new AsyncQueue();
1553
+ const gid = options?.groupId ?? this.defaultGroupId;
1554
+ const queue = new AsyncQueue(
1555
+ options?.queueHighWaterMark,
1556
+ () => this.pauseTopicAllPartitions(gid, topic2),
1557
+ () => this.resumeTopicAllPartitions(gid, topic2)
1558
+ );
1529
1559
  const handlePromise = this.startConsumer(
1530
1560
  [topic2],
1531
1561
  async (envelope) => {
@@ -1533,6 +1563,7 @@ var KafkaClient = class _KafkaClient {
1533
1563
  },
1534
1564
  options
1535
1565
  );
1566
+ handlePromise.catch((err) => queue.fail(err));
1536
1567
  return {
1537
1568
  [Symbol.asyncIterator]() {
1538
1569
  return this;
@@ -1666,6 +1697,24 @@ var KafkaClient = class _KafkaClient {
1666
1697
  )
1667
1698
  );
1668
1699
  }
1700
+ /** Pause all assigned partitions of a topic for a consumer group (used for queue backpressure). */
1701
+ pauseTopicAllPartitions(gid, topic2) {
1702
+ const consumer = this.consumers.get(gid);
1703
+ if (!consumer) return;
1704
+ const assignment = consumer.assignment?.() ?? [];
1705
+ const partitions = assignment.filter((a) => a.topic === topic2).map((a) => a.partition);
1706
+ if (partitions.length > 0)
1707
+ consumer.pause(partitions.map((p) => ({ topic: topic2, partitions: [p] })));
1708
+ }
1709
+ /** Resume all assigned partitions of a topic for a consumer group (used for queue backpressure). */
1710
+ resumeTopicAllPartitions(gid, topic2) {
1711
+ const consumer = this.consumers.get(gid);
1712
+ if (!consumer) return;
1713
+ const assignment = consumer.assignment?.() ?? [];
1714
+ const partitions = assignment.filter((a) => a.topic === topic2).map((a) => a.partition);
1715
+ if (partitions.length > 0)
1716
+ consumer.resume(partitions.map((p) => ({ topic: topic2, partitions: [p] })));
1717
+ }
1669
1718
  /** DLQ header keys added by `sendToDlq` — stripped before re-publishing. */
1670
1719
  static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
1671
1720
  "x-dlq-original-topic",
@@ -1802,6 +1851,49 @@ var KafkaClient = class _KafkaClient {
1802
1851
  );
1803
1852
  }
1804
1853
  }
1854
+ async seekToTimestamp(groupId, assignments) {
1855
+ const gid = groupId ?? this.defaultGroupId;
1856
+ if (this.runningConsumers.has(gid)) {
1857
+ throw new Error(
1858
+ `seekToTimestamp: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
1859
+ );
1860
+ }
1861
+ await this.ensureAdminConnected();
1862
+ const byTopic = /* @__PURE__ */ new Map();
1863
+ for (const { topic: topic2, partition, timestamp } of assignments) {
1864
+ const list = byTopic.get(topic2) ?? [];
1865
+ list.push({ partition, timestamp });
1866
+ byTopic.set(topic2, list);
1867
+ }
1868
+ for (const [topic2, parts] of byTopic) {
1869
+ const offsets = await Promise.all(
1870
+ parts.map(async ({ partition, timestamp }) => {
1871
+ const results = await this.admin.fetchTopicOffsetsByTime(
1872
+ topic2,
1873
+ timestamp
1874
+ );
1875
+ const found = results.find(
1876
+ (r) => r.partition === partition
1877
+ );
1878
+ return { partition, offset: found?.offset ?? "-1" };
1879
+ })
1880
+ );
1881
+ await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions: offsets });
1882
+ this.logger.log(
1883
+ `Offsets set by timestamp for group "${gid}" on "${topic2}": ${JSON.stringify(offsets)}`
1884
+ );
1885
+ }
1886
+ }
1887
+ getCircuitState(topic2, partition, groupId) {
1888
+ const gid = groupId ?? this.defaultGroupId;
1889
+ const state = this.circuitStates.get(`${gid}:${topic2}:${partition}`);
1890
+ if (!state) return void 0;
1891
+ return {
1892
+ status: state.status,
1893
+ failures: state.window.filter((v) => !v).length,
1894
+ windowSize: state.window.length
1895
+ };
1896
+ }
1805
1897
  /**
1806
1898
  * Query consumer group lag per partition.
1807
1899
  * Lag = broker high-watermark − last committed offset.
@@ -2022,6 +2114,11 @@ var KafkaClient = class _KafkaClient {
2022
2114
  if (state.status === "open") return;
2023
2115
  const openCircuit = () => {
2024
2116
  state.status = "open";
2117
+ state.window = [];
2118
+ state.successes = 0;
2119
+ clearTimeout(state.timer);
2120
+ for (const inst of this.instrumentation)
2121
+ inst.onCircuitOpen?.(envelope.topic, envelope.partition);
2025
2122
  this.pauseConsumer(gid, [
2026
2123
  { topic: envelope.topic, partitions: [envelope.partition] }
2027
2124
  ]);
@@ -2031,6 +2128,8 @@ var KafkaClient = class _KafkaClient {
2031
2128
  this.logger.log(
2032
2129
  `[CircuitBreaker] HALF-OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
2033
2130
  );
2131
+ for (const inst of this.instrumentation)
2132
+ inst.onCircuitHalfOpen?.(envelope.topic, envelope.partition);
2034
2133
  this.resumeConsumer(gid, [
2035
2134
  { topic: envelope.topic, partitions: [envelope.partition] }
2036
2135
  ]);
@@ -2086,6 +2185,8 @@ var KafkaClient = class _KafkaClient {
2086
2185
  this.logger.log(
2087
2186
  `[CircuitBreaker] CLOSED \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
2088
2187
  );
2188
+ for (const inst of this.instrumentation)
2189
+ inst.onCircuitClose?.(envelope.topic, envelope.partition);
2089
2190
  }
2090
2191
  } else if (state.status === "closed") {
2091
2192
  const threshold = cfg.threshold ?? 5;
@@ -2322,6 +2423,7 @@ var KafkaClient = class _KafkaClient {
2322
2423
  producer: this.producer,
2323
2424
  instrumentation: this.instrumentation,
2324
2425
  onMessageLost: this.onMessageLost,
2426
+ onTtlExpired: this.onTtlExpired,
2325
2427
  onRetry: this.notifyRetry.bind(this),
2326
2428
  onDlq: (envelope, reason) => this.notifyDlq(envelope, reason, gid),
2327
2429
  onDuplicate: this.notifyDuplicate.bind(this),
@@ -2379,4 +2481,4 @@ export {
2379
2481
  KafkaClient,
2380
2482
  topic
2381
2483
  };
2382
- //# sourceMappingURL=chunk-MJ342P4R.mjs.map
2484
+ //# sourceMappingURL=chunk-AMEGMOZH.mjs.map