@drarzter/kafka-client 0.7.2 → 0.7.4
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 +137 -7
- package/dist/{chunk-MADAJD2F.mjs → chunk-BVWRZTMD.mjs} +858 -864
- package/dist/chunk-BVWRZTMD.mjs.map +1 -0
- package/dist/core.d.mts +36 -139
- package/dist/core.d.ts +36 -139
- package/dist/core.js +857 -863
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +853 -859
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/otel.d.mts +1 -1
- package/dist/otel.d.ts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{types-4qWrf2aJ.d.mts → types-Db7qSbZP.d.mts} +14 -0
- package/dist/{types-4qWrf2aJ.d.ts → types-Db7qSbZP.d.ts} +14 -0
- package/package.json +1 -1
- package/dist/chunk-MADAJD2F.mjs.map +0 -1
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
|
|
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
|
|
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/
|
|
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/
|
|
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,28 @@ 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;
|
|
1860
|
+
/** Topics to scan for the highest Lamport clock value on `connectProducer()`. */
|
|
1861
|
+
clockRecoveryTopics;
|
|
1309
1862
|
/** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
|
|
1310
1863
|
dedupStates = /* @__PURE__ */ new Map();
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
isAdminConnected = false;
|
|
1316
|
-
inFlightTotal = 0;
|
|
1317
|
-
drainResolvers = [];
|
|
1864
|
+
circuitBreaker;
|
|
1865
|
+
adminOps;
|
|
1866
|
+
metrics;
|
|
1867
|
+
inFlight;
|
|
1318
1868
|
clientId;
|
|
1869
|
+
_producerOpsDeps;
|
|
1870
|
+
_consumerOpsDeps;
|
|
1871
|
+
_retryTopicDeps;
|
|
1872
|
+
/** DLQ header keys added by the pipeline — stripped before re-publishing. */
|
|
1873
|
+
static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
|
|
1874
|
+
"x-dlq-original-topic",
|
|
1875
|
+
"x-dlq-failed-at",
|
|
1876
|
+
"x-dlq-error-message",
|
|
1877
|
+
"x-dlq-error-stack",
|
|
1878
|
+
"x-dlq-attempt-count"
|
|
1879
|
+
]);
|
|
1319
1880
|
constructor(clientId, groupId, brokers, options) {
|
|
1320
1881
|
this.clientId = clientId;
|
|
1321
1882
|
this.defaultGroupId = groupId;
|
|
@@ -1333,6 +1894,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1333
1894
|
this.onTtlExpired = options?.onTtlExpired;
|
|
1334
1895
|
this.onRebalance = options?.onRebalance;
|
|
1335
1896
|
this.txId = options?.transactionalId ?? `${clientId}-tx`;
|
|
1897
|
+
this.clockRecoveryTopics = options?.clockRecovery?.topics ?? [];
|
|
1336
1898
|
this.kafka = new KafkaClass({
|
|
1337
1899
|
kafkaJS: {
|
|
1338
1900
|
clientId: this.clientId,
|
|
@@ -1340,12 +1902,41 @@ var KafkaClient = class _KafkaClient {
|
|
|
1340
1902
|
logLevel: KafkaLogLevel.ERROR
|
|
1341
1903
|
}
|
|
1342
1904
|
});
|
|
1343
|
-
this.producer = this.kafka.producer({
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1905
|
+
this.producer = this.kafka.producer({ kafkaJS: { acks: -1 } });
|
|
1906
|
+
this.adminOps = new AdminOps({
|
|
1907
|
+
admin: this.kafka.admin(),
|
|
1908
|
+
logger: this.logger,
|
|
1909
|
+
runningConsumers: this.runningConsumers,
|
|
1910
|
+
defaultGroupId: this.defaultGroupId,
|
|
1911
|
+
clientId: this.clientId
|
|
1912
|
+
});
|
|
1913
|
+
this.circuitBreaker = new CircuitBreakerManager({
|
|
1914
|
+
pauseConsumer: (gid, assignments) => this.pauseConsumer(gid, assignments),
|
|
1915
|
+
resumeConsumer: (gid, assignments) => this.resumeConsumer(gid, assignments),
|
|
1916
|
+
logger: this.logger,
|
|
1917
|
+
instrumentation: this.instrumentation
|
|
1918
|
+
});
|
|
1919
|
+
this.metrics = new MetricsManager({
|
|
1920
|
+
instrumentation: this.instrumentation,
|
|
1921
|
+
onCircuitFailure: (envelope, gid) => this.circuitBreaker.onFailure(envelope, gid),
|
|
1922
|
+
onCircuitSuccess: (envelope, gid) => this.circuitBreaker.onSuccess(envelope, gid)
|
|
1347
1923
|
});
|
|
1348
|
-
this.
|
|
1924
|
+
this.inFlight = new InFlightTracker((msg) => this.logger.warn(msg));
|
|
1925
|
+
this._producerOpsDeps = {
|
|
1926
|
+
schemaRegistry: this.schemaRegistry,
|
|
1927
|
+
strictSchemasEnabled: this.strictSchemasEnabled,
|
|
1928
|
+
instrumentation: this.instrumentation,
|
|
1929
|
+
logger: this.logger,
|
|
1930
|
+
nextLamportClock: () => ++this._lamportClock
|
|
1931
|
+
};
|
|
1932
|
+
this._consumerOpsDeps = {
|
|
1933
|
+
consumers: this.consumers,
|
|
1934
|
+
consumerCreationOptions: this.consumerCreationOptions,
|
|
1935
|
+
kafka: this.kafka,
|
|
1936
|
+
onRebalance: this.onRebalance,
|
|
1937
|
+
logger: this.logger
|
|
1938
|
+
};
|
|
1939
|
+
this._retryTopicDeps = this.buildRetryTopicDeps();
|
|
1349
1940
|
}
|
|
1350
1941
|
async sendMessage(topicOrDesc, message, options = {}) {
|
|
1351
1942
|
const payload = await this.preparePayload(
|
|
@@ -1363,7 +1954,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1363
1954
|
options.compression
|
|
1364
1955
|
);
|
|
1365
1956
|
await this.producer.send(payload);
|
|
1366
|
-
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1957
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1367
1958
|
}
|
|
1368
1959
|
/**
|
|
1369
1960
|
* Send a null-value (tombstone) message. Used with log-compacted topics to signal
|
|
@@ -1377,27 +1968,16 @@ var KafkaClient = class _KafkaClient {
|
|
|
1377
1968
|
* @param headers Optional custom Kafka headers.
|
|
1378
1969
|
*/
|
|
1379
1970
|
async sendTombstone(topic2, key, headers) {
|
|
1380
|
-
const hdrs = { ...headers };
|
|
1381
|
-
for (const inst of this.instrumentation)
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1384
|
-
|
|
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
|
+
const hdrs = { ...headers };
|
|
1972
|
+
for (const inst of this.instrumentation) inst.beforeSend?.(topic2, hdrs);
|
|
1973
|
+
await this.ensureTopic(topic2);
|
|
1974
|
+
await this.producer.send({ topic: topic2, messages: [{ value: null, key, headers: hdrs }] });
|
|
1975
|
+
for (const inst of this.instrumentation) inst.afterSend?.(topic2);
|
|
1392
1976
|
}
|
|
1393
1977
|
async sendBatch(topicOrDesc, messages, options) {
|
|
1394
|
-
const payload = await this.preparePayload(
|
|
1395
|
-
topicOrDesc,
|
|
1396
|
-
messages,
|
|
1397
|
-
options?.compression
|
|
1398
|
-
);
|
|
1978
|
+
const payload = await this.preparePayload(topicOrDesc, messages, options?.compression);
|
|
1399
1979
|
await this.producer.send(payload);
|
|
1400
|
-
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1980
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1401
1981
|
}
|
|
1402
1982
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
1403
1983
|
async transaction(fn) {
|
|
@@ -1409,12 +1989,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1409
1989
|
}
|
|
1410
1990
|
const initPromise = (async () => {
|
|
1411
1991
|
const p = this.kafka.producer({
|
|
1412
|
-
kafkaJS: {
|
|
1413
|
-
acks: -1,
|
|
1414
|
-
idempotent: true,
|
|
1415
|
-
transactionalId: this.txId,
|
|
1416
|
-
maxInFlightRequests: 1
|
|
1417
|
-
}
|
|
1992
|
+
kafkaJS: { acks: -1, idempotent: true, transactionalId: this.txId, maxInFlightRequests: 1 }
|
|
1418
1993
|
});
|
|
1419
1994
|
await p.connect();
|
|
1420
1995
|
_activeTransactionalIds.add(this.txId);
|
|
@@ -1441,19 +2016,12 @@ var KafkaClient = class _KafkaClient {
|
|
|
1441
2016
|
}
|
|
1442
2017
|
]);
|
|
1443
2018
|
await tx.send(payload);
|
|
1444
|
-
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
2019
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1445
2020
|
},
|
|
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
2021
|
sendBatch: async (topicOrDesc, messages) => {
|
|
1454
2022
|
const payload = await this.preparePayload(topicOrDesc, messages);
|
|
1455
2023
|
await tx.send(payload);
|
|
1456
|
-
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
2024
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1457
2025
|
}
|
|
1458
2026
|
};
|
|
1459
2027
|
await fn(ctx);
|
|
@@ -1462,10 +2030,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1462
2030
|
try {
|
|
1463
2031
|
await tx.abort();
|
|
1464
2032
|
} catch (abortError) {
|
|
1465
|
-
this.logger.error(
|
|
1466
|
-
"Failed to abort transaction:",
|
|
1467
|
-
toError(abortError).message
|
|
1468
|
-
);
|
|
2033
|
+
this.logger.error("Failed to abort transaction:", toError(abortError).message);
|
|
1469
2034
|
}
|
|
1470
2035
|
throw error;
|
|
1471
2036
|
}
|
|
@@ -1477,8 +2042,109 @@ var KafkaClient = class _KafkaClient {
|
|
|
1477
2042
|
*/
|
|
1478
2043
|
async connectProducer() {
|
|
1479
2044
|
await this.producer.connect();
|
|
2045
|
+
await this.recoverLamportClock(this.clockRecoveryTopics);
|
|
1480
2046
|
this.logger.log("Producer connected");
|
|
1481
2047
|
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Recover the Lamport clock from the last message across the given topics.
|
|
2050
|
+
*
|
|
2051
|
+
* For each topic, fetches partition high-watermarks via admin, creates a
|
|
2052
|
+
* short-lived consumer, seeks every non-empty partition to its last offset
|
|
2053
|
+
* (`highWatermark − 1`), reads one message per partition, and extracts the
|
|
2054
|
+
* maximum `x-lamport-clock` header value. On completion `_lamportClock` is
|
|
2055
|
+
* set to that maximum so the next `++_lamportClock` yields a strictly greater
|
|
2056
|
+
* value than any previously sent clock.
|
|
2057
|
+
*
|
|
2058
|
+
* Topics that are empty or missing are silently skipped.
|
|
2059
|
+
*/
|
|
2060
|
+
async recoverLamportClock(topics) {
|
|
2061
|
+
if (topics.length === 0) return;
|
|
2062
|
+
this.logger.log(
|
|
2063
|
+
`Clock recovery: scanning ${topics.length} topic(s) for Lamport clock...`
|
|
2064
|
+
);
|
|
2065
|
+
await this.adminOps.ensureConnected();
|
|
2066
|
+
const partitionsToRead = [];
|
|
2067
|
+
for (const t of topics) {
|
|
2068
|
+
let offsets;
|
|
2069
|
+
try {
|
|
2070
|
+
offsets = await this.adminOps.admin.fetchTopicOffsets(t);
|
|
2071
|
+
} catch {
|
|
2072
|
+
this.logger.warn(
|
|
2073
|
+
`Clock recovery: could not fetch offsets for "${t}", skipping`
|
|
2074
|
+
);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
for (const { partition, high, low } of offsets) {
|
|
2078
|
+
if (parseInt(high, 10) > parseInt(low, 10)) {
|
|
2079
|
+
partitionsToRead.push({
|
|
2080
|
+
topic: t,
|
|
2081
|
+
partition,
|
|
2082
|
+
lastOffset: String(parseInt(high, 10) - 1)
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
if (partitionsToRead.length === 0) {
|
|
2088
|
+
this.logger.log(
|
|
2089
|
+
"Clock recovery: all topics empty \u2014 keeping Lamport clock at 0"
|
|
2090
|
+
);
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
const recoveryGroupId = `${this.clientId}-clock-recovery-${Date.now()}`;
|
|
2094
|
+
let maxClock = -1;
|
|
2095
|
+
await new Promise((resolve, reject) => {
|
|
2096
|
+
const consumer = this.kafka.consumer({
|
|
2097
|
+
kafkaJS: { groupId: recoveryGroupId }
|
|
2098
|
+
});
|
|
2099
|
+
const remaining = new Set(
|
|
2100
|
+
partitionsToRead.map((p) => `${p.topic}:${p.partition}`)
|
|
2101
|
+
);
|
|
2102
|
+
const cleanup = () => {
|
|
2103
|
+
consumer.disconnect().catch(() => {
|
|
2104
|
+
});
|
|
2105
|
+
};
|
|
2106
|
+
consumer.connect().then(async () => {
|
|
2107
|
+
const uniqueTopics = [
|
|
2108
|
+
...new Set(partitionsToRead.map((p) => p.topic))
|
|
2109
|
+
];
|
|
2110
|
+
await consumer.subscribe({ topics: uniqueTopics });
|
|
2111
|
+
for (const { topic: t, partition, lastOffset } of partitionsToRead) {
|
|
2112
|
+
consumer.seek({ topic: t, partition, offset: lastOffset });
|
|
2113
|
+
}
|
|
2114
|
+
}).then(
|
|
2115
|
+
() => consumer.run({
|
|
2116
|
+
eachMessage: async ({ topic: t, partition, message }) => {
|
|
2117
|
+
const key = `${t}:${partition}`;
|
|
2118
|
+
if (!remaining.has(key)) return;
|
|
2119
|
+
remaining.delete(key);
|
|
2120
|
+
const clockHeader = message.headers?.[HEADER_LAMPORT_CLOCK];
|
|
2121
|
+
if (clockHeader !== void 0) {
|
|
2122
|
+
const raw = Buffer.isBuffer(clockHeader) ? clockHeader.toString() : String(clockHeader);
|
|
2123
|
+
const clock = Number(raw);
|
|
2124
|
+
if (!isNaN(clock) && clock > maxClock) maxClock = clock;
|
|
2125
|
+
}
|
|
2126
|
+
if (remaining.size === 0) {
|
|
2127
|
+
cleanup();
|
|
2128
|
+
resolve();
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
})
|
|
2132
|
+
).catch((err) => {
|
|
2133
|
+
cleanup();
|
|
2134
|
+
reject(err);
|
|
2135
|
+
});
|
|
2136
|
+
});
|
|
2137
|
+
if (maxClock >= 0) {
|
|
2138
|
+
this._lamportClock = maxClock;
|
|
2139
|
+
this.logger.log(
|
|
2140
|
+
`Clock recovery: Lamport clock restored \u2014 next clock will be ${maxClock + 1}`
|
|
2141
|
+
);
|
|
2142
|
+
} else {
|
|
2143
|
+
this.logger.log(
|
|
2144
|
+
"Clock recovery: no x-lamport-clock headers found \u2014 keeping clock at 0"
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
1482
2148
|
/**
|
|
1483
2149
|
* @internal Not part of `IKafkaClient` — use `disconnect()` for full teardown.
|
|
1484
2150
|
*/
|
|
@@ -1487,35 +2153,14 @@ var KafkaClient = class _KafkaClient {
|
|
|
1487
2153
|
this.logger.log("Producer disconnected");
|
|
1488
2154
|
}
|
|
1489
2155
|
async startConsumer(topics, handleMessage, options = {}) {
|
|
1490
|
-
|
|
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
|
-
}
|
|
2156
|
+
this.validateTopicConsumerOpts(topics, options);
|
|
1501
2157
|
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1502
2158
|
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);
|
|
2159
|
+
if (options.circuitBreaker) this.circuitBreaker.setConfig(gid, options.circuitBreaker);
|
|
1505
2160
|
const deps = this.messageDepsFor(gid);
|
|
1506
|
-
const
|
|
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
|
-
}
|
|
2161
|
+
const eosMainContext = await this.makeEosMainContext(gid, consumer, options);
|
|
1517
2162
|
await consumer.run({
|
|
1518
|
-
eachMessage: (payload) => this.
|
|
2163
|
+
eachMessage: (payload) => this.inFlight.track(
|
|
1519
2164
|
() => handleEachMessage(
|
|
1520
2165
|
payload,
|
|
1521
2166
|
{
|
|
@@ -1525,9 +2170,9 @@ var KafkaClient = class _KafkaClient {
|
|
|
1525
2170
|
dlq,
|
|
1526
2171
|
retry,
|
|
1527
2172
|
retryTopics: options.retryTopics,
|
|
1528
|
-
timeoutMs,
|
|
2173
|
+
timeoutMs: options.handlerTimeoutMs,
|
|
1529
2174
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1530
|
-
deduplication,
|
|
2175
|
+
deduplication: this.resolveDeduplicationContext(gid, options.deduplication),
|
|
1531
2176
|
messageTtlMs: options.messageTtlMs,
|
|
1532
2177
|
onTtlExpired: options.onTtlExpired,
|
|
1533
2178
|
eosMainContext
|
|
@@ -1538,70 +2183,24 @@ var KafkaClient = class _KafkaClient {
|
|
|
1538
2183
|
});
|
|
1539
2184
|
this.runningConsumers.set(gid, "eachMessage");
|
|
1540
2185
|
if (options.retryTopics && retry) {
|
|
1541
|
-
|
|
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);
|
|
2186
|
+
await this.launchRetryChain(gid, topicNames, handleMessage, retry, dlq, interceptors, schemaMap, options.retryTopicAssignmentTimeoutMs);
|
|
1556
2187
|
}
|
|
1557
2188
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
1558
2189
|
}
|
|
1559
2190
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
1560
|
-
|
|
1561
|
-
|
|
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) {
|
|
2191
|
+
this.validateTopicConsumerOpts(topics, options);
|
|
2192
|
+
if (!options.retryTopics && options.autoCommit !== false) {
|
|
1573
2193
|
this.logger.debug?.(
|
|
1574
2194
|
`startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
|
|
1575
2195
|
);
|
|
1576
2196
|
}
|
|
1577
2197
|
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1578
2198
|
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);
|
|
2199
|
+
if (options.circuitBreaker) this.circuitBreaker.setConfig(gid, options.circuitBreaker);
|
|
1581
2200
|
const deps = this.messageDepsFor(gid);
|
|
1582
|
-
const
|
|
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
|
-
}
|
|
2201
|
+
const eosMainContext = await this.makeEosMainContext(gid, consumer, options);
|
|
1593
2202
|
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(
|
|
2203
|
+
eachBatch: (payload) => this.inFlight.track(
|
|
1605
2204
|
() => handleEachBatch(
|
|
1606
2205
|
payload,
|
|
1607
2206
|
{
|
|
@@ -1611,9 +2210,9 @@ var KafkaClient = class _KafkaClient {
|
|
|
1611
2210
|
dlq,
|
|
1612
2211
|
retry,
|
|
1613
2212
|
retryTopics: options.retryTopics,
|
|
1614
|
-
timeoutMs,
|
|
2213
|
+
timeoutMs: options.handlerTimeoutMs,
|
|
1615
2214
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1616
|
-
deduplication,
|
|
2215
|
+
deduplication: this.resolveDeduplicationContext(gid, options.deduplication),
|
|
1617
2216
|
messageTtlMs: options.messageTtlMs,
|
|
1618
2217
|
onTtlExpired: options.onTtlExpired,
|
|
1619
2218
|
eosMainContext
|
|
@@ -1624,9 +2223,6 @@ var KafkaClient = class _KafkaClient {
|
|
|
1624
2223
|
});
|
|
1625
2224
|
this.runningConsumers.set(gid, "eachBatch");
|
|
1626
2225
|
if (options.retryTopics && retry) {
|
|
1627
|
-
if (!this.autoCreateTopicsEnabled) {
|
|
1628
|
-
await this.validateRetryTopicsExist(topicNames, retry.maxRetries);
|
|
1629
|
-
}
|
|
1630
2226
|
const handleMessageForRetry = (env) => handleBatch([env], {
|
|
1631
2227
|
partition: env.partition,
|
|
1632
2228
|
highWatermark: null,
|
|
@@ -1637,18 +2233,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1637
2233
|
commitOffsetsIfNecessary: async () => {
|
|
1638
2234
|
}
|
|
1639
2235
|
});
|
|
1640
|
-
|
|
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);
|
|
2236
|
+
await this.launchRetryChain(gid, topicNames, handleMessageForRetry, retry, dlq, interceptors, schemaMap, options.retryTopicAssignmentTimeoutMs);
|
|
1652
2237
|
}
|
|
1653
2238
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
1654
2239
|
}
|
|
@@ -1707,38 +2292,20 @@ var KafkaClient = class _KafkaClient {
|
|
|
1707
2292
|
if (groupId !== void 0) {
|
|
1708
2293
|
const consumer = this.consumers.get(groupId);
|
|
1709
2294
|
if (!consumer) {
|
|
1710
|
-
this.logger.warn(
|
|
1711
|
-
`stopConsumer: no active consumer for group "${groupId}"`
|
|
1712
|
-
);
|
|
2295
|
+
this.logger.warn(`stopConsumer: no active consumer for group "${groupId}"`);
|
|
1713
2296
|
return;
|
|
1714
2297
|
}
|
|
1715
|
-
await consumer.disconnect().catch(
|
|
1716
|
-
(e) => this.logger.warn(
|
|
1717
|
-
`Error disconnecting consumer "${groupId}":`,
|
|
1718
|
-
toError(e).message
|
|
1719
|
-
)
|
|
1720
|
-
);
|
|
2298
|
+
await consumer.disconnect().catch((e) => this.logger.warn(`Error disconnecting consumer "${groupId}":`, toError(e).message));
|
|
1721
2299
|
this.consumers.delete(groupId);
|
|
1722
2300
|
this.runningConsumers.delete(groupId);
|
|
1723
2301
|
this.consumerCreationOptions.delete(groupId);
|
|
1724
2302
|
this.dedupStates.delete(groupId);
|
|
1725
|
-
|
|
1726
|
-
if (key.startsWith(`${groupId}:`)) {
|
|
1727
|
-
clearTimeout(this.circuitStates.get(key).timer);
|
|
1728
|
-
this.circuitStates.delete(key);
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
this.circuitConfigs.delete(groupId);
|
|
2303
|
+
this.circuitBreaker.removeGroup(groupId);
|
|
1732
2304
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
1733
2305
|
const mainTxId = `${groupId}-main-tx`;
|
|
1734
2306
|
const mainTxProducer = this.retryTxProducers.get(mainTxId);
|
|
1735
2307
|
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
|
-
);
|
|
2308
|
+
await mainTxProducer.disconnect().catch((e) => this.logger.warn(`Error disconnecting main tx producer "${mainTxId}":`, toError(e).message));
|
|
1742
2309
|
_activeTransactionalIds.delete(mainTxId);
|
|
1743
2310
|
this.retryTxProducers.delete(mainTxId);
|
|
1744
2311
|
}
|
|
@@ -1746,12 +2313,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1746
2313
|
for (const cGroupId of companions) {
|
|
1747
2314
|
const cConsumer = this.consumers.get(cGroupId);
|
|
1748
2315
|
if (cConsumer) {
|
|
1749
|
-
await cConsumer.disconnect().catch(
|
|
1750
|
-
(e) => this.logger.warn(
|
|
1751
|
-
`Error disconnecting retry consumer "${cGroupId}":`,
|
|
1752
|
-
toError(e).message
|
|
1753
|
-
)
|
|
1754
|
-
);
|
|
2316
|
+
await cConsumer.disconnect().catch((e) => this.logger.warn(`Error disconnecting retry consumer "${cGroupId}":`, toError(e).message));
|
|
1755
2317
|
this.consumers.delete(cGroupId);
|
|
1756
2318
|
this.runningConsumers.delete(cGroupId);
|
|
1757
2319
|
this.consumerCreationOptions.delete(cGroupId);
|
|
@@ -1760,12 +2322,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1760
2322
|
const txId = `${cGroupId}-tx`;
|
|
1761
2323
|
const txProducer = this.retryTxProducers.get(txId);
|
|
1762
2324
|
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
|
-
);
|
|
2325
|
+
await txProducer.disconnect().catch((e) => this.logger.warn(`Error disconnecting retry tx producer "${txId}":`, toError(e).message));
|
|
1769
2326
|
_activeTransactionalIds.delete(txId);
|
|
1770
2327
|
this.retryTxProducers.delete(txId);
|
|
1771
2328
|
}
|
|
@@ -1773,14 +2330,10 @@ var KafkaClient = class _KafkaClient {
|
|
|
1773
2330
|
this.companionGroupIds.delete(groupId);
|
|
1774
2331
|
} else {
|
|
1775
2332
|
const tasks = [
|
|
1776
|
-
...Array.from(this.consumers.values()).map(
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
)
|
|
1780
|
-
...Array.from(this.retryTxProducers.values()).map(
|
|
1781
|
-
(p) => p.disconnect().catch(() => {
|
|
1782
|
-
})
|
|
1783
|
-
)
|
|
2333
|
+
...Array.from(this.consumers.values()).map((c) => c.disconnect().catch(() => {
|
|
2334
|
+
})),
|
|
2335
|
+
...Array.from(this.retryTxProducers.values()).map((p) => p.disconnect().catch(() => {
|
|
2336
|
+
}))
|
|
1784
2337
|
];
|
|
1785
2338
|
await Promise.allSettled(tasks);
|
|
1786
2339
|
this.consumers.clear();
|
|
@@ -1789,10 +2342,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1789
2342
|
this.companionGroupIds.clear();
|
|
1790
2343
|
this.retryTxProducers.clear();
|
|
1791
2344
|
this.dedupStates.clear();
|
|
1792
|
-
|
|
1793
|
-
clearTimeout(state.timer);
|
|
1794
|
-
this.circuitStates.clear();
|
|
1795
|
-
this.circuitConfigs.clear();
|
|
2345
|
+
this.circuitBreaker.clear();
|
|
1796
2346
|
this.logger.log("All consumers disconnected");
|
|
1797
2347
|
}
|
|
1798
2348
|
}
|
|
@@ -1810,9 +2360,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1810
2360
|
return;
|
|
1811
2361
|
}
|
|
1812
2362
|
consumer.pause(
|
|
1813
|
-
assignments.flatMap(
|
|
1814
|
-
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1815
|
-
)
|
|
2363
|
+
assignments.flatMap(({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] })))
|
|
1816
2364
|
);
|
|
1817
2365
|
}
|
|
1818
2366
|
/**
|
|
@@ -1829,9 +2377,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1829
2377
|
return;
|
|
1830
2378
|
}
|
|
1831
2379
|
consumer.resume(
|
|
1832
|
-
assignments.flatMap(
|
|
1833
|
-
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1834
|
-
)
|
|
2380
|
+
assignments.flatMap(({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] })))
|
|
1835
2381
|
);
|
|
1836
2382
|
}
|
|
1837
2383
|
/** Pause all assigned partitions of a topic for a consumer group (used for queue backpressure). */
|
|
@@ -1852,14 +2398,6 @@ var KafkaClient = class _KafkaClient {
|
|
|
1852
2398
|
if (partitions.length > 0)
|
|
1853
2399
|
consumer.resume(partitions.map((p) => ({ topic: topic2, partitions: [p] })));
|
|
1854
2400
|
}
|
|
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
2401
|
/**
|
|
1864
2402
|
* Re-publish messages from a dead letter queue back to the original topic.
|
|
1865
2403
|
*
|
|
@@ -1872,106 +2410,27 @@ var KafkaClient = class _KafkaClient {
|
|
|
1872
2410
|
* @returns { replayed: number; skipped: number } - counts of re-published vs skipped messages
|
|
1873
2411
|
*/
|
|
1874
2412
|
async replayDlq(topic2, options = {}) {
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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 = () => {
|
|
2413
|
+
await this.adminOps.ensureConnected();
|
|
2414
|
+
return replayDlqTopic(topic2, options, {
|
|
2415
|
+
logger: this.logger,
|
|
2416
|
+
fetchTopicOffsets: (t) => this.adminOps.admin.fetchTopicOffsets(t),
|
|
2417
|
+
send: async (t, messages) => {
|
|
2418
|
+
await this.producer.send({ topic: t, messages });
|
|
2419
|
+
},
|
|
2420
|
+
createConsumer: (gid) => getOrCreateConsumer(gid, true, true, this._consumerOpsDeps),
|
|
2421
|
+
cleanupConsumer: (consumer, gid) => {
|
|
1903
2422
|
consumer.disconnect().catch(() => {
|
|
1904
2423
|
}).finally(() => {
|
|
1905
|
-
this.consumers.delete(
|
|
1906
|
-
this.runningConsumers.delete(
|
|
1907
|
-
this.consumerCreationOptions.delete(
|
|
2424
|
+
this.consumers.delete(gid);
|
|
2425
|
+
this.runningConsumers.delete(gid);
|
|
2426
|
+
this.consumerCreationOptions.delete(gid);
|
|
1908
2427
|
});
|
|
1909
|
-
}
|
|
1910
|
-
|
|
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
|
-
});
|
|
2428
|
+
},
|
|
2429
|
+
dlqHeaderKeys: _KafkaClient.DLQ_HEADER_KEYS
|
|
1952
2430
|
});
|
|
1953
|
-
this.logger.log(
|
|
1954
|
-
`replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
|
|
1955
|
-
);
|
|
1956
|
-
return { replayed, skipped };
|
|
1957
2431
|
}
|
|
1958
2432
|
async resetOffsets(groupId, topic2, position) {
|
|
1959
|
-
|
|
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
|
-
);
|
|
2433
|
+
return this.adminOps.resetOffsets(groupId, topic2, position);
|
|
1975
2434
|
}
|
|
1976
2435
|
/**
|
|
1977
2436
|
* Seek specific topic-partition pairs to explicit offsets for a stopped consumer group.
|
|
@@ -1979,25 +2438,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1979
2438
|
* Assignments are grouped by topic and committed via `admin.setOffsets`.
|
|
1980
2439
|
*/
|
|
1981
2440
|
async seekToOffset(groupId, assignments) {
|
|
1982
|
-
|
|
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
|
-
}
|
|
2441
|
+
return this.adminOps.seekToOffset(groupId, assignments);
|
|
2001
2442
|
}
|
|
2002
2443
|
/**
|
|
2003
2444
|
* Seek specific topic-partition pairs to the offset nearest to a given timestamp
|
|
@@ -2008,37 +2449,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
2008
2449
|
* future timestamp), the partition falls back to `-1` (end of topic — new messages only).
|
|
2009
2450
|
*/
|
|
2010
2451
|
async seekToTimestamp(groupId, assignments) {
|
|
2011
|
-
|
|
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
|
-
}
|
|
2452
|
+
return this.adminOps.seekToTimestamp(groupId, assignments);
|
|
2042
2453
|
}
|
|
2043
2454
|
/**
|
|
2044
2455
|
* Returns the current circuit breaker state for a specific topic partition.
|
|
@@ -2052,14 +2463,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
2052
2463
|
* @returns `{ status, failures, windowSize }` snapshot for a given partition or `undefined` if no state exists.
|
|
2053
2464
|
*/
|
|
2054
2465
|
getCircuitState(topic2, partition, groupId) {
|
|
2055
|
-
|
|
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
|
-
};
|
|
2466
|
+
return this.circuitBreaker.getState(topic2, partition, groupId ?? this.defaultGroupId);
|
|
2063
2467
|
}
|
|
2064
2468
|
/**
|
|
2065
2469
|
* Query consumer group lag per partition.
|
|
@@ -2073,83 +2477,32 @@ var KafkaClient = class _KafkaClient {
|
|
|
2073
2477
|
* committed offset. Use `checkStatus()` to verify broker connectivity in that case.
|
|
2074
2478
|
*/
|
|
2075
2479
|
async getConsumerLag(groupId) {
|
|
2076
|
-
|
|
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;
|
|
2480
|
+
return this.adminOps.getConsumerLag(groupId);
|
|
2096
2481
|
}
|
|
2097
2482
|
/** Check broker connectivity. Never throws — returns a discriminated union. */
|
|
2098
2483
|
async checkStatus() {
|
|
2099
|
-
|
|
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
|
-
}
|
|
2484
|
+
return this.adminOps.checkStatus();
|
|
2110
2485
|
}
|
|
2111
2486
|
/**
|
|
2112
2487
|
* List all consumer groups known to the broker.
|
|
2113
2488
|
* Useful for monitoring which groups are active and their current state.
|
|
2114
2489
|
*/
|
|
2115
2490
|
async listConsumerGroups() {
|
|
2116
|
-
|
|
2117
|
-
const result = await this.admin.listGroups();
|
|
2118
|
-
return result.groups.map((g) => ({
|
|
2119
|
-
groupId: g.groupId,
|
|
2120
|
-
state: g.state ?? "Unknown"
|
|
2121
|
-
}));
|
|
2491
|
+
return this.adminOps.listConsumerGroups();
|
|
2122
2492
|
}
|
|
2123
2493
|
/**
|
|
2124
2494
|
* Describe topics — returns partition layout, leader, replicas, and ISR.
|
|
2125
2495
|
* @param topics Topic names to describe. Omit to describe all topics.
|
|
2126
2496
|
*/
|
|
2127
2497
|
async describeTopics(topics) {
|
|
2128
|
-
|
|
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
|
-
}));
|
|
2498
|
+
return this.adminOps.describeTopics(topics);
|
|
2145
2499
|
}
|
|
2146
2500
|
/**
|
|
2147
2501
|
* Delete records from a topic up to (but not including) the given offsets.
|
|
2148
2502
|
* All messages with offsets **before** the given offset are deleted.
|
|
2149
2503
|
*/
|
|
2150
2504
|
async deleteRecords(topic2, partitions) {
|
|
2151
|
-
|
|
2152
|
-
await this.admin.deleteTopicRecords({ topic: topic2, partitions });
|
|
2505
|
+
return this.adminOps.deleteRecords(topic2, partitions);
|
|
2153
2506
|
}
|
|
2154
2507
|
/** Return the client ID provided during `KafkaClient` construction. */
|
|
2155
2508
|
getClientId() {
|
|
@@ -2165,39 +2518,19 @@ var KafkaClient = class _KafkaClient {
|
|
|
2165
2518
|
* @returns Read-only `KafkaMetrics` snapshot: `processedCount`, `retryCount`, `dlqCount`, `dedupCount`.
|
|
2166
2519
|
*/
|
|
2167
2520
|
getMetrics(topic2) {
|
|
2168
|
-
|
|
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;
|
|
2521
|
+
return this.metrics.getMetrics(topic2);
|
|
2185
2522
|
}
|
|
2186
2523
|
/**
|
|
2187
2524
|
* Reset internal event counters to zero.
|
|
2188
2525
|
*
|
|
2189
2526
|
* @param topic Topic name to reset. When omitted, all topics are reset.
|
|
2190
|
-
*/
|
|
2191
|
-
resetMetrics(topic2) {
|
|
2192
|
-
|
|
2193
|
-
this._topicMetrics.delete(topic2);
|
|
2194
|
-
return;
|
|
2195
|
-
}
|
|
2196
|
-
this._topicMetrics.clear();
|
|
2527
|
+
*/
|
|
2528
|
+
resetMetrics(topic2) {
|
|
2529
|
+
this.metrics.resetMetrics(topic2);
|
|
2197
2530
|
}
|
|
2198
2531
|
/** Gracefully disconnect producer, all consumers, and admin. */
|
|
2199
2532
|
async disconnect(drainTimeoutMs = 3e4) {
|
|
2200
|
-
await this.waitForDrain(drainTimeoutMs);
|
|
2533
|
+
await this.inFlight.waitForDrain(drainTimeoutMs);
|
|
2201
2534
|
const tasks = [this.producer.disconnect()];
|
|
2202
2535
|
if (this.txProducer) {
|
|
2203
2536
|
tasks.push(this.txProducer.disconnect());
|
|
@@ -2205,28 +2538,17 @@ var KafkaClient = class _KafkaClient {
|
|
|
2205
2538
|
this.txProducer = void 0;
|
|
2206
2539
|
this.txProducerInitPromise = void 0;
|
|
2207
2540
|
}
|
|
2208
|
-
for (const txId of this.retryTxProducers.keys())
|
|
2209
|
-
|
|
2210
|
-
}
|
|
2211
|
-
for (const p of this.retryTxProducers.values()) {
|
|
2212
|
-
tasks.push(p.disconnect());
|
|
2213
|
-
}
|
|
2541
|
+
for (const txId of this.retryTxProducers.keys()) _activeTransactionalIds.delete(txId);
|
|
2542
|
+
for (const p of this.retryTxProducers.values()) tasks.push(p.disconnect());
|
|
2214
2543
|
this.retryTxProducers.clear();
|
|
2215
|
-
for (const consumer of this.consumers.values())
|
|
2216
|
-
|
|
2217
|
-
}
|
|
2218
|
-
if (this.isAdminConnected) {
|
|
2219
|
-
tasks.push(this.admin.disconnect());
|
|
2220
|
-
this.isAdminConnected = false;
|
|
2221
|
-
}
|
|
2544
|
+
for (const consumer of this.consumers.values()) tasks.push(consumer.disconnect());
|
|
2545
|
+
tasks.push(this.adminOps.disconnect());
|
|
2222
2546
|
await Promise.allSettled(tasks);
|
|
2223
2547
|
this.consumers.clear();
|
|
2224
2548
|
this.runningConsumers.clear();
|
|
2225
2549
|
this.consumerCreationOptions.clear();
|
|
2226
2550
|
this.companionGroupIds.clear();
|
|
2227
|
-
|
|
2228
|
-
this.circuitStates.clear();
|
|
2229
|
-
this.circuitConfigs.clear();
|
|
2551
|
+
this.circuitBreaker.clear();
|
|
2230
2552
|
this.logger.log("All connections closed");
|
|
2231
2553
|
}
|
|
2232
2554
|
// ── Graceful shutdown ────────────────────────────────────────────
|
|
@@ -2245,235 +2567,20 @@ var KafkaClient = class _KafkaClient {
|
|
|
2245
2567
|
*/
|
|
2246
2568
|
enableGracefulShutdown(signals = ["SIGTERM", "SIGINT"], drainTimeoutMs = 3e4) {
|
|
2247
2569
|
const handler = () => {
|
|
2248
|
-
this.logger.log(
|
|
2249
|
-
"Shutdown signal received \u2014 draining in-flight handlers..."
|
|
2250
|
-
);
|
|
2570
|
+
this.logger.log("Shutdown signal received \u2014 draining in-flight handlers...");
|
|
2251
2571
|
this.disconnect(drainTimeoutMs).catch(
|
|
2252
|
-
(err) => this.logger.error(
|
|
2253
|
-
"Error during graceful shutdown:",
|
|
2254
|
-
toError(err).message
|
|
2255
|
-
)
|
|
2572
|
+
(err) => this.logger.error("Error during graceful shutdown:", toError(err).message)
|
|
2256
2573
|
);
|
|
2257
2574
|
};
|
|
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
|
-
});
|
|
2575
|
+
for (const signal of signals) process.once(signal, handler);
|
|
2302
2576
|
}
|
|
2303
2577
|
// ── 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
2578
|
async preparePayload(topicOrDesc, messages, compression) {
|
|
2311
2579
|
registerSchema(topicOrDesc, this.schemaRegistry, this.logger);
|
|
2312
|
-
const payload = await buildSendPayload(
|
|
2313
|
-
topicOrDesc,
|
|
2314
|
-
messages,
|
|
2315
|
-
this.producerOpsDeps,
|
|
2316
|
-
compression
|
|
2317
|
-
);
|
|
2580
|
+
const payload = await buildSendPayload(topicOrDesc, messages, this._producerOpsDeps, compression);
|
|
2318
2581
|
await this.ensureTopic(payload.topic);
|
|
2319
2582
|
return payload;
|
|
2320
2583
|
}
|
|
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
2584
|
/**
|
|
2478
2585
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
2479
2586
|
* The handler itself is not cancelled — the warning is diagnostic only.
|
|
@@ -2484,79 +2591,10 @@ var KafkaClient = class _KafkaClient {
|
|
|
2484
2591
|
if (timer !== void 0) clearTimeout(timer);
|
|
2485
2592
|
});
|
|
2486
2593
|
timer = setTimeout(() => {
|
|
2487
|
-
this.logger.warn(
|
|
2488
|
-
`Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`
|
|
2489
|
-
);
|
|
2594
|
+
this.logger.warn(`Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`);
|
|
2490
2595
|
}, timeoutMs);
|
|
2491
2596
|
return promise;
|
|
2492
2597
|
}
|
|
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
2598
|
/**
|
|
2561
2599
|
* Create and connect a transactional producer for EOS retry routing.
|
|
2562
2600
|
* Each retry level consumer gets its own producer with a unique `transactionalId`
|
|
@@ -2569,12 +2607,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
2569
2607
|
);
|
|
2570
2608
|
}
|
|
2571
2609
|
const p = this.kafka.producer({
|
|
2572
|
-
kafkaJS: {
|
|
2573
|
-
acks: -1,
|
|
2574
|
-
idempotent: true,
|
|
2575
|
-
transactionalId,
|
|
2576
|
-
maxInFlightRequests: 1
|
|
2577
|
-
}
|
|
2610
|
+
kafkaJS: { acks: -1, idempotent: true, transactionalId, maxInFlightRequests: 1 }
|
|
2578
2611
|
});
|
|
2579
2612
|
await p.connect();
|
|
2580
2613
|
_activeTransactionalIds.add(transactionalId);
|
|
@@ -2583,20 +2616,16 @@ var KafkaClient = class _KafkaClient {
|
|
|
2583
2616
|
}
|
|
2584
2617
|
/**
|
|
2585
2618
|
* Ensure that a topic exists by creating it if it doesn't already exist.
|
|
2586
|
-
* If `autoCreateTopics` is disabled,
|
|
2587
|
-
*
|
|
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.
|
|
2619
|
+
* If `autoCreateTopics` is disabled, returns immediately.
|
|
2620
|
+
* Concurrent calls for the same topic are deduplicated.
|
|
2592
2621
|
*/
|
|
2593
2622
|
async ensureTopic(topic2) {
|
|
2594
2623
|
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
2595
2624
|
let p = this.ensureTopicPromises.get(topic2);
|
|
2596
2625
|
if (!p) {
|
|
2597
2626
|
p = (async () => {
|
|
2598
|
-
await this.
|
|
2599
|
-
await this.admin.createTopics({
|
|
2627
|
+
await this.adminOps.ensureConnected();
|
|
2628
|
+
await this.adminOps.admin.createTopics({
|
|
2600
2629
|
topics: [{ topic: topic2, numPartitions: this.numPartitions }]
|
|
2601
2630
|
});
|
|
2602
2631
|
this.ensuredTopics.add(topic2);
|
|
@@ -2636,99 +2665,78 @@ var KafkaClient = class _KafkaClient {
|
|
|
2636
2665
|
gid,
|
|
2637
2666
|
fromBeginning,
|
|
2638
2667
|
options.autoCommit ?? true,
|
|
2639
|
-
this.
|
|
2668
|
+
this._consumerOpsDeps,
|
|
2640
2669
|
options.partitionAssigner
|
|
2641
2670
|
);
|
|
2642
|
-
const schemaMap = buildSchemaMap(
|
|
2643
|
-
stringTopics,
|
|
2644
|
-
this.schemaRegistry,
|
|
2645
|
-
optionSchemas,
|
|
2646
|
-
this.logger
|
|
2647
|
-
);
|
|
2671
|
+
const schemaMap = buildSchemaMap(stringTopics, this.schemaRegistry, optionSchemas, this.logger);
|
|
2648
2672
|
const topicNames = stringTopics.map((t) => resolveTopicName(t));
|
|
2649
|
-
const subscribeTopics = [
|
|
2650
|
-
|
|
2651
|
-
...regexTopics
|
|
2652
|
-
];
|
|
2653
|
-
for (const t of topicNames) {
|
|
2654
|
-
await this.ensureTopic(t);
|
|
2655
|
-
}
|
|
2673
|
+
const subscribeTopics = [...topicNames, ...regexTopics];
|
|
2674
|
+
for (const t of topicNames) await this.ensureTopic(t);
|
|
2656
2675
|
if (dlq) {
|
|
2657
|
-
for (const t of topicNames) {
|
|
2658
|
-
await this.ensureTopic(`${t}.dlq`);
|
|
2659
|
-
}
|
|
2676
|
+
for (const t of topicNames) await this.ensureTopic(`${t}.dlq`);
|
|
2660
2677
|
if (!this.autoCreateTopicsEnabled && topicNames.length > 0) {
|
|
2661
|
-
await this.validateDlqTopicsExist(topicNames);
|
|
2678
|
+
await this.adminOps.validateDlqTopicsExist(topicNames);
|
|
2662
2679
|
}
|
|
2663
2680
|
}
|
|
2664
2681
|
if (options.deduplication?.strategy === "topic") {
|
|
2665
2682
|
const dest = options.deduplication.duplicatesTopic;
|
|
2666
2683
|
if (this.autoCreateTopicsEnabled) {
|
|
2667
|
-
for (const t of topicNames) {
|
|
2668
|
-
await this.ensureTopic(dest ?? `${t}.duplicates`);
|
|
2669
|
-
}
|
|
2684
|
+
for (const t of topicNames) await this.ensureTopic(dest ?? `${t}.duplicates`);
|
|
2670
2685
|
} else if (topicNames.length > 0) {
|
|
2671
|
-
await this.validateDuplicatesTopicsExist(topicNames, dest);
|
|
2686
|
+
await this.adminOps.validateDuplicatesTopicsExist(topicNames, dest);
|
|
2672
2687
|
}
|
|
2673
2688
|
}
|
|
2674
2689
|
await consumer.connect();
|
|
2675
|
-
await subscribeWithRetry(
|
|
2676
|
-
consumer,
|
|
2677
|
-
subscribeTopics,
|
|
2678
|
-
this.logger,
|
|
2679
|
-
options.subscribeRetry
|
|
2680
|
-
);
|
|
2690
|
+
await subscribeWithRetry(consumer, subscribeTopics, this.logger, options.subscribeRetry);
|
|
2681
2691
|
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
|
-
);
|
|
2692
|
+
this.logger.log(`${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${displayTopics}`);
|
|
2685
2693
|
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry, hasRegex };
|
|
2686
2694
|
}
|
|
2687
2695
|
/** Create or retrieve the deduplication context for a consumer group. */
|
|
2688
2696
|
resolveDeduplicationContext(groupId, options) {
|
|
2689
2697
|
if (!options) return void 0;
|
|
2690
|
-
if (!this.dedupStates.has(groupId))
|
|
2691
|
-
this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
|
|
2692
|
-
}
|
|
2698
|
+
if (!this.dedupStates.has(groupId)) this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
|
|
2693
2699
|
return { options, state: this.dedupStates.get(groupId) };
|
|
2694
2700
|
}
|
|
2695
|
-
// ──
|
|
2696
|
-
/**
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
strictSchemasEnabled: this.strictSchemasEnabled,
|
|
2709
|
-
instrumentation: this.instrumentation,
|
|
2710
|
-
logger: this.logger,
|
|
2711
|
-
nextLamportClock: () => ++this._lamportClock
|
|
2712
|
-
};
|
|
2701
|
+
// ── Shared consumer setup helpers ────────────────────────────────
|
|
2702
|
+
/** Guard checks shared by startConsumer and startBatchConsumer. */
|
|
2703
|
+
validateTopicConsumerOpts(topics, options) {
|
|
2704
|
+
if (options.retryTopics && !options.retry) {
|
|
2705
|
+
throw new Error(
|
|
2706
|
+
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
2707
|
+
);
|
|
2708
|
+
}
|
|
2709
|
+
if (options.retryTopics && topics.some((t) => t instanceof RegExp)) {
|
|
2710
|
+
throw new Error(
|
|
2711
|
+
"retryTopics is incompatible with regex topic patterns \u2014 retry topics require a fixed topic name to build the retry chain."
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2713
2714
|
}
|
|
2714
|
-
/**
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2715
|
+
/** Create EOS transactional producer context for atomic main → retry.1 routing. */
|
|
2716
|
+
async makeEosMainContext(gid, consumer, options) {
|
|
2717
|
+
if (!options.retryTopics || !options.retry) return void 0;
|
|
2718
|
+
const txProducer = await this.createRetryTxProducer(`${gid}-main-tx`);
|
|
2719
|
+
return { txProducer, consumer };
|
|
2720
|
+
}
|
|
2721
|
+
/** Start companion retry-level consumers and register them under the main groupId. */
|
|
2722
|
+
async launchRetryChain(gid, topicNames, handleMessage, retry, dlq, interceptors, schemaMap, assignmentTimeoutMs) {
|
|
2723
|
+
if (!this.autoCreateTopicsEnabled) {
|
|
2724
|
+
await this.adminOps.validateRetryTopicsExist(topicNames, retry.maxRetries);
|
|
2725
|
+
}
|
|
2726
|
+
const companions = await startRetryTopicConsumers(
|
|
2727
|
+
topicNames,
|
|
2728
|
+
gid,
|
|
2729
|
+
handleMessage,
|
|
2730
|
+
retry,
|
|
2731
|
+
dlq,
|
|
2732
|
+
interceptors,
|
|
2733
|
+
schemaMap,
|
|
2734
|
+
this._retryTopicDeps,
|
|
2735
|
+
assignmentTimeoutMs
|
|
2736
|
+
);
|
|
2737
|
+
this.companionGroupIds.set(gid, companions);
|
|
2731
2738
|
}
|
|
2739
|
+
// ── Deps object builders ─────────────────────────────────────────
|
|
2732
2740
|
/** Build MessageHandlerDeps with circuit breaker callbacks bound to the given groupId. */
|
|
2733
2741
|
messageDepsFor(gid) {
|
|
2734
2742
|
return {
|
|
@@ -2737,38 +2745,24 @@ var KafkaClient = class _KafkaClient {
|
|
|
2737
2745
|
instrumentation: this.instrumentation,
|
|
2738
2746
|
onMessageLost: this.onMessageLost,
|
|
2739
2747
|
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)
|
|
2748
|
+
onRetry: this.metrics.notifyRetry.bind(this.metrics),
|
|
2749
|
+
onDlq: (envelope, reason) => this.metrics.notifyDlq(envelope, reason, gid),
|
|
2750
|
+
onDuplicate: this.metrics.notifyDuplicate.bind(this.metrics),
|
|
2751
|
+
onMessage: (envelope) => this.metrics.notifyMessage(envelope, gid)
|
|
2744
2752
|
};
|
|
2745
2753
|
}
|
|
2746
|
-
/**
|
|
2747
|
-
|
|
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() {
|
|
2754
|
+
/** Build the deps object passed to retry topic consumers. */
|
|
2755
|
+
buildRetryTopicDeps() {
|
|
2762
2756
|
return {
|
|
2763
2757
|
logger: this.logger,
|
|
2764
2758
|
producer: this.producer,
|
|
2765
2759
|
instrumentation: this.instrumentation,
|
|
2766
2760
|
onMessageLost: this.onMessageLost,
|
|
2767
|
-
onRetry: this.notifyRetry.bind(this),
|
|
2768
|
-
onDlq: this.notifyDlq.bind(this),
|
|
2769
|
-
onMessage: this.notifyMessage.bind(this),
|
|
2761
|
+
onRetry: this.metrics.notifyRetry.bind(this.metrics),
|
|
2762
|
+
onDlq: this.metrics.notifyDlq.bind(this.metrics),
|
|
2763
|
+
onMessage: this.metrics.notifyMessage.bind(this.metrics),
|
|
2770
2764
|
ensureTopic: (t) => this.ensureTopic(t),
|
|
2771
|
-
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.
|
|
2765
|
+
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this._consumerOpsDeps),
|
|
2772
2766
|
runningConsumers: this.runningConsumers,
|
|
2773
2767
|
createRetryTxProducer: (txId) => this.createRetryTxProducer(txId)
|
|
2774
2768
|
};
|