@drarzter/kafka-client 0.7.2 → 0.7.3

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/dist/core.js CHANGED
@@ -136,7 +136,7 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
136
136
  }
137
137
  };
138
138
 
139
- // src/client/kafka.client/producer-ops.ts
139
+ // src/client/kafka.client/producer/ops.ts
140
140
  function resolveTopicName(topicOrDescriptor) {
141
141
  if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
142
142
  if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
@@ -214,7 +214,7 @@ async function buildSendPayload(topicOrDesc, messages, deps, compression) {
214
214
  return { topic: topic2, messages: builtMessages, ...compression && { compression } };
215
215
  }
216
216
 
217
- // src/client/kafka.client/consumer-ops.ts
217
+ // src/client/kafka.client/consumer/ops.ts
218
218
  var import_kafka_javascript = require("@confluentinc/kafka-javascript");
219
219
  function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps, partitionAssigner) {
220
220
  const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
@@ -277,7 +277,7 @@ function buildSchemaMap(topics, schemaRegistry, optionSchemas, logger) {
277
277
  return schemaMap;
278
278
  }
279
279
 
280
- // src/client/consumer/pipeline.ts
280
+ // src/client/kafka.client/consumer/pipeline.ts
281
281
  function toError(error) {
282
282
  return error instanceof Error ? error : new Error(String(error));
283
283
  }
@@ -627,7 +627,7 @@ async function executeWithRetry(fn, ctx, deps) {
627
627
  }
628
628
  }
629
629
 
630
- // src/client/kafka.client/message-handler.ts
630
+ // src/client/kafka.client/consumer/handler.ts
631
631
  async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
632
632
  const clockRaw = envelope.headers[HEADER_LAMPORT_CLOCK];
633
633
  if (clockRaw === void 0) return false;
@@ -948,7 +948,7 @@ async function handleEachBatch(payload, opts, deps) {
948
948
  );
949
949
  }
950
950
 
951
- // src/client/consumer/subscribe-retry.ts
951
+ // src/client/kafka.client/consumer/subscribe-retry.ts
952
952
  async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
953
953
  const maxAttempts = retryOpts?.retries ?? 5;
954
954
  const backoffMs = retryOpts?.backoffMs ?? 5e3;
@@ -969,7 +969,7 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
969
969
  }
970
970
  }
971
971
 
972
- // src/client/kafka.client/retry-topic.ts
972
+ // src/client/kafka.client/consumer/retry-topic.ts
973
973
  async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
974
974
  const topicSet = new Set(topics);
975
975
  const deadline = Date.now() + timeoutMs;
@@ -1223,9 +1223,7 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
1223
1223
  return levelGroupIds;
1224
1224
  }
1225
1225
 
1226
- // src/client/kafka.client/index.ts
1227
- var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript2.KafkaJS;
1228
- var _activeTransactionalIds = /* @__PURE__ */ new Set();
1226
+ // src/client/kafka.client/consumer/queue.ts
1229
1227
  var AsyncQueue = class {
1230
1228
  constructor(highWaterMark = Infinity, onFull = () => {
1231
1229
  }, onDrained = () => {
@@ -1274,6 +1272,562 @@ var AsyncQueue = class {
1274
1272
  return new Promise((resolve, reject) => this.waiting.push({ resolve, reject }));
1275
1273
  }
1276
1274
  };
1275
+
1276
+ // src/client/kafka.client/infra/circuit-breaker.ts
1277
+ var CircuitBreakerManager = class {
1278
+ constructor(deps) {
1279
+ this.deps = deps;
1280
+ }
1281
+ states = /* @__PURE__ */ new Map();
1282
+ configs = /* @__PURE__ */ new Map();
1283
+ setConfig(gid, options) {
1284
+ this.configs.set(gid, options);
1285
+ }
1286
+ /**
1287
+ * Returns a snapshot of the circuit breaker state for a given topic-partition.
1288
+ * Returns `undefined` when no state exists for the key.
1289
+ */
1290
+ getState(topic2, partition, gid) {
1291
+ const state = this.states.get(`${gid}:${topic2}:${partition}`);
1292
+ if (!state) return void 0;
1293
+ return {
1294
+ status: state.status,
1295
+ failures: state.window.filter((v) => !v).length,
1296
+ windowSize: state.window.length
1297
+ };
1298
+ }
1299
+ /**
1300
+ * Record a failure for the given envelope and group.
1301
+ * Drives the CLOSED → OPEN and HALF-OPEN → OPEN transitions.
1302
+ */
1303
+ onFailure(envelope, gid) {
1304
+ const cfg = this.configs.get(gid);
1305
+ if (!cfg) return;
1306
+ const threshold = cfg.threshold ?? 5;
1307
+ const recoveryMs = cfg.recoveryMs ?? 3e4;
1308
+ const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
1309
+ let state = this.states.get(stateKey);
1310
+ if (!state) {
1311
+ state = { status: "closed", window: [], successes: 0 };
1312
+ this.states.set(stateKey, state);
1313
+ }
1314
+ if (state.status === "open") return;
1315
+ const openCircuit = () => {
1316
+ state.status = "open";
1317
+ state.window = [];
1318
+ state.successes = 0;
1319
+ clearTimeout(state.timer);
1320
+ for (const inst of this.deps.instrumentation)
1321
+ inst.onCircuitOpen?.(envelope.topic, envelope.partition);
1322
+ this.deps.pauseConsumer(gid, [{ topic: envelope.topic, partitions: [envelope.partition] }]);
1323
+ state.timer = setTimeout(() => {
1324
+ state.status = "half-open";
1325
+ state.successes = 0;
1326
+ this.deps.logger.log(
1327
+ `[CircuitBreaker] HALF-OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
1328
+ );
1329
+ for (const inst of this.deps.instrumentation)
1330
+ inst.onCircuitHalfOpen?.(envelope.topic, envelope.partition);
1331
+ this.deps.resumeConsumer(gid, [{ topic: envelope.topic, partitions: [envelope.partition] }]);
1332
+ }, recoveryMs);
1333
+ };
1334
+ if (state.status === "half-open") {
1335
+ clearTimeout(state.timer);
1336
+ this.deps.logger.warn(
1337
+ `[CircuitBreaker] OPEN (half-open failure) \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
1338
+ );
1339
+ openCircuit();
1340
+ return;
1341
+ }
1342
+ const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
1343
+ state.window = [...state.window, false];
1344
+ if (state.window.length > windowSize) {
1345
+ state.window = state.window.slice(state.window.length - windowSize);
1346
+ }
1347
+ const failures = state.window.filter((v) => !v).length;
1348
+ if (failures >= threshold) {
1349
+ this.deps.logger.warn(
1350
+ `[CircuitBreaker] OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition} (${failures}/${state.window.length} failures, threshold=${threshold})`
1351
+ );
1352
+ openCircuit();
1353
+ }
1354
+ }
1355
+ /**
1356
+ * Record a success for the given envelope and group.
1357
+ * Drives the HALF-OPEN → CLOSED transition and updates the success window.
1358
+ */
1359
+ onSuccess(envelope, gid) {
1360
+ const cfg = this.configs.get(gid);
1361
+ if (!cfg) return;
1362
+ const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
1363
+ const state = this.states.get(stateKey);
1364
+ if (!state) return;
1365
+ const halfOpenSuccesses = cfg.halfOpenSuccesses ?? 1;
1366
+ if (state.status === "half-open") {
1367
+ state.successes++;
1368
+ if (state.successes >= halfOpenSuccesses) {
1369
+ clearTimeout(state.timer);
1370
+ state.timer = void 0;
1371
+ state.status = "closed";
1372
+ state.window = [];
1373
+ state.successes = 0;
1374
+ this.deps.logger.log(
1375
+ `[CircuitBreaker] CLOSED \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
1376
+ );
1377
+ for (const inst of this.deps.instrumentation)
1378
+ inst.onCircuitClose?.(envelope.topic, envelope.partition);
1379
+ }
1380
+ } else if (state.status === "closed") {
1381
+ const threshold = cfg.threshold ?? 5;
1382
+ const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
1383
+ state.window = [...state.window, true];
1384
+ if (state.window.length > windowSize) {
1385
+ state.window = state.window.slice(state.window.length - windowSize);
1386
+ }
1387
+ }
1388
+ }
1389
+ /**
1390
+ * Remove all circuit state and config for the given group.
1391
+ * Called when a consumer is stopped via `stopConsumer(groupId)`.
1392
+ */
1393
+ removeGroup(gid) {
1394
+ for (const key of [...this.states.keys()]) {
1395
+ if (key.startsWith(`${gid}:`)) {
1396
+ clearTimeout(this.states.get(key).timer);
1397
+ this.states.delete(key);
1398
+ }
1399
+ }
1400
+ this.configs.delete(gid);
1401
+ }
1402
+ /** Clear all circuit state and config. Called on `disconnect()`. */
1403
+ clear() {
1404
+ for (const state of this.states.values()) clearTimeout(state.timer);
1405
+ this.states.clear();
1406
+ this.configs.clear();
1407
+ }
1408
+ };
1409
+
1410
+ // src/client/kafka.client/admin/ops.ts
1411
+ var AdminOps = class {
1412
+ constructor(deps) {
1413
+ this.deps = deps;
1414
+ }
1415
+ isConnected = false;
1416
+ /** Underlying admin client — used by index.ts for topic validation. */
1417
+ get admin() {
1418
+ return this.deps.admin;
1419
+ }
1420
+ /** Whether the admin client is currently connected. */
1421
+ get connected() {
1422
+ return this.isConnected;
1423
+ }
1424
+ /**
1425
+ * Connect the admin client if not already connected.
1426
+ * The flag is only set to `true` after a successful connect — if `admin.connect()`
1427
+ * throws the flag remains `false` so the next call will retry the connection.
1428
+ */
1429
+ async ensureConnected() {
1430
+ if (this.isConnected) return;
1431
+ try {
1432
+ await this.deps.admin.connect();
1433
+ this.isConnected = true;
1434
+ } catch (err) {
1435
+ this.isConnected = false;
1436
+ throw err;
1437
+ }
1438
+ }
1439
+ /** Disconnect admin if connected. Resets the connected flag. */
1440
+ async disconnect() {
1441
+ if (!this.isConnected) return;
1442
+ await this.deps.admin.disconnect();
1443
+ this.isConnected = false;
1444
+ }
1445
+ async resetOffsets(groupId, topic2, position) {
1446
+ const gid = groupId ?? this.deps.defaultGroupId;
1447
+ if (this.deps.runningConsumers.has(gid)) {
1448
+ throw new Error(
1449
+ `resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
1450
+ );
1451
+ }
1452
+ await this.ensureConnected();
1453
+ const partitionOffsets = await this.deps.admin.fetchTopicOffsets(topic2);
1454
+ const partitions = partitionOffsets.map(({ partition, low, high }) => ({
1455
+ partition,
1456
+ offset: position === "earliest" ? low : high
1457
+ }));
1458
+ await this.deps.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
1459
+ this.deps.logger.log(
1460
+ `Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
1461
+ );
1462
+ }
1463
+ /**
1464
+ * Seek specific topic-partition pairs to explicit offsets for a stopped consumer group.
1465
+ * Throws if the group is still running — call `stopConsumer(groupId)` first.
1466
+ * Assignments are grouped by topic and committed via `admin.setOffsets`.
1467
+ */
1468
+ async seekToOffset(groupId, assignments) {
1469
+ const gid = groupId ?? this.deps.defaultGroupId;
1470
+ if (this.deps.runningConsumers.has(gid)) {
1471
+ throw new Error(
1472
+ `seekToOffset: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
1473
+ );
1474
+ }
1475
+ await this.ensureConnected();
1476
+ const byTopic = /* @__PURE__ */ new Map();
1477
+ for (const { topic: topic2, partition, offset } of assignments) {
1478
+ const list = byTopic.get(topic2) ?? [];
1479
+ list.push({ partition, offset });
1480
+ byTopic.set(topic2, list);
1481
+ }
1482
+ for (const [topic2, partitions] of byTopic) {
1483
+ await this.deps.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
1484
+ this.deps.logger.log(
1485
+ `Offsets set for group "${gid}" on "${topic2}": ${JSON.stringify(partitions)}`
1486
+ );
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Seek specific topic-partition pairs to the offset nearest to a given timestamp
1491
+ * (in milliseconds) for a stopped consumer group.
1492
+ * Throws if the group is still running — call `stopConsumer(groupId)` first.
1493
+ * Assignments are grouped by topic and committed via `admin.setOffsets`.
1494
+ * If no offset exists at the requested timestamp (e.g. empty partition or
1495
+ * future timestamp), the partition falls back to `-1` (end of topic — new messages only).
1496
+ */
1497
+ async seekToTimestamp(groupId, assignments) {
1498
+ const gid = groupId ?? this.deps.defaultGroupId;
1499
+ if (this.deps.runningConsumers.has(gid)) {
1500
+ throw new Error(
1501
+ `seekToTimestamp: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
1502
+ );
1503
+ }
1504
+ await this.ensureConnected();
1505
+ const byTopic = /* @__PURE__ */ new Map();
1506
+ for (const { topic: topic2, partition, timestamp } of assignments) {
1507
+ const list = byTopic.get(topic2) ?? [];
1508
+ list.push({ partition, timestamp });
1509
+ byTopic.set(topic2, list);
1510
+ }
1511
+ for (const [topic2, parts] of byTopic) {
1512
+ const offsets = await Promise.all(
1513
+ parts.map(async ({ partition, timestamp }) => {
1514
+ const results = await this.deps.admin.fetchTopicOffsetsByTime(
1515
+ topic2,
1516
+ timestamp
1517
+ );
1518
+ const found = results.find(
1519
+ (r) => r.partition === partition
1520
+ );
1521
+ return { partition, offset: found?.offset ?? "-1" };
1522
+ })
1523
+ );
1524
+ await this.deps.admin.setOffsets({ groupId: gid, topic: topic2, partitions: offsets });
1525
+ this.deps.logger.log(
1526
+ `Offsets set by timestamp for group "${gid}" on "${topic2}": ${JSON.stringify(offsets)}`
1527
+ );
1528
+ }
1529
+ }
1530
+ /**
1531
+ * Query consumer group lag per partition.
1532
+ * Lag = broker high-watermark − last committed offset.
1533
+ * A committed offset of -1 (nothing committed yet) counts as full lag.
1534
+ *
1535
+ * Returns an empty array when the consumer group has never committed any
1536
+ * offsets (freshly created group, `autoCommit: false` with no manual commits,
1537
+ * or group not yet assigned). This is a Kafka protocol limitation:
1538
+ * `fetchOffsets` only returns data for topic-partitions that have at least one
1539
+ * committed offset. Use `checkStatus()` to verify broker connectivity in that case.
1540
+ */
1541
+ async getConsumerLag(groupId) {
1542
+ const gid = groupId ?? this.deps.defaultGroupId;
1543
+ await this.ensureConnected();
1544
+ const committedByTopic = await this.deps.admin.fetchOffsets({ groupId: gid });
1545
+ const brokerOffsetsAll = await Promise.all(
1546
+ committedByTopic.map(({ topic: topic2 }) => this.deps.admin.fetchTopicOffsets(topic2))
1547
+ );
1548
+ const result = [];
1549
+ for (let i = 0; i < committedByTopic.length; i++) {
1550
+ const { topic: topic2, partitions } = committedByTopic[i];
1551
+ const brokerOffsets = brokerOffsetsAll[i];
1552
+ for (const { partition, offset } of partitions) {
1553
+ const broker = brokerOffsets.find((o) => o.partition === partition);
1554
+ if (!broker) continue;
1555
+ const committed = parseInt(offset, 10);
1556
+ const high = parseInt(broker.high, 10);
1557
+ const lag = committed === -1 ? high : Math.max(0, high - committed);
1558
+ result.push({ topic: topic2, partition, lag });
1559
+ }
1560
+ }
1561
+ return result;
1562
+ }
1563
+ /** Check broker connectivity. Never throws — returns a discriminated union. */
1564
+ async checkStatus() {
1565
+ try {
1566
+ await this.ensureConnected();
1567
+ const topics = await this.deps.admin.listTopics();
1568
+ return { status: "up", clientId: this.deps.clientId, topics };
1569
+ } catch (error) {
1570
+ return {
1571
+ status: "down",
1572
+ clientId: this.deps.clientId,
1573
+ error: error instanceof Error ? error.message : String(error)
1574
+ };
1575
+ }
1576
+ }
1577
+ /**
1578
+ * List all consumer groups known to the broker.
1579
+ * Useful for monitoring which groups are active and their current state.
1580
+ */
1581
+ async listConsumerGroups() {
1582
+ await this.ensureConnected();
1583
+ const result = await this.deps.admin.listGroups();
1584
+ return result.groups.map((g) => ({
1585
+ groupId: g.groupId,
1586
+ state: g.state ?? "Unknown"
1587
+ }));
1588
+ }
1589
+ /**
1590
+ * Describe topics — returns partition layout, leader, replicas, and ISR.
1591
+ * @param topics Topic names to describe. Omit to describe all topics.
1592
+ */
1593
+ async describeTopics(topics) {
1594
+ await this.ensureConnected();
1595
+ const result = await this.deps.admin.fetchTopicMetadata(
1596
+ topics ? { topics } : void 0
1597
+ );
1598
+ return result.topics.map((t) => ({
1599
+ name: t.name,
1600
+ partitions: t.partitions.map((p) => ({
1601
+ partition: p.partitionId ?? p.partition,
1602
+ leader: p.leader,
1603
+ replicas: p.replicas.map(
1604
+ (r) => typeof r === "number" ? r : r.nodeId
1605
+ ),
1606
+ isr: p.isr.map(
1607
+ (r) => typeof r === "number" ? r : r.nodeId
1608
+ )
1609
+ }))
1610
+ }));
1611
+ }
1612
+ /**
1613
+ * Delete records from a topic up to (but not including) the given offsets.
1614
+ * All messages with offsets **before** the given offset are deleted.
1615
+ */
1616
+ async deleteRecords(topic2, partitions) {
1617
+ await this.ensureConnected();
1618
+ await this.deps.admin.deleteTopicRecords({ topic: topic2, partitions });
1619
+ }
1620
+ /**
1621
+ * When `retryTopics: true` and `autoCreateTopics: false`, verify that every
1622
+ * `<topic>.retry.<level>` topic already exists. Throws a clear error at startup
1623
+ * rather than silently discovering missing topics on the first handler failure.
1624
+ */
1625
+ async validateRetryTopicsExist(topicNames, maxRetries) {
1626
+ await this.ensureConnected();
1627
+ const existing = new Set(await this.deps.admin.listTopics());
1628
+ const missing = [];
1629
+ for (const t of topicNames) {
1630
+ for (let level = 1; level <= maxRetries; level++) {
1631
+ const retryTopic = `${t}.retry.${level}`;
1632
+ if (!existing.has(retryTopic)) missing.push(retryTopic);
1633
+ }
1634
+ }
1635
+ if (missing.length > 0) {
1636
+ throw new Error(
1637
+ `retryTopics: true but the following retry topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
1638
+ );
1639
+ }
1640
+ }
1641
+ /**
1642
+ * When `autoCreateTopics` is disabled, verify that `<topic>.dlq` exists for every
1643
+ * consumed topic. Throws a clear error at startup rather than silently discovering
1644
+ * missing DLQ topics on the first handler failure.
1645
+ */
1646
+ async validateDlqTopicsExist(topicNames) {
1647
+ await this.ensureConnected();
1648
+ const existing = new Set(await this.deps.admin.listTopics());
1649
+ const missing = topicNames.filter((t) => !existing.has(`${t}.dlq`)).map((t) => `${t}.dlq`);
1650
+ if (missing.length > 0) {
1651
+ throw new Error(
1652
+ `dlq: true but the following DLQ topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
1653
+ );
1654
+ }
1655
+ }
1656
+ /**
1657
+ * When `deduplication.strategy: 'topic'` and `autoCreateTopics: false`, verify
1658
+ * that every `<topic>.duplicates` destination topic already exists. Throws a
1659
+ * clear error at startup rather than silently dropping duplicates on first hit.
1660
+ */
1661
+ async validateDuplicatesTopicsExist(topicNames, customDestination) {
1662
+ await this.ensureConnected();
1663
+ const existing = new Set(await this.deps.admin.listTopics());
1664
+ const toCheck = customDestination ? [customDestination] : topicNames.map((t) => `${t}.duplicates`);
1665
+ const missing = toCheck.filter((t) => !existing.has(t));
1666
+ if (missing.length > 0) {
1667
+ throw new Error(
1668
+ `deduplication.strategy: 'topic' but the following duplicate-routing topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
1669
+ );
1670
+ }
1671
+ }
1672
+ };
1673
+
1674
+ // src/client/kafka.client/consumer/dlq-replay.ts
1675
+ async function replayDlqTopic(topic2, options = {}, deps) {
1676
+ const dlqTopic = `${topic2}.dlq`;
1677
+ const partitionOffsets = await deps.fetchTopicOffsets(dlqTopic);
1678
+ const activePartitions = partitionOffsets.filter((p) => parseInt(p.high, 10) > 0);
1679
+ if (activePartitions.length === 0) {
1680
+ deps.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
1681
+ return { replayed: 0, skipped: 0 };
1682
+ }
1683
+ const highWatermarks = new Map(
1684
+ activePartitions.map(({ partition, high }) => [partition, parseInt(high, 10)])
1685
+ );
1686
+ const processedOffsets = /* @__PURE__ */ new Map();
1687
+ let replayed = 0;
1688
+ let skipped = 0;
1689
+ const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
1690
+ await new Promise((resolve, reject) => {
1691
+ const consumer = deps.createConsumer(tempGroupId);
1692
+ const cleanup = () => deps.cleanupConsumer(consumer, tempGroupId);
1693
+ consumer.connect().then(() => subscribeWithRetry(consumer, [dlqTopic], deps.logger)).then(
1694
+ () => consumer.run({
1695
+ eachMessage: async ({ partition, message }) => {
1696
+ if (!message.value) return;
1697
+ const offset = parseInt(message.offset, 10);
1698
+ processedOffsets.set(partition, offset);
1699
+ const headers = decodeHeaders(message.headers);
1700
+ const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
1701
+ const originalHeaders = Object.fromEntries(
1702
+ Object.entries(headers).filter(([k]) => !deps.dlqHeaderKeys.has(k))
1703
+ );
1704
+ const value = message.value.toString();
1705
+ const shouldProcess = !options.filter || options.filter(headers, value);
1706
+ if (!targetTopic || !shouldProcess) {
1707
+ skipped++;
1708
+ } else if (options.dryRun) {
1709
+ deps.logger.log(`[DLQ replay dry-run] Would replay to "${targetTopic}"`);
1710
+ replayed++;
1711
+ } else {
1712
+ await deps.send(targetTopic, [{ value, headers: originalHeaders }]);
1713
+ replayed++;
1714
+ }
1715
+ const allDone = Array.from(highWatermarks.entries()).every(
1716
+ ([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
1717
+ );
1718
+ if (allDone) {
1719
+ cleanup();
1720
+ resolve();
1721
+ }
1722
+ }
1723
+ })
1724
+ ).catch((err) => {
1725
+ cleanup();
1726
+ reject(err);
1727
+ });
1728
+ });
1729
+ deps.logger.log(`replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`);
1730
+ return { replayed, skipped };
1731
+ }
1732
+
1733
+ // src/client/kafka.client/infra/metrics-manager.ts
1734
+ var MetricsManager = class {
1735
+ constructor(deps) {
1736
+ this.deps = deps;
1737
+ }
1738
+ topicMetrics = /* @__PURE__ */ new Map();
1739
+ metricsFor(topic2) {
1740
+ let m = this.topicMetrics.get(topic2);
1741
+ if (!m) {
1742
+ m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1743
+ this.topicMetrics.set(topic2, m);
1744
+ }
1745
+ return m;
1746
+ }
1747
+ /** Fire `afterSend` instrumentation hooks for each message in a batch. */
1748
+ notifyAfterSend(topic2, count) {
1749
+ for (let i = 0; i < count; i++)
1750
+ for (const inst of this.deps.instrumentation) inst.afterSend?.(topic2);
1751
+ }
1752
+ notifyRetry(envelope, attempt, maxRetries) {
1753
+ this.metricsFor(envelope.topic).retryCount++;
1754
+ for (const inst of this.deps.instrumentation) inst.onRetry?.(envelope, attempt, maxRetries);
1755
+ }
1756
+ notifyDlq(envelope, reason, gid) {
1757
+ this.metricsFor(envelope.topic).dlqCount++;
1758
+ for (const inst of this.deps.instrumentation) inst.onDlq?.(envelope, reason);
1759
+ if (gid) this.deps.onCircuitFailure(envelope, gid);
1760
+ }
1761
+ notifyDuplicate(envelope, strategy) {
1762
+ this.metricsFor(envelope.topic).dedupCount++;
1763
+ for (const inst of this.deps.instrumentation) inst.onDuplicate?.(envelope, strategy);
1764
+ }
1765
+ notifyMessage(envelope, gid) {
1766
+ this.metricsFor(envelope.topic).processedCount++;
1767
+ for (const inst of this.deps.instrumentation) inst.onMessage?.(envelope);
1768
+ if (gid) this.deps.onCircuitSuccess(envelope, gid);
1769
+ }
1770
+ getMetrics(topic2) {
1771
+ if (topic2 !== void 0) {
1772
+ const m = this.topicMetrics.get(topic2);
1773
+ return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1774
+ }
1775
+ const agg = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
1776
+ for (const m of this.topicMetrics.values()) {
1777
+ agg.processedCount += m.processedCount;
1778
+ agg.retryCount += m.retryCount;
1779
+ agg.dlqCount += m.dlqCount;
1780
+ agg.dedupCount += m.dedupCount;
1781
+ }
1782
+ return agg;
1783
+ }
1784
+ resetMetrics(topic2) {
1785
+ if (topic2 !== void 0) {
1786
+ this.topicMetrics.delete(topic2);
1787
+ return;
1788
+ }
1789
+ this.topicMetrics.clear();
1790
+ }
1791
+ };
1792
+
1793
+ // src/client/kafka.client/infra/inflight-tracker.ts
1794
+ var InFlightTracker = class {
1795
+ constructor(warn) {
1796
+ this.warn = warn;
1797
+ }
1798
+ inFlightTotal = 0;
1799
+ drainResolvers = [];
1800
+ track(fn) {
1801
+ this.inFlightTotal++;
1802
+ return fn().finally(() => {
1803
+ this.inFlightTotal--;
1804
+ if (this.inFlightTotal === 0) this.drainResolvers.splice(0).forEach((r) => r());
1805
+ });
1806
+ }
1807
+ waitForDrain(timeoutMs) {
1808
+ if (this.inFlightTotal === 0) return Promise.resolve();
1809
+ return new Promise((resolve) => {
1810
+ let handle;
1811
+ const onDrain = () => {
1812
+ clearTimeout(handle);
1813
+ resolve();
1814
+ };
1815
+ this.drainResolvers.push(onDrain);
1816
+ handle = setTimeout(() => {
1817
+ const idx = this.drainResolvers.indexOf(onDrain);
1818
+ if (idx !== -1) this.drainResolvers.splice(idx, 1);
1819
+ this.warn(
1820
+ `Drain timed out after ${timeoutMs}ms \u2014 ${this.inFlightTotal} handler(s) still in flight`
1821
+ );
1822
+ resolve();
1823
+ }, timeoutMs);
1824
+ });
1825
+ }
1826
+ };
1827
+
1828
+ // src/client/kafka.client/index.ts
1829
+ var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript2.KafkaJS;
1830
+ var _activeTransactionalIds = /* @__PURE__ */ new Set();
1277
1831
  var KafkaClient = class _KafkaClient {
1278
1832
  kafka;
1279
1833
  producer;
@@ -1282,7 +1836,6 @@ var KafkaClient = class _KafkaClient {
1282
1836
  /** Maps transactionalId → Producer for each active retry level consumer. */
1283
1837
  retryTxProducers = /* @__PURE__ */ new Map();
1284
1838
  consumers = /* @__PURE__ */ new Map();
1285
- admin;
1286
1839
  logger;
1287
1840
  autoCreateTopicsEnabled;
1288
1841
  strictSchemasEnabled;
@@ -1302,20 +1855,26 @@ var KafkaClient = class _KafkaClient {
1302
1855
  onRebalance;
1303
1856
  /** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
1304
1857
  txId;
1305
- /** Per-topic event counters, lazily created on first event. Aggregated by `getMetrics()`. */
1306
- _topicMetrics = /* @__PURE__ */ new Map();
1307
1858
  /** Monotonically increasing Lamport clock stamped on every outgoing message. */
1308
1859
  _lamportClock = 0;
1309
1860
  /** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
1310
1861
  dedupStates = /* @__PURE__ */ new Map();
1311
- /** Circuit breaker state per `"${gid}:${topic}:${partition}"` key. */
1312
- circuitStates = /* @__PURE__ */ new Map();
1313
- /** Circuit breaker config per groupId, set at startConsumer/startBatchConsumer time. */
1314
- circuitConfigs = /* @__PURE__ */ new Map();
1315
- isAdminConnected = false;
1316
- inFlightTotal = 0;
1317
- drainResolvers = [];
1862
+ circuitBreaker;
1863
+ adminOps;
1864
+ metrics;
1865
+ inFlight;
1318
1866
  clientId;
1867
+ _producerOpsDeps;
1868
+ _consumerOpsDeps;
1869
+ _retryTopicDeps;
1870
+ /** DLQ header keys added by the pipeline — stripped before re-publishing. */
1871
+ static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
1872
+ "x-dlq-original-topic",
1873
+ "x-dlq-failed-at",
1874
+ "x-dlq-error-message",
1875
+ "x-dlq-error-stack",
1876
+ "x-dlq-attempt-count"
1877
+ ]);
1319
1878
  constructor(clientId, groupId, brokers, options) {
1320
1879
  this.clientId = clientId;
1321
1880
  this.defaultGroupId = groupId;
@@ -1340,12 +1899,41 @@ var KafkaClient = class _KafkaClient {
1340
1899
  logLevel: KafkaLogLevel.ERROR
1341
1900
  }
1342
1901
  });
1343
- this.producer = this.kafka.producer({
1344
- kafkaJS: {
1345
- acks: -1
1346
- }
1902
+ this.producer = this.kafka.producer({ kafkaJS: { acks: -1 } });
1903
+ this.adminOps = new AdminOps({
1904
+ admin: this.kafka.admin(),
1905
+ logger: this.logger,
1906
+ runningConsumers: this.runningConsumers,
1907
+ defaultGroupId: this.defaultGroupId,
1908
+ clientId: this.clientId
1909
+ });
1910
+ this.circuitBreaker = new CircuitBreakerManager({
1911
+ pauseConsumer: (gid, assignments) => this.pauseConsumer(gid, assignments),
1912
+ resumeConsumer: (gid, assignments) => this.resumeConsumer(gid, assignments),
1913
+ logger: this.logger,
1914
+ instrumentation: this.instrumentation
1915
+ });
1916
+ this.metrics = new MetricsManager({
1917
+ instrumentation: this.instrumentation,
1918
+ onCircuitFailure: (envelope, gid) => this.circuitBreaker.onFailure(envelope, gid),
1919
+ onCircuitSuccess: (envelope, gid) => this.circuitBreaker.onSuccess(envelope, gid)
1347
1920
  });
1348
- this.admin = this.kafka.admin();
1921
+ this.inFlight = new InFlightTracker((msg) => this.logger.warn(msg));
1922
+ this._producerOpsDeps = {
1923
+ schemaRegistry: this.schemaRegistry,
1924
+ strictSchemasEnabled: this.strictSchemasEnabled,
1925
+ instrumentation: this.instrumentation,
1926
+ logger: this.logger,
1927
+ nextLamportClock: () => ++this._lamportClock
1928
+ };
1929
+ this._consumerOpsDeps = {
1930
+ consumers: this.consumers,
1931
+ consumerCreationOptions: this.consumerCreationOptions,
1932
+ kafka: this.kafka,
1933
+ onRebalance: this.onRebalance,
1934
+ logger: this.logger
1935
+ };
1936
+ this._retryTopicDeps = this.buildRetryTopicDeps();
1349
1937
  }
1350
1938
  async sendMessage(topicOrDesc, message, options = {}) {
1351
1939
  const payload = await this.preparePayload(
@@ -1363,7 +1951,7 @@ var KafkaClient = class _KafkaClient {
1363
1951
  options.compression
1364
1952
  );
1365
1953
  await this.producer.send(payload);
1366
- this.notifyAfterSend(payload.topic, payload.messages.length);
1954
+ this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
1367
1955
  }
1368
1956
  /**
1369
1957
  * Send a null-value (tombstone) message. Used with log-compacted topics to signal
@@ -1378,26 +1966,15 @@ var KafkaClient = class _KafkaClient {
1378
1966
  */
1379
1967
  async sendTombstone(topic2, key, headers) {
1380
1968
  const hdrs = { ...headers };
1381
- for (const inst of this.instrumentation) {
1382
- inst.beforeSend?.(topic2, hdrs);
1383
- }
1969
+ for (const inst of this.instrumentation) inst.beforeSend?.(topic2, hdrs);
1384
1970
  await this.ensureTopic(topic2);
1385
- await this.producer.send({
1386
- topic: topic2,
1387
- messages: [{ value: null, key, headers: hdrs }]
1388
- });
1389
- for (const inst of this.instrumentation) {
1390
- inst.afterSend?.(topic2);
1391
- }
1971
+ await this.producer.send({ topic: topic2, messages: [{ value: null, key, headers: hdrs }] });
1972
+ for (const inst of this.instrumentation) inst.afterSend?.(topic2);
1392
1973
  }
1393
1974
  async sendBatch(topicOrDesc, messages, options) {
1394
- const payload = await this.preparePayload(
1395
- topicOrDesc,
1396
- messages,
1397
- options?.compression
1398
- );
1975
+ const payload = await this.preparePayload(topicOrDesc, messages, options?.compression);
1399
1976
  await this.producer.send(payload);
1400
- this.notifyAfterSend(payload.topic, payload.messages.length);
1977
+ this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
1401
1978
  }
1402
1979
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
1403
1980
  async transaction(fn) {
@@ -1409,12 +1986,7 @@ var KafkaClient = class _KafkaClient {
1409
1986
  }
1410
1987
  const initPromise = (async () => {
1411
1988
  const p = this.kafka.producer({
1412
- kafkaJS: {
1413
- acks: -1,
1414
- idempotent: true,
1415
- transactionalId: this.txId,
1416
- maxInFlightRequests: 1
1417
- }
1989
+ kafkaJS: { acks: -1, idempotent: true, transactionalId: this.txId, maxInFlightRequests: 1 }
1418
1990
  });
1419
1991
  await p.connect();
1420
1992
  _activeTransactionalIds.add(this.txId);
@@ -1441,19 +2013,12 @@ var KafkaClient = class _KafkaClient {
1441
2013
  }
1442
2014
  ]);
1443
2015
  await tx.send(payload);
1444
- this.notifyAfterSend(payload.topic, payload.messages.length);
2016
+ this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
1445
2017
  },
1446
- /**
1447
- * Send multiple messages in a single call to the topic.
1448
- * All messages in the batch will be sent atomically.
1449
- * If any message fails to send, the entire batch will be aborted.
1450
- * @param topicOrDesc - topic name or TopicDescriptor
1451
- * @param messages - array of messages to send with optional key, headers, correlationId, schemaVersion, and eventId
1452
- */
1453
2018
  sendBatch: async (topicOrDesc, messages) => {
1454
2019
  const payload = await this.preparePayload(topicOrDesc, messages);
1455
2020
  await tx.send(payload);
1456
- this.notifyAfterSend(payload.topic, payload.messages.length);
2021
+ this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
1457
2022
  }
1458
2023
  };
1459
2024
  await fn(ctx);
@@ -1462,10 +2027,7 @@ var KafkaClient = class _KafkaClient {
1462
2027
  try {
1463
2028
  await tx.abort();
1464
2029
  } catch (abortError) {
1465
- this.logger.error(
1466
- "Failed to abort transaction:",
1467
- toError(abortError).message
1468
- );
2030
+ this.logger.error("Failed to abort transaction:", toError(abortError).message);
1469
2031
  }
1470
2032
  throw error;
1471
2033
  }
@@ -1487,35 +2049,14 @@ var KafkaClient = class _KafkaClient {
1487
2049
  this.logger.log("Producer disconnected");
1488
2050
  }
1489
2051
  async startConsumer(topics, handleMessage, options = {}) {
1490
- if (options.retryTopics && !options.retry) {
1491
- throw new Error(
1492
- "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
1493
- );
1494
- }
1495
- const hasRegexTopics = topics.some((t) => t instanceof RegExp);
1496
- if (options.retryTopics && hasRegexTopics) {
1497
- throw new Error(
1498
- "retryTopics is incompatible with regex topic patterns \u2014 retry topics require a fixed topic name to build the retry chain."
1499
- );
1500
- }
2052
+ this.validateTopicConsumerOpts(topics, options);
1501
2053
  const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
1502
2054
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", setupOptions);
1503
- if (options.circuitBreaker)
1504
- this.circuitConfigs.set(gid, options.circuitBreaker);
2055
+ if (options.circuitBreaker) this.circuitBreaker.setConfig(gid, options.circuitBreaker);
1505
2056
  const deps = this.messageDepsFor(gid);
1506
- const timeoutMs = options.handlerTimeoutMs;
1507
- const deduplication = this.resolveDeduplicationContext(
1508
- gid,
1509
- options.deduplication
1510
- );
1511
- let eosMainContext;
1512
- if (options.retryTopics && retry) {
1513
- const mainTxId = `${gid}-main-tx`;
1514
- const txProducer = await this.createRetryTxProducer(mainTxId);
1515
- eosMainContext = { txProducer, consumer };
1516
- }
2057
+ const eosMainContext = await this.makeEosMainContext(gid, consumer, options);
1517
2058
  await consumer.run({
1518
- eachMessage: (payload) => this.trackInFlight(
2059
+ eachMessage: (payload) => this.inFlight.track(
1519
2060
  () => handleEachMessage(
1520
2061
  payload,
1521
2062
  {
@@ -1525,9 +2066,9 @@ var KafkaClient = class _KafkaClient {
1525
2066
  dlq,
1526
2067
  retry,
1527
2068
  retryTopics: options.retryTopics,
1528
- timeoutMs,
2069
+ timeoutMs: options.handlerTimeoutMs,
1529
2070
  wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1530
- deduplication,
2071
+ deduplication: this.resolveDeduplicationContext(gid, options.deduplication),
1531
2072
  messageTtlMs: options.messageTtlMs,
1532
2073
  onTtlExpired: options.onTtlExpired,
1533
2074
  eosMainContext
@@ -1538,70 +2079,24 @@ var KafkaClient = class _KafkaClient {
1538
2079
  });
1539
2080
  this.runningConsumers.set(gid, "eachMessage");
1540
2081
  if (options.retryTopics && retry) {
1541
- if (!this.autoCreateTopicsEnabled) {
1542
- await this.validateRetryTopicsExist(topicNames, retry.maxRetries);
1543
- }
1544
- const companions = await startRetryTopicConsumers(
1545
- topicNames,
1546
- gid,
1547
- handleMessage,
1548
- retry,
1549
- dlq,
1550
- interceptors,
1551
- schemaMap,
1552
- this.retryTopicDeps,
1553
- options.retryTopicAssignmentTimeoutMs
1554
- );
1555
- this.companionGroupIds.set(gid, companions);
2082
+ await this.launchRetryChain(gid, topicNames, handleMessage, retry, dlq, interceptors, schemaMap, options.retryTopicAssignmentTimeoutMs);
1556
2083
  }
1557
2084
  return { groupId: gid, stop: () => this.stopConsumer(gid) };
1558
2085
  }
1559
2086
  async startBatchConsumer(topics, handleBatch, options = {}) {
1560
- if (options.retryTopics && !options.retry) {
1561
- throw new Error(
1562
- "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
1563
- );
1564
- }
1565
- const hasRegexTopics = topics.some((t) => t instanceof RegExp);
1566
- if (options.retryTopics && hasRegexTopics) {
1567
- throw new Error(
1568
- "retryTopics is incompatible with regex topic patterns \u2014 retry topics require a fixed topic name to build the retry chain."
1569
- );
1570
- }
1571
- if (options.retryTopics) {
1572
- } else if (options.autoCommit !== false) {
2087
+ this.validateTopicConsumerOpts(topics, options);
2088
+ if (!options.retryTopics && options.autoCommit !== false) {
1573
2089
  this.logger.debug?.(
1574
2090
  `startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
1575
2091
  );
1576
2092
  }
1577
2093
  const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
1578
2094
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", setupOptions);
1579
- if (options.circuitBreaker)
1580
- this.circuitConfigs.set(gid, options.circuitBreaker);
2095
+ if (options.circuitBreaker) this.circuitBreaker.setConfig(gid, options.circuitBreaker);
1581
2096
  const deps = this.messageDepsFor(gid);
1582
- const timeoutMs = options.handlerTimeoutMs;
1583
- const deduplication = this.resolveDeduplicationContext(
1584
- gid,
1585
- options.deduplication
1586
- );
1587
- let eosMainContext;
1588
- if (options.retryTopics && retry) {
1589
- const mainTxId = `${gid}-main-tx`;
1590
- const txProducer = await this.createRetryTxProducer(mainTxId);
1591
- eosMainContext = { txProducer, consumer };
1592
- }
2097
+ const eosMainContext = await this.makeEosMainContext(gid, consumer, options);
1593
2098
  await consumer.run({
1594
- /**
1595
- * eachBatch: called by the consumer for each batch of messages.
1596
- * Called with the `payload` argument, which is an object containing the
1597
- * batch of messages and a `BatchMeta` object with offset management controls.
1598
- *
1599
- * The function is wrapped with `trackInFlight` and `handleEachBatch` to provide
1600
- * error handling and offset management.
1601
- *
1602
- * @param payload - an object containing the batch of messages and a `BatchMeta` object.
1603
- */
1604
- eachBatch: (payload) => this.trackInFlight(
2099
+ eachBatch: (payload) => this.inFlight.track(
1605
2100
  () => handleEachBatch(
1606
2101
  payload,
1607
2102
  {
@@ -1611,9 +2106,9 @@ var KafkaClient = class _KafkaClient {
1611
2106
  dlq,
1612
2107
  retry,
1613
2108
  retryTopics: options.retryTopics,
1614
- timeoutMs,
2109
+ timeoutMs: options.handlerTimeoutMs,
1615
2110
  wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
1616
- deduplication,
2111
+ deduplication: this.resolveDeduplicationContext(gid, options.deduplication),
1617
2112
  messageTtlMs: options.messageTtlMs,
1618
2113
  onTtlExpired: options.onTtlExpired,
1619
2114
  eosMainContext
@@ -1624,9 +2119,6 @@ var KafkaClient = class _KafkaClient {
1624
2119
  });
1625
2120
  this.runningConsumers.set(gid, "eachBatch");
1626
2121
  if (options.retryTopics && retry) {
1627
- if (!this.autoCreateTopicsEnabled) {
1628
- await this.validateRetryTopicsExist(topicNames, retry.maxRetries);
1629
- }
1630
2122
  const handleMessageForRetry = (env) => handleBatch([env], {
1631
2123
  partition: env.partition,
1632
2124
  highWatermark: null,
@@ -1637,18 +2129,7 @@ var KafkaClient = class _KafkaClient {
1637
2129
  commitOffsetsIfNecessary: async () => {
1638
2130
  }
1639
2131
  });
1640
- const companions = await startRetryTopicConsumers(
1641
- topicNames,
1642
- gid,
1643
- handleMessageForRetry,
1644
- retry,
1645
- dlq,
1646
- interceptors,
1647
- schemaMap,
1648
- this.retryTopicDeps,
1649
- options.retryTopicAssignmentTimeoutMs
1650
- );
1651
- this.companionGroupIds.set(gid, companions);
2132
+ await this.launchRetryChain(gid, topicNames, handleMessageForRetry, retry, dlq, interceptors, schemaMap, options.retryTopicAssignmentTimeoutMs);
1652
2133
  }
1653
2134
  return { groupId: gid, stop: () => this.stopConsumer(gid) };
1654
2135
  }
@@ -1707,38 +2188,20 @@ var KafkaClient = class _KafkaClient {
1707
2188
  if (groupId !== void 0) {
1708
2189
  const consumer = this.consumers.get(groupId);
1709
2190
  if (!consumer) {
1710
- this.logger.warn(
1711
- `stopConsumer: no active consumer for group "${groupId}"`
1712
- );
2191
+ this.logger.warn(`stopConsumer: no active consumer for group "${groupId}"`);
1713
2192
  return;
1714
2193
  }
1715
- await consumer.disconnect().catch(
1716
- (e) => this.logger.warn(
1717
- `Error disconnecting consumer "${groupId}":`,
1718
- toError(e).message
1719
- )
1720
- );
2194
+ await consumer.disconnect().catch((e) => this.logger.warn(`Error disconnecting consumer "${groupId}":`, toError(e).message));
1721
2195
  this.consumers.delete(groupId);
1722
2196
  this.runningConsumers.delete(groupId);
1723
2197
  this.consumerCreationOptions.delete(groupId);
1724
2198
  this.dedupStates.delete(groupId);
1725
- for (const key of [...this.circuitStates.keys()]) {
1726
- if (key.startsWith(`${groupId}:`)) {
1727
- clearTimeout(this.circuitStates.get(key).timer);
1728
- this.circuitStates.delete(key);
1729
- }
1730
- }
1731
- this.circuitConfigs.delete(groupId);
2199
+ this.circuitBreaker.removeGroup(groupId);
1732
2200
  this.logger.log(`Consumer disconnected: group "${groupId}"`);
1733
2201
  const mainTxId = `${groupId}-main-tx`;
1734
2202
  const mainTxProducer = this.retryTxProducers.get(mainTxId);
1735
2203
  if (mainTxProducer) {
1736
- await mainTxProducer.disconnect().catch(
1737
- (e) => this.logger.warn(
1738
- `Error disconnecting main tx producer "${mainTxId}":`,
1739
- toError(e).message
1740
- )
1741
- );
2204
+ await mainTxProducer.disconnect().catch((e) => this.logger.warn(`Error disconnecting main tx producer "${mainTxId}":`, toError(e).message));
1742
2205
  _activeTransactionalIds.delete(mainTxId);
1743
2206
  this.retryTxProducers.delete(mainTxId);
1744
2207
  }
@@ -1746,12 +2209,7 @@ var KafkaClient = class _KafkaClient {
1746
2209
  for (const cGroupId of companions) {
1747
2210
  const cConsumer = this.consumers.get(cGroupId);
1748
2211
  if (cConsumer) {
1749
- await cConsumer.disconnect().catch(
1750
- (e) => this.logger.warn(
1751
- `Error disconnecting retry consumer "${cGroupId}":`,
1752
- toError(e).message
1753
- )
1754
- );
2212
+ await cConsumer.disconnect().catch((e) => this.logger.warn(`Error disconnecting retry consumer "${cGroupId}":`, toError(e).message));
1755
2213
  this.consumers.delete(cGroupId);
1756
2214
  this.runningConsumers.delete(cGroupId);
1757
2215
  this.consumerCreationOptions.delete(cGroupId);
@@ -1760,12 +2218,7 @@ var KafkaClient = class _KafkaClient {
1760
2218
  const txId = `${cGroupId}-tx`;
1761
2219
  const txProducer = this.retryTxProducers.get(txId);
1762
2220
  if (txProducer) {
1763
- await txProducer.disconnect().catch(
1764
- (e) => this.logger.warn(
1765
- `Error disconnecting retry tx producer "${txId}":`,
1766
- toError(e).message
1767
- )
1768
- );
2221
+ await txProducer.disconnect().catch((e) => this.logger.warn(`Error disconnecting retry tx producer "${txId}":`, toError(e).message));
1769
2222
  _activeTransactionalIds.delete(txId);
1770
2223
  this.retryTxProducers.delete(txId);
1771
2224
  }
@@ -1773,14 +2226,10 @@ var KafkaClient = class _KafkaClient {
1773
2226
  this.companionGroupIds.delete(groupId);
1774
2227
  } else {
1775
2228
  const tasks = [
1776
- ...Array.from(this.consumers.values()).map(
1777
- (c) => c.disconnect().catch(() => {
1778
- })
1779
- ),
1780
- ...Array.from(this.retryTxProducers.values()).map(
1781
- (p) => p.disconnect().catch(() => {
1782
- })
1783
- )
2229
+ ...Array.from(this.consumers.values()).map((c) => c.disconnect().catch(() => {
2230
+ })),
2231
+ ...Array.from(this.retryTxProducers.values()).map((p) => p.disconnect().catch(() => {
2232
+ }))
1784
2233
  ];
1785
2234
  await Promise.allSettled(tasks);
1786
2235
  this.consumers.clear();
@@ -1789,10 +2238,7 @@ var KafkaClient = class _KafkaClient {
1789
2238
  this.companionGroupIds.clear();
1790
2239
  this.retryTxProducers.clear();
1791
2240
  this.dedupStates.clear();
1792
- for (const state of this.circuitStates.values())
1793
- clearTimeout(state.timer);
1794
- this.circuitStates.clear();
1795
- this.circuitConfigs.clear();
2241
+ this.circuitBreaker.clear();
1796
2242
  this.logger.log("All consumers disconnected");
1797
2243
  }
1798
2244
  }
@@ -1810,9 +2256,7 @@ var KafkaClient = class _KafkaClient {
1810
2256
  return;
1811
2257
  }
1812
2258
  consumer.pause(
1813
- assignments.flatMap(
1814
- ({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
1815
- )
2259
+ assignments.flatMap(({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] })))
1816
2260
  );
1817
2261
  }
1818
2262
  /**
@@ -1829,9 +2273,7 @@ var KafkaClient = class _KafkaClient {
1829
2273
  return;
1830
2274
  }
1831
2275
  consumer.resume(
1832
- assignments.flatMap(
1833
- ({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
1834
- )
2276
+ assignments.flatMap(({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] })))
1835
2277
  );
1836
2278
  }
1837
2279
  /** Pause all assigned partitions of a topic for a consumer group (used for queue backpressure). */
@@ -1852,14 +2294,6 @@ var KafkaClient = class _KafkaClient {
1852
2294
  if (partitions.length > 0)
1853
2295
  consumer.resume(partitions.map((p) => ({ topic: topic2, partitions: [p] })));
1854
2296
  }
1855
- /** DLQ header keys added by `sendToDlq` — stripped before re-publishing. */
1856
- static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
1857
- "x-dlq-original-topic",
1858
- "x-dlq-failed-at",
1859
- "x-dlq-error-message",
1860
- "x-dlq-error-stack",
1861
- "x-dlq-attempt-count"
1862
- ]);
1863
2297
  /**
1864
2298
  * Re-publish messages from a dead letter queue back to the original topic.
1865
2299
  *
@@ -1872,106 +2306,27 @@ var KafkaClient = class _KafkaClient {
1872
2306
  * @returns { replayed: number; skipped: number } - counts of re-published vs skipped messages
1873
2307
  */
1874
2308
  async replayDlq(topic2, options = {}) {
1875
- const dlqTopic = `${topic2}.dlq`;
1876
- await this.ensureAdminConnected();
1877
- const partitionOffsets = await this.admin.fetchTopicOffsets(dlqTopic);
1878
- const activePartitions = partitionOffsets.filter(
1879
- (p) => parseInt(p.high, 10) > 0
1880
- );
1881
- if (activePartitions.length === 0) {
1882
- this.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
1883
- return { replayed: 0, skipped: 0 };
1884
- }
1885
- const highWatermarks = new Map(
1886
- activePartitions.map(({ partition, high }) => [
1887
- partition,
1888
- parseInt(high, 10)
1889
- ])
1890
- );
1891
- const processedOffsets = /* @__PURE__ */ new Map();
1892
- let replayed = 0;
1893
- let skipped = 0;
1894
- const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
1895
- await new Promise((resolve, reject) => {
1896
- const consumer = getOrCreateConsumer(
1897
- tempGroupId,
1898
- true,
1899
- true,
1900
- this.consumerOpsDeps
1901
- );
1902
- const cleanup = () => {
2309
+ await this.adminOps.ensureConnected();
2310
+ return replayDlqTopic(topic2, options, {
2311
+ logger: this.logger,
2312
+ fetchTopicOffsets: (t) => this.adminOps.admin.fetchTopicOffsets(t),
2313
+ send: async (t, messages) => {
2314
+ await this.producer.send({ topic: t, messages });
2315
+ },
2316
+ createConsumer: (gid) => getOrCreateConsumer(gid, true, true, this._consumerOpsDeps),
2317
+ cleanupConsumer: (consumer, gid) => {
1903
2318
  consumer.disconnect().catch(() => {
1904
2319
  }).finally(() => {
1905
- this.consumers.delete(tempGroupId);
1906
- this.runningConsumers.delete(tempGroupId);
1907
- this.consumerCreationOptions.delete(tempGroupId);
2320
+ this.consumers.delete(gid);
2321
+ this.runningConsumers.delete(gid);
2322
+ this.consumerCreationOptions.delete(gid);
1908
2323
  });
1909
- };
1910
- consumer.connect().then(() => subscribeWithRetry(consumer, [dlqTopic], this.logger)).then(
1911
- () => consumer.run({
1912
- eachMessage: async ({ partition, message }) => {
1913
- if (!message.value) return;
1914
- const offset = parseInt(message.offset, 10);
1915
- processedOffsets.set(partition, offset);
1916
- const headers = decodeHeaders(message.headers);
1917
- const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
1918
- const originalHeaders = Object.fromEntries(
1919
- Object.entries(headers).filter(
1920
- ([k]) => !_KafkaClient.DLQ_HEADER_KEYS.has(k)
1921
- )
1922
- );
1923
- const value = message.value.toString();
1924
- const shouldProcess = !options.filter || options.filter(headers, value);
1925
- if (!targetTopic || !shouldProcess) {
1926
- skipped++;
1927
- } else if (options.dryRun) {
1928
- this.logger.log(
1929
- `[DLQ replay dry-run] Would replay to "${targetTopic}"`
1930
- );
1931
- replayed++;
1932
- } else {
1933
- await this.producer.send({
1934
- topic: targetTopic,
1935
- messages: [{ value, headers: originalHeaders }]
1936
- });
1937
- replayed++;
1938
- }
1939
- const allDone = Array.from(highWatermarks.entries()).every(
1940
- ([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
1941
- );
1942
- if (allDone) {
1943
- cleanup();
1944
- resolve();
1945
- }
1946
- }
1947
- })
1948
- ).catch((err) => {
1949
- cleanup();
1950
- reject(err);
1951
- });
2324
+ },
2325
+ dlqHeaderKeys: _KafkaClient.DLQ_HEADER_KEYS
1952
2326
  });
1953
- this.logger.log(
1954
- `replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
1955
- );
1956
- return { replayed, skipped };
1957
2327
  }
1958
2328
  async resetOffsets(groupId, topic2, position) {
1959
- const gid = groupId ?? this.defaultGroupId;
1960
- if (this.runningConsumers.has(gid)) {
1961
- throw new Error(
1962
- `resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
1963
- );
1964
- }
1965
- await this.ensureAdminConnected();
1966
- const partitionOffsets = await this.admin.fetchTopicOffsets(topic2);
1967
- const partitions = partitionOffsets.map(({ partition, low, high }) => ({
1968
- partition,
1969
- offset: position === "earliest" ? low : high
1970
- }));
1971
- await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
1972
- this.logger.log(
1973
- `Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
1974
- );
2329
+ return this.adminOps.resetOffsets(groupId, topic2, position);
1975
2330
  }
1976
2331
  /**
1977
2332
  * Seek specific topic-partition pairs to explicit offsets for a stopped consumer group.
@@ -1979,25 +2334,7 @@ var KafkaClient = class _KafkaClient {
1979
2334
  * Assignments are grouped by topic and committed via `admin.setOffsets`.
1980
2335
  */
1981
2336
  async seekToOffset(groupId, assignments) {
1982
- const gid = groupId ?? this.defaultGroupId;
1983
- if (this.runningConsumers.has(gid)) {
1984
- throw new Error(
1985
- `seekToOffset: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
1986
- );
1987
- }
1988
- await this.ensureAdminConnected();
1989
- const byTopic = /* @__PURE__ */ new Map();
1990
- for (const { topic: topic2, partition, offset } of assignments) {
1991
- const list = byTopic.get(topic2) ?? [];
1992
- list.push({ partition, offset });
1993
- byTopic.set(topic2, list);
1994
- }
1995
- for (const [topic2, partitions] of byTopic) {
1996
- await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
1997
- this.logger.log(
1998
- `Offsets set for group "${gid}" on "${topic2}": ${JSON.stringify(partitions)}`
1999
- );
2000
- }
2337
+ return this.adminOps.seekToOffset(groupId, assignments);
2001
2338
  }
2002
2339
  /**
2003
2340
  * Seek specific topic-partition pairs to the offset nearest to a given timestamp
@@ -2008,37 +2345,7 @@ var KafkaClient = class _KafkaClient {
2008
2345
  * future timestamp), the partition falls back to `-1` (end of topic — new messages only).
2009
2346
  */
2010
2347
  async seekToTimestamp(groupId, assignments) {
2011
- const gid = groupId ?? this.defaultGroupId;
2012
- if (this.runningConsumers.has(gid)) {
2013
- throw new Error(
2014
- `seekToTimestamp: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
2015
- );
2016
- }
2017
- await this.ensureAdminConnected();
2018
- const byTopic = /* @__PURE__ */ new Map();
2019
- for (const { topic: topic2, partition, timestamp } of assignments) {
2020
- const list = byTopic.get(topic2) ?? [];
2021
- list.push({ partition, timestamp });
2022
- byTopic.set(topic2, list);
2023
- }
2024
- for (const [topic2, parts] of byTopic) {
2025
- const offsets = await Promise.all(
2026
- parts.map(async ({ partition, timestamp }) => {
2027
- const results = await this.admin.fetchTopicOffsetsByTime(
2028
- topic2,
2029
- timestamp
2030
- );
2031
- const found = results.find(
2032
- (r) => r.partition === partition
2033
- );
2034
- return { partition, offset: found?.offset ?? "-1" };
2035
- })
2036
- );
2037
- await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions: offsets });
2038
- this.logger.log(
2039
- `Offsets set by timestamp for group "${gid}" on "${topic2}": ${JSON.stringify(offsets)}`
2040
- );
2041
- }
2348
+ return this.adminOps.seekToTimestamp(groupId, assignments);
2042
2349
  }
2043
2350
  /**
2044
2351
  * Returns the current circuit breaker state for a specific topic partition.
@@ -2052,14 +2359,7 @@ var KafkaClient = class _KafkaClient {
2052
2359
  * @returns `{ status, failures, windowSize }` snapshot for a given partition or `undefined` if no state exists.
2053
2360
  */
2054
2361
  getCircuitState(topic2, partition, groupId) {
2055
- const gid = groupId ?? this.defaultGroupId;
2056
- const state = this.circuitStates.get(`${gid}:${topic2}:${partition}`);
2057
- if (!state) return void 0;
2058
- return {
2059
- status: state.status,
2060
- failures: state.window.filter((v) => !v).length,
2061
- windowSize: state.window.length
2062
- };
2362
+ return this.circuitBreaker.getState(topic2, partition, groupId ?? this.defaultGroupId);
2063
2363
  }
2064
2364
  /**
2065
2365
  * Query consumer group lag per partition.
@@ -2073,83 +2373,32 @@ var KafkaClient = class _KafkaClient {
2073
2373
  * committed offset. Use `checkStatus()` to verify broker connectivity in that case.
2074
2374
  */
2075
2375
  async getConsumerLag(groupId) {
2076
- const gid = groupId ?? this.defaultGroupId;
2077
- await this.ensureAdminConnected();
2078
- const committedByTopic = await this.admin.fetchOffsets({ groupId: gid });
2079
- const brokerOffsetsAll = await Promise.all(
2080
- committedByTopic.map(({ topic: topic2 }) => this.admin.fetchTopicOffsets(topic2))
2081
- );
2082
- const result = [];
2083
- for (let i = 0; i < committedByTopic.length; i++) {
2084
- const { topic: topic2, partitions } = committedByTopic[i];
2085
- const brokerOffsets = brokerOffsetsAll[i];
2086
- for (const { partition, offset } of partitions) {
2087
- const broker = brokerOffsets.find((o) => o.partition === partition);
2088
- if (!broker) continue;
2089
- const committed = parseInt(offset, 10);
2090
- const high = parseInt(broker.high, 10);
2091
- const lag = committed === -1 ? high : Math.max(0, high - committed);
2092
- result.push({ topic: topic2, partition, lag });
2093
- }
2094
- }
2095
- return result;
2376
+ return this.adminOps.getConsumerLag(groupId);
2096
2377
  }
2097
2378
  /** Check broker connectivity. Never throws — returns a discriminated union. */
2098
2379
  async checkStatus() {
2099
- try {
2100
- await this.ensureAdminConnected();
2101
- const topics = await this.admin.listTopics();
2102
- return { status: "up", clientId: this.clientId, topics };
2103
- } catch (error) {
2104
- return {
2105
- status: "down",
2106
- clientId: this.clientId,
2107
- error: error instanceof Error ? error.message : String(error)
2108
- };
2109
- }
2380
+ return this.adminOps.checkStatus();
2110
2381
  }
2111
2382
  /**
2112
2383
  * List all consumer groups known to the broker.
2113
2384
  * Useful for monitoring which groups are active and their current state.
2114
2385
  */
2115
2386
  async listConsumerGroups() {
2116
- await this.ensureAdminConnected();
2117
- const result = await this.admin.listGroups();
2118
- return result.groups.map((g) => ({
2119
- groupId: g.groupId,
2120
- state: g.state ?? "Unknown"
2121
- }));
2387
+ return this.adminOps.listConsumerGroups();
2122
2388
  }
2123
2389
  /**
2124
2390
  * Describe topics — returns partition layout, leader, replicas, and ISR.
2125
2391
  * @param topics Topic names to describe. Omit to describe all topics.
2126
2392
  */
2127
2393
  async describeTopics(topics) {
2128
- await this.ensureAdminConnected();
2129
- const result = await this.admin.fetchTopicMetadata(
2130
- topics ? { topics } : void 0
2131
- );
2132
- return result.topics.map((t) => ({
2133
- name: t.name,
2134
- partitions: t.partitions.map((p) => ({
2135
- partition: p.partitionId ?? p.partition,
2136
- leader: p.leader,
2137
- replicas: p.replicas.map(
2138
- (r) => typeof r === "number" ? r : r.nodeId
2139
- ),
2140
- isr: p.isr.map(
2141
- (r) => typeof r === "number" ? r : r.nodeId
2142
- )
2143
- }))
2144
- }));
2394
+ return this.adminOps.describeTopics(topics);
2145
2395
  }
2146
2396
  /**
2147
2397
  * Delete records from a topic up to (but not including) the given offsets.
2148
2398
  * All messages with offsets **before** the given offset are deleted.
2149
2399
  */
2150
2400
  async deleteRecords(topic2, partitions) {
2151
- await this.ensureAdminConnected();
2152
- await this.admin.deleteTopicRecords({ topic: topic2, partitions });
2401
+ return this.adminOps.deleteRecords(topic2, partitions);
2153
2402
  }
2154
2403
  /** Return the client ID provided during `KafkaClient` construction. */
2155
2404
  getClientId() {
@@ -2164,24 +2413,8 @@ var KafkaClient = class _KafkaClient {
2164
2413
  * a zero-valued snapshot.
2165
2414
  * @returns Read-only `KafkaMetrics` snapshot: `processedCount`, `retryCount`, `dlqCount`, `dedupCount`.
2166
2415
  */
2167
- getMetrics(topic2) {
2168
- if (topic2 !== void 0) {
2169
- const m = this._topicMetrics.get(topic2);
2170
- return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
2171
- }
2172
- const agg = {
2173
- processedCount: 0,
2174
- retryCount: 0,
2175
- dlqCount: 0,
2176
- dedupCount: 0
2177
- };
2178
- for (const m of this._topicMetrics.values()) {
2179
- agg.processedCount += m.processedCount;
2180
- agg.retryCount += m.retryCount;
2181
- agg.dlqCount += m.dlqCount;
2182
- agg.dedupCount += m.dedupCount;
2183
- }
2184
- return agg;
2416
+ getMetrics(topic2) {
2417
+ return this.metrics.getMetrics(topic2);
2185
2418
  }
2186
2419
  /**
2187
2420
  * Reset internal event counters to zero.
@@ -2189,15 +2422,11 @@ var KafkaClient = class _KafkaClient {
2189
2422
  * @param topic Topic name to reset. When omitted, all topics are reset.
2190
2423
  */
2191
2424
  resetMetrics(topic2) {
2192
- if (topic2 !== void 0) {
2193
- this._topicMetrics.delete(topic2);
2194
- return;
2195
- }
2196
- this._topicMetrics.clear();
2425
+ this.metrics.resetMetrics(topic2);
2197
2426
  }
2198
2427
  /** Gracefully disconnect producer, all consumers, and admin. */
2199
2428
  async disconnect(drainTimeoutMs = 3e4) {
2200
- await this.waitForDrain(drainTimeoutMs);
2429
+ await this.inFlight.waitForDrain(drainTimeoutMs);
2201
2430
  const tasks = [this.producer.disconnect()];
2202
2431
  if (this.txProducer) {
2203
2432
  tasks.push(this.txProducer.disconnect());
@@ -2205,28 +2434,17 @@ var KafkaClient = class _KafkaClient {
2205
2434
  this.txProducer = void 0;
2206
2435
  this.txProducerInitPromise = void 0;
2207
2436
  }
2208
- for (const txId of this.retryTxProducers.keys()) {
2209
- _activeTransactionalIds.delete(txId);
2210
- }
2211
- for (const p of this.retryTxProducers.values()) {
2212
- tasks.push(p.disconnect());
2213
- }
2437
+ for (const txId of this.retryTxProducers.keys()) _activeTransactionalIds.delete(txId);
2438
+ for (const p of this.retryTxProducers.values()) tasks.push(p.disconnect());
2214
2439
  this.retryTxProducers.clear();
2215
- for (const consumer of this.consumers.values()) {
2216
- tasks.push(consumer.disconnect());
2217
- }
2218
- if (this.isAdminConnected) {
2219
- tasks.push(this.admin.disconnect());
2220
- this.isAdminConnected = false;
2221
- }
2440
+ for (const consumer of this.consumers.values()) tasks.push(consumer.disconnect());
2441
+ tasks.push(this.adminOps.disconnect());
2222
2442
  await Promise.allSettled(tasks);
2223
2443
  this.consumers.clear();
2224
2444
  this.runningConsumers.clear();
2225
2445
  this.consumerCreationOptions.clear();
2226
2446
  this.companionGroupIds.clear();
2227
- for (const state of this.circuitStates.values()) clearTimeout(state.timer);
2228
- this.circuitStates.clear();
2229
- this.circuitConfigs.clear();
2447
+ this.circuitBreaker.clear();
2230
2448
  this.logger.log("All connections closed");
2231
2449
  }
2232
2450
  // ── Graceful shutdown ────────────────────────────────────────────
@@ -2245,235 +2463,20 @@ var KafkaClient = class _KafkaClient {
2245
2463
  */
2246
2464
  enableGracefulShutdown(signals = ["SIGTERM", "SIGINT"], drainTimeoutMs = 3e4) {
2247
2465
  const handler = () => {
2248
- this.logger.log(
2249
- "Shutdown signal received \u2014 draining in-flight handlers..."
2250
- );
2466
+ this.logger.log("Shutdown signal received \u2014 draining in-flight handlers...");
2251
2467
  this.disconnect(drainTimeoutMs).catch(
2252
- (err) => this.logger.error(
2253
- "Error during graceful shutdown:",
2254
- toError(err).message
2255
- )
2468
+ (err) => this.logger.error("Error during graceful shutdown:", toError(err).message)
2256
2469
  );
2257
2470
  };
2258
- for (const signal of signals) {
2259
- process.once(signal, handler);
2260
- }
2261
- }
2262
- /**
2263
- * Increment the in-flight handler count and return a promise that calls the given handler.
2264
- * When the promise resolves or rejects, decrement the in flight handler count.
2265
- * If the in flight handler count reaches 0, call all previously registered drain resolvers.
2266
- * @param fn The handler to call when the promise is resolved or rejected.
2267
- * @returns A promise that resolves or rejects with the result of calling the handler.
2268
- */
2269
- trackInFlight(fn) {
2270
- this.inFlightTotal++;
2271
- return fn().finally(() => {
2272
- this.inFlightTotal--;
2273
- if (this.inFlightTotal === 0) {
2274
- this.drainResolvers.splice(0).forEach((r) => r());
2275
- }
2276
- });
2277
- }
2278
- /**
2279
- * Waits for all in-flight handlers to complete or for a given timeout, whichever comes first.
2280
- * @param timeoutMs Maximum time to wait in milliseconds.
2281
- * @returns A promise that resolves when all handlers have completed or the timeout is reached.
2282
- * @private
2283
- */
2284
- waitForDrain(timeoutMs) {
2285
- if (this.inFlightTotal === 0) return Promise.resolve();
2286
- return new Promise((resolve) => {
2287
- let handle;
2288
- const onDrain = () => {
2289
- clearTimeout(handle);
2290
- resolve();
2291
- };
2292
- this.drainResolvers.push(onDrain);
2293
- handle = setTimeout(() => {
2294
- const idx = this.drainResolvers.indexOf(onDrain);
2295
- if (idx !== -1) this.drainResolvers.splice(idx, 1);
2296
- this.logger.warn(
2297
- `Drain timed out after ${timeoutMs}ms \u2014 ${this.inFlightTotal} handler(s) still in flight`
2298
- );
2299
- resolve();
2300
- }, timeoutMs);
2301
- });
2471
+ for (const signal of signals) process.once(signal, handler);
2302
2472
  }
2303
2473
  // ── Private helpers ──────────────────────────────────────────────
2304
- /**
2305
- * Prepare a send payload by registering the topic's schema and then building the payload.
2306
- * @param topicOrDesc - topic name or topic descriptor
2307
- * @param messages - batch of messages to send
2308
- * @returns - prepared payload
2309
- */
2310
2474
  async preparePayload(topicOrDesc, messages, compression) {
2311
2475
  registerSchema(topicOrDesc, this.schemaRegistry, this.logger);
2312
- const payload = await buildSendPayload(
2313
- topicOrDesc,
2314
- messages,
2315
- this.producerOpsDeps,
2316
- compression
2317
- );
2476
+ const payload = await buildSendPayload(topicOrDesc, messages, this._producerOpsDeps, compression);
2318
2477
  await this.ensureTopic(payload.topic);
2319
2478
  return payload;
2320
2479
  }
2321
- // afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
2322
- notifyAfterSend(topic2, count) {
2323
- for (let i = 0; i < count; i++) {
2324
- for (const inst of this.instrumentation) {
2325
- inst.afterSend?.(topic2);
2326
- }
2327
- }
2328
- }
2329
- /**
2330
- * Returns the KafkaMetrics for a given topic.
2331
- * If the topic hasn't seen any events, initializes a zero-valued snapshot.
2332
- * @param topic - name of the topic to get the metrics for
2333
- * @returns - KafkaMetrics for the given topic
2334
- */
2335
- metricsFor(topic2) {
2336
- let m = this._topicMetrics.get(topic2);
2337
- if (!m) {
2338
- m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
2339
- this._topicMetrics.set(topic2, m);
2340
- }
2341
- return m;
2342
- }
2343
- /**
2344
- * Notifies instrumentation hooks of a retry event.
2345
- * @param envelope The original message envelope that triggered the retry.
2346
- * @param attempt The current retry attempt (1-indexed).
2347
- * @param maxRetries The maximum number of retries configured for this topic.
2348
- */
2349
- notifyRetry(envelope, attempt, maxRetries) {
2350
- this.metricsFor(envelope.topic).retryCount++;
2351
- for (const inst of this.instrumentation) {
2352
- inst.onRetry?.(envelope, attempt, maxRetries);
2353
- }
2354
- }
2355
- /**
2356
- * Called whenever a message is routed to the dead letter queue.
2357
- * @param envelope The original message envelope.
2358
- * @param reason The reason for routing to the dead letter queue.
2359
- * @param gid The group ID of the consumer that triggered the circuit breaker, if any.
2360
- */
2361
- notifyDlq(envelope, reason, gid) {
2362
- this.metricsFor(envelope.topic).dlqCount++;
2363
- for (const inst of this.instrumentation) {
2364
- inst.onDlq?.(envelope, reason);
2365
- }
2366
- if (!gid) return;
2367
- const cfg = this.circuitConfigs.get(gid);
2368
- if (!cfg) return;
2369
- const threshold = cfg.threshold ?? 5;
2370
- const recoveryMs = cfg.recoveryMs ?? 3e4;
2371
- const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
2372
- let state = this.circuitStates.get(stateKey);
2373
- if (!state) {
2374
- state = { status: "closed", window: [], successes: 0 };
2375
- this.circuitStates.set(stateKey, state);
2376
- }
2377
- if (state.status === "open") return;
2378
- const openCircuit = () => {
2379
- state.status = "open";
2380
- state.window = [];
2381
- state.successes = 0;
2382
- clearTimeout(state.timer);
2383
- for (const inst of this.instrumentation)
2384
- inst.onCircuitOpen?.(envelope.topic, envelope.partition);
2385
- this.pauseConsumer(gid, [
2386
- { topic: envelope.topic, partitions: [envelope.partition] }
2387
- ]);
2388
- state.timer = setTimeout(() => {
2389
- state.status = "half-open";
2390
- state.successes = 0;
2391
- this.logger.log(
2392
- `[CircuitBreaker] HALF-OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
2393
- );
2394
- for (const inst of this.instrumentation)
2395
- inst.onCircuitHalfOpen?.(envelope.topic, envelope.partition);
2396
- this.resumeConsumer(gid, [
2397
- { topic: envelope.topic, partitions: [envelope.partition] }
2398
- ]);
2399
- }, recoveryMs);
2400
- };
2401
- if (state.status === "half-open") {
2402
- clearTimeout(state.timer);
2403
- this.logger.warn(
2404
- `[CircuitBreaker] OPEN (half-open failure) \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
2405
- );
2406
- openCircuit();
2407
- return;
2408
- }
2409
- const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
2410
- state.window = [...state.window, false];
2411
- if (state.window.length > windowSize) {
2412
- state.window = state.window.slice(state.window.length - windowSize);
2413
- }
2414
- const failures = state.window.filter((v) => !v).length;
2415
- if (failures >= threshold) {
2416
- this.logger.warn(
2417
- `[CircuitBreaker] OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition} (${failures}/${state.window.length} failures, threshold=${threshold})`
2418
- );
2419
- openCircuit();
2420
- }
2421
- }
2422
- /**
2423
- * Notify all instrumentation hooks about a duplicate message detection.
2424
- * Invoked by the consumer after a message has been successfully processed
2425
- * and the Lamport clock detected a duplicate.
2426
- * @param envelope The processed message envelope.
2427
- * @param strategy The duplicate detection strategy used.
2428
- */
2429
- notifyDuplicate(envelope, strategy) {
2430
- this.metricsFor(envelope.topic).dedupCount++;
2431
- for (const inst of this.instrumentation) {
2432
- inst.onDuplicate?.(envelope, strategy);
2433
- }
2434
- }
2435
- /**
2436
- * Notify all instrumentation hooks about a successfully processed message.
2437
- * Invoked by the consumer after a message has been successfully processed
2438
- * by the handler.
2439
- * @param envelope The processed message envelope.
2440
- * @param gid The optional consumer group ID.
2441
- */
2442
- notifyMessage(envelope, gid) {
2443
- this.metricsFor(envelope.topic).processedCount++;
2444
- for (const inst of this.instrumentation) {
2445
- inst.onMessage?.(envelope);
2446
- }
2447
- if (!gid) return;
2448
- const cfg = this.circuitConfigs.get(gid);
2449
- if (!cfg) return;
2450
- const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
2451
- const state = this.circuitStates.get(stateKey);
2452
- if (!state) return;
2453
- const halfOpenSuccesses = cfg.halfOpenSuccesses ?? 1;
2454
- if (state.status === "half-open") {
2455
- state.successes++;
2456
- if (state.successes >= halfOpenSuccesses) {
2457
- clearTimeout(state.timer);
2458
- state.timer = void 0;
2459
- state.status = "closed";
2460
- state.window = [];
2461
- state.successes = 0;
2462
- this.logger.log(
2463
- `[CircuitBreaker] CLOSED \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
2464
- );
2465
- for (const inst of this.instrumentation)
2466
- inst.onCircuitClose?.(envelope.topic, envelope.partition);
2467
- }
2468
- } else if (state.status === "closed") {
2469
- const threshold = cfg.threshold ?? 5;
2470
- const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
2471
- state.window = [...state.window, true];
2472
- if (state.window.length > windowSize) {
2473
- state.window = state.window.slice(state.window.length - windowSize);
2474
- }
2475
- }
2476
- }
2477
2480
  /**
2478
2481
  * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
2479
2482
  * The handler itself is not cancelled — the warning is diagnostic only.
@@ -2484,79 +2487,10 @@ var KafkaClient = class _KafkaClient {
2484
2487
  if (timer !== void 0) clearTimeout(timer);
2485
2488
  });
2486
2489
  timer = setTimeout(() => {
2487
- this.logger.warn(
2488
- `Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`
2489
- );
2490
+ this.logger.warn(`Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`);
2490
2491
  }, timeoutMs);
2491
2492
  return promise;
2492
2493
  }
2493
- /**
2494
- * When `retryTopics: true` and `autoCreateTopics: false`, verify that every
2495
- * `<topic>.retry.<level>` topic already exists. Throws a clear error at startup
2496
- * rather than silently discovering missing topics on the first handler failure.
2497
- */
2498
- async validateRetryTopicsExist(topicNames, maxRetries) {
2499
- await this.ensureAdminConnected();
2500
- const existing = new Set(await this.admin.listTopics());
2501
- const missing = [];
2502
- for (const t of topicNames) {
2503
- for (let level = 1; level <= maxRetries; level++) {
2504
- const retryTopic = `${t}.retry.${level}`;
2505
- if (!existing.has(retryTopic)) missing.push(retryTopic);
2506
- }
2507
- }
2508
- if (missing.length > 0) {
2509
- throw new Error(
2510
- `retryTopics: true but the following retry topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
2511
- );
2512
- }
2513
- }
2514
- /**
2515
- * When `autoCreateTopics` is disabled, verify that `<topic>.dlq` exists for every
2516
- * consumed topic. Throws a clear error at startup rather than silently discovering
2517
- * missing DLQ topics on the first handler failure.
2518
- */
2519
- async validateDlqTopicsExist(topicNames) {
2520
- await this.ensureAdminConnected();
2521
- const existing = new Set(await this.admin.listTopics());
2522
- const missing = topicNames.filter((t) => !existing.has(`${t}.dlq`)).map((t) => `${t}.dlq`);
2523
- if (missing.length > 0) {
2524
- throw new Error(
2525
- `dlq: true but the following DLQ topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
2526
- );
2527
- }
2528
- }
2529
- /**
2530
- * When `deduplication.strategy: 'topic'` and `autoCreateTopics: false`, verify
2531
- * that every `<topic>.duplicates` destination topic already exists. Throws a
2532
- * clear error at startup rather than silently dropping duplicates on first hit.
2533
- */
2534
- async validateDuplicatesTopicsExist(topicNames, customDestination) {
2535
- await this.ensureAdminConnected();
2536
- const existing = new Set(await this.admin.listTopics());
2537
- const toCheck = customDestination ? [customDestination] : topicNames.map((t) => `${t}.duplicates`);
2538
- const missing = toCheck.filter((t) => !existing.has(t));
2539
- if (missing.length > 0) {
2540
- throw new Error(
2541
- `deduplication.strategy: 'topic' but the following duplicate-routing topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
2542
- );
2543
- }
2544
- }
2545
- /**
2546
- * Connect the admin client if not already connected.
2547
- * The flag is only set to `true` after a successful connect — if `admin.connect()`
2548
- * throws the flag remains `false` so the next call will retry the connection.
2549
- */
2550
- async ensureAdminConnected() {
2551
- if (this.isAdminConnected) return;
2552
- try {
2553
- await this.admin.connect();
2554
- this.isAdminConnected = true;
2555
- } catch (err) {
2556
- this.isAdminConnected = false;
2557
- throw err;
2558
- }
2559
- }
2560
2494
  /**
2561
2495
  * Create and connect a transactional producer for EOS retry routing.
2562
2496
  * Each retry level consumer gets its own producer with a unique `transactionalId`
@@ -2569,12 +2503,7 @@ var KafkaClient = class _KafkaClient {
2569
2503
  );
2570
2504
  }
2571
2505
  const p = this.kafka.producer({
2572
- kafkaJS: {
2573
- acks: -1,
2574
- idempotent: true,
2575
- transactionalId,
2576
- maxInFlightRequests: 1
2577
- }
2506
+ kafkaJS: { acks: -1, idempotent: true, transactionalId, maxInFlightRequests: 1 }
2578
2507
  });
2579
2508
  await p.connect();
2580
2509
  _activeTransactionalIds.add(transactionalId);
@@ -2583,20 +2512,16 @@ var KafkaClient = class _KafkaClient {
2583
2512
  }
2584
2513
  /**
2585
2514
  * Ensure that a topic exists by creating it if it doesn't already exist.
2586
- * If `autoCreateTopics` is disabled, this method will not create the topic and
2587
- * will return immediately.
2588
- * If multiple concurrent calls are made to `ensureTopic` for the same topic,
2589
- * they are deduplicated to prevent multiple calls to `admin.createTopics()`.
2590
- * @param topic - The topic to ensure exists.
2591
- * @returns A promise that resolves when the topic has been created or already exists.
2515
+ * If `autoCreateTopics` is disabled, returns immediately.
2516
+ * Concurrent calls for the same topic are deduplicated.
2592
2517
  */
2593
2518
  async ensureTopic(topic2) {
2594
2519
  if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
2595
2520
  let p = this.ensureTopicPromises.get(topic2);
2596
2521
  if (!p) {
2597
2522
  p = (async () => {
2598
- await this.ensureAdminConnected();
2599
- await this.admin.createTopics({
2523
+ await this.adminOps.ensureConnected();
2524
+ await this.adminOps.admin.createTopics({
2600
2525
  topics: [{ topic: topic2, numPartitions: this.numPartitions }]
2601
2526
  });
2602
2527
  this.ensuredTopics.add(topic2);
@@ -2636,99 +2561,78 @@ var KafkaClient = class _KafkaClient {
2636
2561
  gid,
2637
2562
  fromBeginning,
2638
2563
  options.autoCommit ?? true,
2639
- this.consumerOpsDeps,
2564
+ this._consumerOpsDeps,
2640
2565
  options.partitionAssigner
2641
2566
  );
2642
- const schemaMap = buildSchemaMap(
2643
- stringTopics,
2644
- this.schemaRegistry,
2645
- optionSchemas,
2646
- this.logger
2647
- );
2567
+ const schemaMap = buildSchemaMap(stringTopics, this.schemaRegistry, optionSchemas, this.logger);
2648
2568
  const topicNames = stringTopics.map((t) => resolveTopicName(t));
2649
- const subscribeTopics = [
2650
- ...topicNames,
2651
- ...regexTopics
2652
- ];
2653
- for (const t of topicNames) {
2654
- await this.ensureTopic(t);
2655
- }
2569
+ const subscribeTopics = [...topicNames, ...regexTopics];
2570
+ for (const t of topicNames) await this.ensureTopic(t);
2656
2571
  if (dlq) {
2657
- for (const t of topicNames) {
2658
- await this.ensureTopic(`${t}.dlq`);
2659
- }
2572
+ for (const t of topicNames) await this.ensureTopic(`${t}.dlq`);
2660
2573
  if (!this.autoCreateTopicsEnabled && topicNames.length > 0) {
2661
- await this.validateDlqTopicsExist(topicNames);
2574
+ await this.adminOps.validateDlqTopicsExist(topicNames);
2662
2575
  }
2663
2576
  }
2664
2577
  if (options.deduplication?.strategy === "topic") {
2665
2578
  const dest = options.deduplication.duplicatesTopic;
2666
2579
  if (this.autoCreateTopicsEnabled) {
2667
- for (const t of topicNames) {
2668
- await this.ensureTopic(dest ?? `${t}.duplicates`);
2669
- }
2580
+ for (const t of topicNames) await this.ensureTopic(dest ?? `${t}.duplicates`);
2670
2581
  } else if (topicNames.length > 0) {
2671
- await this.validateDuplicatesTopicsExist(topicNames, dest);
2582
+ await this.adminOps.validateDuplicatesTopicsExist(topicNames, dest);
2672
2583
  }
2673
2584
  }
2674
2585
  await consumer.connect();
2675
- await subscribeWithRetry(
2676
- consumer,
2677
- subscribeTopics,
2678
- this.logger,
2679
- options.subscribeRetry
2680
- );
2586
+ await subscribeWithRetry(consumer, subscribeTopics, this.logger, options.subscribeRetry);
2681
2587
  const displayTopics = subscribeTopics.map((t) => t instanceof RegExp ? t.toString() : t).join(", ");
2682
- this.logger.log(
2683
- `${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${displayTopics}`
2684
- );
2588
+ this.logger.log(`${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${displayTopics}`);
2685
2589
  return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry, hasRegex };
2686
2590
  }
2687
2591
  /** Create or retrieve the deduplication context for a consumer group. */
2688
2592
  resolveDeduplicationContext(groupId, options) {
2689
2593
  if (!options) return void 0;
2690
- if (!this.dedupStates.has(groupId)) {
2691
- this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
2692
- }
2594
+ if (!this.dedupStates.has(groupId)) this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
2693
2595
  return { options, state: this.dedupStates.get(groupId) };
2694
2596
  }
2695
- // ── Deps object getters ──────────────────────────────────────────
2696
- /**
2697
- * An object containing the necessary dependencies for building a send payload.
2698
- *
2699
- * @property {Map<string, SchemaLike>} schemaRegistry - A map of topic names to their schemas.
2700
- * @property {boolean} strictSchemasEnabled - Whether strict schema validation is enabled.
2701
- * @property {KafkaInstrumentation} instrumentation - An object for creating a span for instrumentation.
2702
- * @property {KafkaLogger} logger - A logger for logging messages.
2703
- * @property {() => number} nextLamportClock - A function that returns the next value of the logical clock.
2704
- */
2705
- get producerOpsDeps() {
2706
- return {
2707
- schemaRegistry: this.schemaRegistry,
2708
- strictSchemasEnabled: this.strictSchemasEnabled,
2709
- instrumentation: this.instrumentation,
2710
- logger: this.logger,
2711
- nextLamportClock: () => ++this._lamportClock
2712
- };
2597
+ // ── Shared consumer setup helpers ────────────────────────────────
2598
+ /** Guard checks shared by startConsumer and startBatchConsumer. */
2599
+ validateTopicConsumerOpts(topics, options) {
2600
+ if (options.retryTopics && !options.retry) {
2601
+ throw new Error(
2602
+ "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
2603
+ );
2604
+ }
2605
+ if (options.retryTopics && topics.some((t) => t instanceof RegExp)) {
2606
+ throw new Error(
2607
+ "retryTopics is incompatible with regex topic patterns \u2014 retry topics require a fixed topic name to build the retry chain."
2608
+ );
2609
+ }
2713
2610
  }
2714
- /**
2715
- * ConsumerOpsDeps object properties:
2716
- *
2717
- * @property {Map<string, Consumer>} consumers - A map of consumer group IDs to their corresponding consumer instances.
2718
- * @property {Map<string, { fromBeginning: boolean; autoCommit: boolean }>} consumerCreationOptions - A map of consumer group IDs to their creation options.
2719
- * @property {Kafka} kafka - The Kafka client instance.
2720
- * @property {function(string, Partition[]): void} onRebalance - An optional callback function called when a consumer group is rebalanced.
2721
- * @property {KafkaLogger} logger - The logger instance used for logging consumer operations.
2722
- */
2723
- get consumerOpsDeps() {
2724
- return {
2725
- consumers: this.consumers,
2726
- consumerCreationOptions: this.consumerCreationOptions,
2727
- kafka: this.kafka,
2728
- onRebalance: this.onRebalance,
2729
- logger: this.logger
2730
- };
2611
+ /** Create EOS transactional producer context for atomic main → retry.1 routing. */
2612
+ async makeEosMainContext(gid, consumer, options) {
2613
+ if (!options.retryTopics || !options.retry) return void 0;
2614
+ const txProducer = await this.createRetryTxProducer(`${gid}-main-tx`);
2615
+ return { txProducer, consumer };
2616
+ }
2617
+ /** Start companion retry-level consumers and register them under the main groupId. */
2618
+ async launchRetryChain(gid, topicNames, handleMessage, retry, dlq, interceptors, schemaMap, assignmentTimeoutMs) {
2619
+ if (!this.autoCreateTopicsEnabled) {
2620
+ await this.adminOps.validateRetryTopicsExist(topicNames, retry.maxRetries);
2621
+ }
2622
+ const companions = await startRetryTopicConsumers(
2623
+ topicNames,
2624
+ gid,
2625
+ handleMessage,
2626
+ retry,
2627
+ dlq,
2628
+ interceptors,
2629
+ schemaMap,
2630
+ this._retryTopicDeps,
2631
+ assignmentTimeoutMs
2632
+ );
2633
+ this.companionGroupIds.set(gid, companions);
2731
2634
  }
2635
+ // ── Deps object builders ─────────────────────────────────────────
2732
2636
  /** Build MessageHandlerDeps with circuit breaker callbacks bound to the given groupId. */
2733
2637
  messageDepsFor(gid) {
2734
2638
  return {
@@ -2737,38 +2641,24 @@ var KafkaClient = class _KafkaClient {
2737
2641
  instrumentation: this.instrumentation,
2738
2642
  onMessageLost: this.onMessageLost,
2739
2643
  onTtlExpired: this.onTtlExpired,
2740
- onRetry: this.notifyRetry.bind(this),
2741
- onDlq: (envelope, reason) => this.notifyDlq(envelope, reason, gid),
2742
- onDuplicate: this.notifyDuplicate.bind(this),
2743
- onMessage: (envelope) => this.notifyMessage(envelope, gid)
2644
+ onRetry: this.metrics.notifyRetry.bind(this.metrics),
2645
+ onDlq: (envelope, reason) => this.metrics.notifyDlq(envelope, reason, gid),
2646
+ onDuplicate: this.metrics.notifyDuplicate.bind(this.metrics),
2647
+ onMessage: (envelope) => this.metrics.notifyMessage(envelope, gid)
2744
2648
  };
2745
2649
  }
2746
- /**
2747
- * The dependencies object passed to the retry topic consumers.
2748
- *
2749
- * `logger`: The logger instance passed to the retry topic consumers.
2750
- * `producer`: The producer instance passed to the retry topic consumers.
2751
- * `instrumentation`: The instrumentation instance passed to the retry topic consumers.
2752
- * `onMessageLost`: The callback function passed to the retry topic consumers for tracking lost messages.
2753
- * `onRetry`: The callback function passed to the retry topic consumers for tracking retry attempts.
2754
- * `onDlq`: The callback function passed to the retry topic consumers for tracking dead-letter queue routing.
2755
- * `onMessage`: The callback function passed to the retry topic consumers for tracking message delivery.
2756
- * `ensureTopic`: A function that ensures a topic exists before subscribing to it.
2757
- * `getOrCreateConsumer`: A function that creates or retrieves a consumer instance.
2758
- * `runningConsumers`: A map of consumer group IDs to their corresponding consumer instances.
2759
- * `createRetryTxProducer`: A function that creates a retry transactional producer instance.
2760
- */
2761
- get retryTopicDeps() {
2650
+ /** Build the deps object passed to retry topic consumers. */
2651
+ buildRetryTopicDeps() {
2762
2652
  return {
2763
2653
  logger: this.logger,
2764
2654
  producer: this.producer,
2765
2655
  instrumentation: this.instrumentation,
2766
2656
  onMessageLost: this.onMessageLost,
2767
- onRetry: this.notifyRetry.bind(this),
2768
- onDlq: this.notifyDlq.bind(this),
2769
- onMessage: this.notifyMessage.bind(this),
2657
+ onRetry: this.metrics.notifyRetry.bind(this.metrics),
2658
+ onDlq: this.metrics.notifyDlq.bind(this.metrics),
2659
+ onMessage: this.metrics.notifyMessage.bind(this.metrics),
2770
2660
  ensureTopic: (t) => this.ensureTopic(t),
2771
- getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
2661
+ getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this._consumerOpsDeps),
2772
2662
  runningConsumers: this.runningConsumers,
2773
2663
  createRetryTxProducer: (txId) => this.createRetryTxProducer(txId)
2774
2664
  };