@drarzter/kafka-client 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -8
- package/dist/{chunk-AMEGMOZH.mjs → chunk-XP7LLRGQ.mjs} +1026 -849
- package/dist/chunk-XP7LLRGQ.mjs.map +1 -0
- package/dist/core.d.mts +167 -56
- package/dist/core.d.ts +167 -56
- package/dist/core.js +1025 -848
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/index.d.mts +37 -4
- package/dist/index.d.ts +37 -4
- package/dist/index.js +1031 -848
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -1
- package/dist/index.mjs.map +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-BEIGjmV6.d.mts → types-4qWrf2aJ.d.mts} +136 -4
- package/dist/{types-BEIGjmV6.d.ts → types-4qWrf2aJ.d.ts} +136 -4
- package/package.json +1 -1
- package/dist/chunk-AMEGMOZH.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/client/kafka.client/index.ts
|
|
2
|
-
import { KafkaJS } from "@confluentinc/kafka-javascript";
|
|
2
|
+
import { KafkaJS as KafkaJS2 } from "@confluentinc/kafka-javascript";
|
|
3
3
|
|
|
4
4
|
// src/client/message/envelope.ts
|
|
5
5
|
import { AsyncLocalStorage } from "async_hooks";
|
|
@@ -95,7 +95,7 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
|
|
|
95
95
|
}
|
|
96
96
|
};
|
|
97
97
|
|
|
98
|
-
// src/client/kafka.client/producer
|
|
98
|
+
// src/client/kafka.client/producer/ops.ts
|
|
99
99
|
function resolveTopicName(topicOrDescriptor) {
|
|
100
100
|
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
101
101
|
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
@@ -140,7 +140,7 @@ async function validateMessage(topicOrDesc, message, deps, ctx) {
|
|
|
140
140
|
}
|
|
141
141
|
return message;
|
|
142
142
|
}
|
|
143
|
-
async function buildSendPayload(topicOrDesc, messages, deps) {
|
|
143
|
+
async function buildSendPayload(topicOrDesc, messages, deps, compression) {
|
|
144
144
|
const topic2 = resolveTopicName(topicOrDesc);
|
|
145
145
|
const builtMessages = await Promise.all(
|
|
146
146
|
messages.map(async (m) => {
|
|
@@ -170,11 +170,12 @@ async function buildSendPayload(topicOrDesc, messages, deps) {
|
|
|
170
170
|
};
|
|
171
171
|
})
|
|
172
172
|
);
|
|
173
|
-
return { topic: topic2, messages: builtMessages };
|
|
173
|
+
return { topic: topic2, messages: builtMessages, ...compression && { compression } };
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// src/client/kafka.client/consumer
|
|
177
|
-
|
|
176
|
+
// src/client/kafka.client/consumer/ops.ts
|
|
177
|
+
import { KafkaJS } from "@confluentinc/kafka-javascript";
|
|
178
|
+
function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps, partitionAssigner) {
|
|
178
179
|
const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
|
|
179
180
|
if (consumers.has(groupId)) {
|
|
180
181
|
const prev = consumerCreationOptions.get(groupId);
|
|
@@ -186,8 +187,11 @@ function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps) {
|
|
|
186
187
|
return consumers.get(groupId);
|
|
187
188
|
}
|
|
188
189
|
consumerCreationOptions.set(groupId, { fromBeginning, autoCommit });
|
|
190
|
+
const assigners = [
|
|
191
|
+
partitionAssigner === "roundrobin" ? KafkaJS.PartitionAssigners.roundRobin : partitionAssigner === "range" ? KafkaJS.PartitionAssigners.range : KafkaJS.PartitionAssigners.cooperativeSticky
|
|
192
|
+
];
|
|
189
193
|
const config = {
|
|
190
|
-
kafkaJS: { groupId, fromBeginning, autoCommit }
|
|
194
|
+
kafkaJS: { groupId, fromBeginning, autoCommit, partitionAssigners: assigners }
|
|
191
195
|
};
|
|
192
196
|
if (onRebalance) {
|
|
193
197
|
const cb = onRebalance;
|
|
@@ -232,7 +236,7 @@ function buildSchemaMap(topics, schemaRegistry, optionSchemas, logger) {
|
|
|
232
236
|
return schemaMap;
|
|
233
237
|
}
|
|
234
238
|
|
|
235
|
-
// src/client/consumer/pipeline.ts
|
|
239
|
+
// src/client/kafka.client/consumer/pipeline.ts
|
|
236
240
|
function toError(error) {
|
|
237
241
|
return error instanceof Error ? error : new Error(String(error));
|
|
238
242
|
}
|
|
@@ -582,7 +586,7 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
582
586
|
}
|
|
583
587
|
}
|
|
584
588
|
|
|
585
|
-
// src/client/kafka.client/
|
|
589
|
+
// src/client/kafka.client/consumer/handler.ts
|
|
586
590
|
async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
|
|
587
591
|
const clockRaw = envelope.headers[HEADER_LAMPORT_CLOCK];
|
|
588
592
|
if (clockRaw === void 0) return false;
|
|
@@ -732,7 +736,8 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
732
736
|
});
|
|
733
737
|
deps.onDlq?.(envelope, "ttl-expired");
|
|
734
738
|
} else {
|
|
735
|
-
|
|
739
|
+
const ttlHandler = opts.onTtlExpired ?? deps.onTtlExpired;
|
|
740
|
+
await ttlHandler?.({
|
|
736
741
|
topic: topic2,
|
|
737
742
|
ageMs,
|
|
738
743
|
messageTtlMs: opts.messageTtlMs,
|
|
@@ -859,7 +864,8 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
859
864
|
});
|
|
860
865
|
deps.onDlq?.(envelope, "ttl-expired");
|
|
861
866
|
} else {
|
|
862
|
-
|
|
867
|
+
const ttlHandler = opts.onTtlExpired ?? deps.onTtlExpired;
|
|
868
|
+
await ttlHandler?.({
|
|
863
869
|
topic: batch.topic,
|
|
864
870
|
ageMs,
|
|
865
871
|
messageTtlMs: opts.messageTtlMs,
|
|
@@ -901,10 +907,11 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
901
907
|
);
|
|
902
908
|
}
|
|
903
909
|
|
|
904
|
-
// src/client/consumer/subscribe-retry.ts
|
|
910
|
+
// src/client/kafka.client/consumer/subscribe-retry.ts
|
|
905
911
|
async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
906
912
|
const maxAttempts = retryOpts?.retries ?? 5;
|
|
907
913
|
const backoffMs = retryOpts?.backoffMs ?? 5e3;
|
|
914
|
+
const displayTopics = topics.map((t) => t instanceof RegExp ? t.toString() : t).join(", ");
|
|
908
915
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
909
916
|
try {
|
|
910
917
|
await consumer.subscribe({ topics });
|
|
@@ -914,14 +921,14 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
|
|
|
914
921
|
const msg = toError(error).message;
|
|
915
922
|
const delay = Math.floor(Math.random() * backoffMs);
|
|
916
923
|
logger.warn(
|
|
917
|
-
`Failed to subscribe to [${
|
|
924
|
+
`Failed to subscribe to [${displayTopics}] (attempt ${attempt}/${maxAttempts}): ${msg}. Retrying in ${delay}ms...`
|
|
918
925
|
);
|
|
919
926
|
await sleep(delay);
|
|
920
927
|
}
|
|
921
928
|
}
|
|
922
929
|
}
|
|
923
930
|
|
|
924
|
-
// src/client/kafka.client/retry-topic.ts
|
|
931
|
+
// src/client/kafka.client/consumer/retry-topic.ts
|
|
925
932
|
async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
|
|
926
933
|
const topicSet = new Set(topics);
|
|
927
934
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1175,9 +1182,7 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
|
|
|
1175
1182
|
return levelGroupIds;
|
|
1176
1183
|
}
|
|
1177
1184
|
|
|
1178
|
-
// src/client/kafka.client/
|
|
1179
|
-
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS;
|
|
1180
|
-
var _activeTransactionalIds = /* @__PURE__ */ new Set();
|
|
1185
|
+
// src/client/kafka.client/consumer/queue.ts
|
|
1181
1186
|
var AsyncQueue = class {
|
|
1182
1187
|
constructor(highWaterMark = Infinity, onFull = () => {
|
|
1183
1188
|
}, onDrained = () => {
|
|
@@ -1226,216 +1231,806 @@ var AsyncQueue = class {
|
|
|
1226
1231
|
return new Promise((resolve, reject) => this.waiting.push({ resolve, reject }));
|
|
1227
1232
|
}
|
|
1228
1233
|
};
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
/** Maps transactionalId → Producer for each active retry level consumer. */
|
|
1235
|
-
retryTxProducers = /* @__PURE__ */ new Map();
|
|
1236
|
-
consumers = /* @__PURE__ */ new Map();
|
|
1237
|
-
admin;
|
|
1238
|
-
logger;
|
|
1239
|
-
autoCreateTopicsEnabled;
|
|
1240
|
-
strictSchemasEnabled;
|
|
1241
|
-
numPartitions;
|
|
1242
|
-
ensuredTopics = /* @__PURE__ */ new Set();
|
|
1243
|
-
/** Pending topic-creation promises keyed by topic name. Prevents duplicate createTopics calls. */
|
|
1244
|
-
ensureTopicPromises = /* @__PURE__ */ new Map();
|
|
1245
|
-
defaultGroupId;
|
|
1246
|
-
schemaRegistry = /* @__PURE__ */ new Map();
|
|
1247
|
-
runningConsumers = /* @__PURE__ */ new Map();
|
|
1248
|
-
consumerCreationOptions = /* @__PURE__ */ new Map();
|
|
1249
|
-
/** Maps each main consumer groupId to its companion retry level groupIds. */
|
|
1250
|
-
companionGroupIds = /* @__PURE__ */ new Map();
|
|
1251
|
-
instrumentation;
|
|
1252
|
-
onMessageLost;
|
|
1253
|
-
onTtlExpired;
|
|
1254
|
-
onRebalance;
|
|
1255
|
-
/** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
|
|
1256
|
-
txId;
|
|
1257
|
-
/** Per-topic event counters, lazily created on first event. Aggregated by `getMetrics()`. */
|
|
1258
|
-
_topicMetrics = /* @__PURE__ */ new Map();
|
|
1259
|
-
/** Monotonically increasing Lamport clock stamped on every outgoing message. */
|
|
1260
|
-
_lamportClock = 0;
|
|
1261
|
-
/** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
|
|
1262
|
-
dedupStates = /* @__PURE__ */ new Map();
|
|
1263
|
-
/** Circuit breaker state per `"${gid}:${topic}:${partition}"` key. */
|
|
1264
|
-
circuitStates = /* @__PURE__ */ new Map();
|
|
1265
|
-
/** Circuit breaker config per groupId, set at startConsumer/startBatchConsumer time. */
|
|
1266
|
-
circuitConfigs = /* @__PURE__ */ new Map();
|
|
1267
|
-
isAdminConnected = false;
|
|
1268
|
-
inFlightTotal = 0;
|
|
1269
|
-
drainResolvers = [];
|
|
1270
|
-
clientId;
|
|
1271
|
-
constructor(clientId, groupId, brokers, options) {
|
|
1272
|
-
this.clientId = clientId;
|
|
1273
|
-
this.defaultGroupId = groupId;
|
|
1274
|
-
this.logger = options?.logger ?? {
|
|
1275
|
-
log: (msg) => console.log(`[KafkaClient:${clientId}] ${msg}`),
|
|
1276
|
-
warn: (msg, ...args) => console.warn(`[KafkaClient:${clientId}] ${msg}`, ...args),
|
|
1277
|
-
error: (msg, ...args) => console.error(`[KafkaClient:${clientId}] ${msg}`, ...args),
|
|
1278
|
-
debug: (msg, ...args) => console.debug(`[KafkaClient:${clientId}] ${msg}`, ...args)
|
|
1279
|
-
};
|
|
1280
|
-
this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
|
|
1281
|
-
this.strictSchemasEnabled = options?.strictSchemas ?? true;
|
|
1282
|
-
this.numPartitions = options?.numPartitions ?? 1;
|
|
1283
|
-
this.instrumentation = options?.instrumentation ?? [];
|
|
1284
|
-
this.onMessageLost = options?.onMessageLost;
|
|
1285
|
-
this.onTtlExpired = options?.onTtlExpired;
|
|
1286
|
-
this.onRebalance = options?.onRebalance;
|
|
1287
|
-
this.txId = options?.transactionalId ?? `${clientId}-tx`;
|
|
1288
|
-
this.kafka = new KafkaClass({
|
|
1289
|
-
kafkaJS: {
|
|
1290
|
-
clientId: this.clientId,
|
|
1291
|
-
brokers,
|
|
1292
|
-
logLevel: KafkaLogLevel.ERROR
|
|
1293
|
-
}
|
|
1294
|
-
});
|
|
1295
|
-
this.producer = this.kafka.producer({
|
|
1296
|
-
kafkaJS: {
|
|
1297
|
-
acks: -1
|
|
1298
|
-
}
|
|
1299
|
-
});
|
|
1300
|
-
this.admin = this.kafka.admin();
|
|
1234
|
+
|
|
1235
|
+
// src/client/kafka.client/infra/circuit-breaker.ts
|
|
1236
|
+
var CircuitBreakerManager = class {
|
|
1237
|
+
constructor(deps) {
|
|
1238
|
+
this.deps = deps;
|
|
1301
1239
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
key: options.key,
|
|
1307
|
-
headers: options.headers,
|
|
1308
|
-
correlationId: options.correlationId,
|
|
1309
|
-
schemaVersion: options.schemaVersion,
|
|
1310
|
-
eventId: options.eventId
|
|
1311
|
-
}
|
|
1312
|
-
]);
|
|
1313
|
-
await this.producer.send(payload);
|
|
1314
|
-
this.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1240
|
+
states = /* @__PURE__ */ new Map();
|
|
1241
|
+
configs = /* @__PURE__ */ new Map();
|
|
1242
|
+
setConfig(gid, options) {
|
|
1243
|
+
this.configs.set(gid, options);
|
|
1315
1244
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1245
|
+
/**
|
|
1246
|
+
* Returns a snapshot of the circuit breaker state for a given topic-partition.
|
|
1247
|
+
* Returns `undefined` when no state exists for the key.
|
|
1248
|
+
*/
|
|
1249
|
+
getState(topic2, partition, gid) {
|
|
1250
|
+
const state = this.states.get(`${gid}:${topic2}:${partition}`);
|
|
1251
|
+
if (!state) return void 0;
|
|
1252
|
+
return {
|
|
1253
|
+
status: state.status,
|
|
1254
|
+
failures: state.window.filter((v) => !v).length,
|
|
1255
|
+
windowSize: state.window.length
|
|
1256
|
+
};
|
|
1320
1257
|
}
|
|
1321
|
-
/**
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1258
|
+
/**
|
|
1259
|
+
* Record a failure for the given envelope and group.
|
|
1260
|
+
* Drives the CLOSED → OPEN and HALF-OPEN → OPEN transitions.
|
|
1261
|
+
*/
|
|
1262
|
+
onFailure(envelope, gid) {
|
|
1263
|
+
const cfg = this.configs.get(gid);
|
|
1264
|
+
if (!cfg) return;
|
|
1265
|
+
const threshold = cfg.threshold ?? 5;
|
|
1266
|
+
const recoveryMs = cfg.recoveryMs ?? 3e4;
|
|
1267
|
+
const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
|
|
1268
|
+
let state = this.states.get(stateKey);
|
|
1269
|
+
if (!state) {
|
|
1270
|
+
state = { status: "closed", window: [], successes: 0 };
|
|
1271
|
+
this.states.set(stateKey, state);
|
|
1272
|
+
}
|
|
1273
|
+
if (state.status === "open") return;
|
|
1274
|
+
const openCircuit = () => {
|
|
1275
|
+
state.status = "open";
|
|
1276
|
+
state.window = [];
|
|
1277
|
+
state.successes = 0;
|
|
1278
|
+
clearTimeout(state.timer);
|
|
1279
|
+
for (const inst of this.deps.instrumentation)
|
|
1280
|
+
inst.onCircuitOpen?.(envelope.topic, envelope.partition);
|
|
1281
|
+
this.deps.pauseConsumer(gid, [{ topic: envelope.topic, partitions: [envelope.partition] }]);
|
|
1282
|
+
state.timer = setTimeout(() => {
|
|
1283
|
+
state.status = "half-open";
|
|
1284
|
+
state.successes = 0;
|
|
1285
|
+
this.deps.logger.log(
|
|
1286
|
+
`[CircuitBreaker] HALF-OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
|
|
1327
1287
|
);
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
return p;
|
|
1341
|
-
})();
|
|
1342
|
-
this.txProducerInitPromise = initPromise.catch((err) => {
|
|
1343
|
-
this.txProducerInitPromise = void 0;
|
|
1344
|
-
throw err;
|
|
1345
|
-
});
|
|
1288
|
+
for (const inst of this.deps.instrumentation)
|
|
1289
|
+
inst.onCircuitHalfOpen?.(envelope.topic, envelope.partition);
|
|
1290
|
+
this.deps.resumeConsumer(gid, [{ topic: envelope.topic, partitions: [envelope.partition] }]);
|
|
1291
|
+
}, recoveryMs);
|
|
1292
|
+
};
|
|
1293
|
+
if (state.status === "half-open") {
|
|
1294
|
+
clearTimeout(state.timer);
|
|
1295
|
+
this.deps.logger.warn(
|
|
1296
|
+
`[CircuitBreaker] OPEN (half-open failure) \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
|
|
1297
|
+
);
|
|
1298
|
+
openCircuit();
|
|
1299
|
+
return;
|
|
1346
1300
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1301
|
+
const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
|
|
1302
|
+
state.window = [...state.window, false];
|
|
1303
|
+
if (state.window.length > windowSize) {
|
|
1304
|
+
state.window = state.window.slice(state.window.length - windowSize);
|
|
1305
|
+
}
|
|
1306
|
+
const failures = state.window.filter((v) => !v).length;
|
|
1307
|
+
if (failures >= threshold) {
|
|
1308
|
+
this.deps.logger.warn(
|
|
1309
|
+
`[CircuitBreaker] OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition} (${failures}/${state.window.length} failures, threshold=${threshold})`
|
|
1310
|
+
);
|
|
1311
|
+
openCircuit();
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Record a success for the given envelope and group.
|
|
1316
|
+
* Drives the HALF-OPEN → CLOSED transition and updates the success window.
|
|
1317
|
+
*/
|
|
1318
|
+
onSuccess(envelope, gid) {
|
|
1319
|
+
const cfg = this.configs.get(gid);
|
|
1320
|
+
if (!cfg) return;
|
|
1321
|
+
const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
|
|
1322
|
+
const state = this.states.get(stateKey);
|
|
1323
|
+
if (!state) return;
|
|
1324
|
+
const halfOpenSuccesses = cfg.halfOpenSuccesses ?? 1;
|
|
1325
|
+
if (state.status === "half-open") {
|
|
1326
|
+
state.successes++;
|
|
1327
|
+
if (state.successes >= halfOpenSuccesses) {
|
|
1328
|
+
clearTimeout(state.timer);
|
|
1329
|
+
state.timer = void 0;
|
|
1330
|
+
state.status = "closed";
|
|
1331
|
+
state.window = [];
|
|
1332
|
+
state.successes = 0;
|
|
1333
|
+
this.deps.logger.log(
|
|
1334
|
+
`[CircuitBreaker] CLOSED \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
|
|
1380
1335
|
);
|
|
1336
|
+
for (const inst of this.deps.instrumentation)
|
|
1337
|
+
inst.onCircuitClose?.(envelope.topic, envelope.partition);
|
|
1338
|
+
}
|
|
1339
|
+
} else if (state.status === "closed") {
|
|
1340
|
+
const threshold = cfg.threshold ?? 5;
|
|
1341
|
+
const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
|
|
1342
|
+
state.window = [...state.window, true];
|
|
1343
|
+
if (state.window.length > windowSize) {
|
|
1344
|
+
state.window = state.window.slice(state.window.length - windowSize);
|
|
1381
1345
|
}
|
|
1382
|
-
throw error;
|
|
1383
1346
|
}
|
|
1384
1347
|
}
|
|
1385
|
-
// ── Producer lifecycle ───────────────────────────────────────────
|
|
1386
1348
|
/**
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1349
|
+
* Remove all circuit state and config for the given group.
|
|
1350
|
+
* Called when a consumer is stopped via `stopConsumer(groupId)`.
|
|
1389
1351
|
*/
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1352
|
+
removeGroup(gid) {
|
|
1353
|
+
for (const key of [...this.states.keys()]) {
|
|
1354
|
+
if (key.startsWith(`${gid}:`)) {
|
|
1355
|
+
clearTimeout(this.states.get(key).timer);
|
|
1356
|
+
this.states.delete(key);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
this.configs.delete(gid);
|
|
1360
|
+
}
|
|
1361
|
+
/** Clear all circuit state and config. Called on `disconnect()`. */
|
|
1362
|
+
clear() {
|
|
1363
|
+
for (const state of this.states.values()) clearTimeout(state.timer);
|
|
1364
|
+
this.states.clear();
|
|
1365
|
+
this.configs.clear();
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// src/client/kafka.client/admin/ops.ts
|
|
1370
|
+
var AdminOps = class {
|
|
1371
|
+
constructor(deps) {
|
|
1372
|
+
this.deps = deps;
|
|
1373
|
+
}
|
|
1374
|
+
isConnected = false;
|
|
1375
|
+
/** Underlying admin client — used by index.ts for topic validation. */
|
|
1376
|
+
get admin() {
|
|
1377
|
+
return this.deps.admin;
|
|
1378
|
+
}
|
|
1379
|
+
/** Whether the admin client is currently connected. */
|
|
1380
|
+
get connected() {
|
|
1381
|
+
return this.isConnected;
|
|
1393
1382
|
}
|
|
1394
1383
|
/**
|
|
1395
|
-
*
|
|
1384
|
+
* Connect the admin client if not already connected.
|
|
1385
|
+
* The flag is only set to `true` after a successful connect — if `admin.connect()`
|
|
1386
|
+
* throws the flag remains `false` so the next call will retry the connection.
|
|
1396
1387
|
*/
|
|
1397
|
-
async
|
|
1398
|
-
|
|
1399
|
-
|
|
1388
|
+
async ensureConnected() {
|
|
1389
|
+
if (this.isConnected) return;
|
|
1390
|
+
try {
|
|
1391
|
+
await this.deps.admin.connect();
|
|
1392
|
+
this.isConnected = true;
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
this.isConnected = false;
|
|
1395
|
+
throw err;
|
|
1396
|
+
}
|
|
1400
1397
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1398
|
+
/** Disconnect admin if connected. Resets the connected flag. */
|
|
1399
|
+
async disconnect() {
|
|
1400
|
+
if (!this.isConnected) return;
|
|
1401
|
+
await this.deps.admin.disconnect();
|
|
1402
|
+
this.isConnected = false;
|
|
1403
|
+
}
|
|
1404
|
+
async resetOffsets(groupId, topic2, position) {
|
|
1405
|
+
const gid = groupId ?? this.deps.defaultGroupId;
|
|
1406
|
+
if (this.deps.runningConsumers.has(gid)) {
|
|
1403
1407
|
throw new Error(
|
|
1404
|
-
|
|
1408
|
+
`resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
|
|
1405
1409
|
);
|
|
1406
1410
|
}
|
|
1407
|
-
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1411
|
+
await this.ensureConnected();
|
|
1412
|
+
const partitionOffsets = await this.deps.admin.fetchTopicOffsets(topic2);
|
|
1413
|
+
const partitions = partitionOffsets.map(({ partition, low, high }) => ({
|
|
1414
|
+
partition,
|
|
1415
|
+
offset: position === "earliest" ? low : high
|
|
1416
|
+
}));
|
|
1417
|
+
await this.deps.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
|
|
1418
|
+
this.deps.logger.log(
|
|
1419
|
+
`Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
|
|
1416
1420
|
);
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Seek specific topic-partition pairs to explicit offsets for a stopped consumer group.
|
|
1424
|
+
* Throws if the group is still running — call `stopConsumer(groupId)` first.
|
|
1425
|
+
* Assignments are grouped by topic and committed via `admin.setOffsets`.
|
|
1426
|
+
*/
|
|
1427
|
+
async seekToOffset(groupId, assignments) {
|
|
1428
|
+
const gid = groupId ?? this.deps.defaultGroupId;
|
|
1429
|
+
if (this.deps.runningConsumers.has(gid)) {
|
|
1430
|
+
throw new Error(
|
|
1431
|
+
`seekToOffset: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
|
|
1432
|
+
);
|
|
1422
1433
|
}
|
|
1423
|
-
await
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1434
|
+
await this.ensureConnected();
|
|
1435
|
+
const byTopic = /* @__PURE__ */ new Map();
|
|
1436
|
+
for (const { topic: topic2, partition, offset } of assignments) {
|
|
1437
|
+
const list = byTopic.get(topic2) ?? [];
|
|
1438
|
+
list.push({ partition, offset });
|
|
1439
|
+
byTopic.set(topic2, list);
|
|
1440
|
+
}
|
|
1441
|
+
for (const [topic2, partitions] of byTopic) {
|
|
1442
|
+
await this.deps.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
|
|
1443
|
+
this.deps.logger.log(
|
|
1444
|
+
`Offsets set for group "${gid}" on "${topic2}": ${JSON.stringify(partitions)}`
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Seek specific topic-partition pairs to the offset nearest to a given timestamp
|
|
1450
|
+
* (in milliseconds) for a stopped consumer group.
|
|
1451
|
+
* Throws if the group is still running — call `stopConsumer(groupId)` first.
|
|
1452
|
+
* Assignments are grouped by topic and committed via `admin.setOffsets`.
|
|
1453
|
+
* If no offset exists at the requested timestamp (e.g. empty partition or
|
|
1454
|
+
* future timestamp), the partition falls back to `-1` (end of topic — new messages only).
|
|
1455
|
+
*/
|
|
1456
|
+
async seekToTimestamp(groupId, assignments) {
|
|
1457
|
+
const gid = groupId ?? this.deps.defaultGroupId;
|
|
1458
|
+
if (this.deps.runningConsumers.has(gid)) {
|
|
1459
|
+
throw new Error(
|
|
1460
|
+
`seekToTimestamp: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
await this.ensureConnected();
|
|
1464
|
+
const byTopic = /* @__PURE__ */ new Map();
|
|
1465
|
+
for (const { topic: topic2, partition, timestamp } of assignments) {
|
|
1466
|
+
const list = byTopic.get(topic2) ?? [];
|
|
1467
|
+
list.push({ partition, timestamp });
|
|
1468
|
+
byTopic.set(topic2, list);
|
|
1469
|
+
}
|
|
1470
|
+
for (const [topic2, parts] of byTopic) {
|
|
1471
|
+
const offsets = await Promise.all(
|
|
1472
|
+
parts.map(async ({ partition, timestamp }) => {
|
|
1473
|
+
const results = await this.deps.admin.fetchTopicOffsetsByTime(
|
|
1474
|
+
topic2,
|
|
1475
|
+
timestamp
|
|
1476
|
+
);
|
|
1477
|
+
const found = results.find(
|
|
1478
|
+
(r) => r.partition === partition
|
|
1479
|
+
);
|
|
1480
|
+
return { partition, offset: found?.offset ?? "-1" };
|
|
1481
|
+
})
|
|
1482
|
+
);
|
|
1483
|
+
await this.deps.admin.setOffsets({ groupId: gid, topic: topic2, partitions: offsets });
|
|
1484
|
+
this.deps.logger.log(
|
|
1485
|
+
`Offsets set by timestamp for group "${gid}" on "${topic2}": ${JSON.stringify(offsets)}`
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Query consumer group lag per partition.
|
|
1491
|
+
* Lag = broker high-watermark − last committed offset.
|
|
1492
|
+
* A committed offset of -1 (nothing committed yet) counts as full lag.
|
|
1493
|
+
*
|
|
1494
|
+
* Returns an empty array when the consumer group has never committed any
|
|
1495
|
+
* offsets (freshly created group, `autoCommit: false` with no manual commits,
|
|
1496
|
+
* or group not yet assigned). This is a Kafka protocol limitation:
|
|
1497
|
+
* `fetchOffsets` only returns data for topic-partitions that have at least one
|
|
1498
|
+
* committed offset. Use `checkStatus()` to verify broker connectivity in that case.
|
|
1499
|
+
*/
|
|
1500
|
+
async getConsumerLag(groupId) {
|
|
1501
|
+
const gid = groupId ?? this.deps.defaultGroupId;
|
|
1502
|
+
await this.ensureConnected();
|
|
1503
|
+
const committedByTopic = await this.deps.admin.fetchOffsets({ groupId: gid });
|
|
1504
|
+
const brokerOffsetsAll = await Promise.all(
|
|
1505
|
+
committedByTopic.map(({ topic: topic2 }) => this.deps.admin.fetchTopicOffsets(topic2))
|
|
1506
|
+
);
|
|
1507
|
+
const result = [];
|
|
1508
|
+
for (let i = 0; i < committedByTopic.length; i++) {
|
|
1509
|
+
const { topic: topic2, partitions } = committedByTopic[i];
|
|
1510
|
+
const brokerOffsets = brokerOffsetsAll[i];
|
|
1511
|
+
for (const { partition, offset } of partitions) {
|
|
1512
|
+
const broker = brokerOffsets.find((o) => o.partition === partition);
|
|
1513
|
+
if (!broker) continue;
|
|
1514
|
+
const committed = parseInt(offset, 10);
|
|
1515
|
+
const high = parseInt(broker.high, 10);
|
|
1516
|
+
const lag = committed === -1 ? high : Math.max(0, high - committed);
|
|
1517
|
+
result.push({ topic: topic2, partition, lag });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return result;
|
|
1521
|
+
}
|
|
1522
|
+
/** Check broker connectivity. Never throws — returns a discriminated union. */
|
|
1523
|
+
async checkStatus() {
|
|
1524
|
+
try {
|
|
1525
|
+
await this.ensureConnected();
|
|
1526
|
+
const topics = await this.deps.admin.listTopics();
|
|
1527
|
+
return { status: "up", clientId: this.deps.clientId, topics };
|
|
1528
|
+
} catch (error) {
|
|
1529
|
+
return {
|
|
1530
|
+
status: "down",
|
|
1531
|
+
clientId: this.deps.clientId,
|
|
1532
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* List all consumer groups known to the broker.
|
|
1538
|
+
* Useful for monitoring which groups are active and their current state.
|
|
1539
|
+
*/
|
|
1540
|
+
async listConsumerGroups() {
|
|
1541
|
+
await this.ensureConnected();
|
|
1542
|
+
const result = await this.deps.admin.listGroups();
|
|
1543
|
+
return result.groups.map((g) => ({
|
|
1544
|
+
groupId: g.groupId,
|
|
1545
|
+
state: g.state ?? "Unknown"
|
|
1546
|
+
}));
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Describe topics — returns partition layout, leader, replicas, and ISR.
|
|
1550
|
+
* @param topics Topic names to describe. Omit to describe all topics.
|
|
1551
|
+
*/
|
|
1552
|
+
async describeTopics(topics) {
|
|
1553
|
+
await this.ensureConnected();
|
|
1554
|
+
const result = await this.deps.admin.fetchTopicMetadata(
|
|
1555
|
+
topics ? { topics } : void 0
|
|
1556
|
+
);
|
|
1557
|
+
return result.topics.map((t) => ({
|
|
1558
|
+
name: t.name,
|
|
1559
|
+
partitions: t.partitions.map((p) => ({
|
|
1560
|
+
partition: p.partitionId ?? p.partition,
|
|
1561
|
+
leader: p.leader,
|
|
1562
|
+
replicas: p.replicas.map(
|
|
1563
|
+
(r) => typeof r === "number" ? r : r.nodeId
|
|
1564
|
+
),
|
|
1565
|
+
isr: p.isr.map(
|
|
1566
|
+
(r) => typeof r === "number" ? r : r.nodeId
|
|
1567
|
+
)
|
|
1568
|
+
}))
|
|
1569
|
+
}));
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Delete records from a topic up to (but not including) the given offsets.
|
|
1573
|
+
* All messages with offsets **before** the given offset are deleted.
|
|
1574
|
+
*/
|
|
1575
|
+
async deleteRecords(topic2, partitions) {
|
|
1576
|
+
await this.ensureConnected();
|
|
1577
|
+
await this.deps.admin.deleteTopicRecords({ topic: topic2, partitions });
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* When `retryTopics: true` and `autoCreateTopics: false`, verify that every
|
|
1581
|
+
* `<topic>.retry.<level>` topic already exists. Throws a clear error at startup
|
|
1582
|
+
* rather than silently discovering missing topics on the first handler failure.
|
|
1583
|
+
*/
|
|
1584
|
+
async validateRetryTopicsExist(topicNames, maxRetries) {
|
|
1585
|
+
await this.ensureConnected();
|
|
1586
|
+
const existing = new Set(await this.deps.admin.listTopics());
|
|
1587
|
+
const missing = [];
|
|
1588
|
+
for (const t of topicNames) {
|
|
1589
|
+
for (let level = 1; level <= maxRetries; level++) {
|
|
1590
|
+
const retryTopic = `${t}.retry.${level}`;
|
|
1591
|
+
if (!existing.has(retryTopic)) missing.push(retryTopic);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (missing.length > 0) {
|
|
1595
|
+
throw new Error(
|
|
1596
|
+
`retryTopics: true but the following retry topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* When `autoCreateTopics` is disabled, verify that `<topic>.dlq` exists for every
|
|
1602
|
+
* consumed topic. Throws a clear error at startup rather than silently discovering
|
|
1603
|
+
* missing DLQ topics on the first handler failure.
|
|
1604
|
+
*/
|
|
1605
|
+
async validateDlqTopicsExist(topicNames) {
|
|
1606
|
+
await this.ensureConnected();
|
|
1607
|
+
const existing = new Set(await this.deps.admin.listTopics());
|
|
1608
|
+
const missing = topicNames.filter((t) => !existing.has(`${t}.dlq`)).map((t) => `${t}.dlq`);
|
|
1609
|
+
if (missing.length > 0) {
|
|
1610
|
+
throw new Error(
|
|
1611
|
+
`dlq: true but the following DLQ topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* When `deduplication.strategy: 'topic'` and `autoCreateTopics: false`, verify
|
|
1617
|
+
* that every `<topic>.duplicates` destination topic already exists. Throws a
|
|
1618
|
+
* clear error at startup rather than silently dropping duplicates on first hit.
|
|
1619
|
+
*/
|
|
1620
|
+
async validateDuplicatesTopicsExist(topicNames, customDestination) {
|
|
1621
|
+
await this.ensureConnected();
|
|
1622
|
+
const existing = new Set(await this.deps.admin.listTopics());
|
|
1623
|
+
const toCheck = customDestination ? [customDestination] : topicNames.map((t) => `${t}.duplicates`);
|
|
1624
|
+
const missing = toCheck.filter((t) => !existing.has(t));
|
|
1625
|
+
if (missing.length > 0) {
|
|
1626
|
+
throw new Error(
|
|
1627
|
+
`deduplication.strategy: 'topic' but the following duplicate-routing topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
// src/client/kafka.client/consumer/dlq-replay.ts
|
|
1634
|
+
async function replayDlqTopic(topic2, options = {}, deps) {
|
|
1635
|
+
const dlqTopic = `${topic2}.dlq`;
|
|
1636
|
+
const partitionOffsets = await deps.fetchTopicOffsets(dlqTopic);
|
|
1637
|
+
const activePartitions = partitionOffsets.filter((p) => parseInt(p.high, 10) > 0);
|
|
1638
|
+
if (activePartitions.length === 0) {
|
|
1639
|
+
deps.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
|
|
1640
|
+
return { replayed: 0, skipped: 0 };
|
|
1641
|
+
}
|
|
1642
|
+
const highWatermarks = new Map(
|
|
1643
|
+
activePartitions.map(({ partition, high }) => [partition, parseInt(high, 10)])
|
|
1644
|
+
);
|
|
1645
|
+
const processedOffsets = /* @__PURE__ */ new Map();
|
|
1646
|
+
let replayed = 0;
|
|
1647
|
+
let skipped = 0;
|
|
1648
|
+
const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
|
|
1649
|
+
await new Promise((resolve, reject) => {
|
|
1650
|
+
const consumer = deps.createConsumer(tempGroupId);
|
|
1651
|
+
const cleanup = () => deps.cleanupConsumer(consumer, tempGroupId);
|
|
1652
|
+
consumer.connect().then(() => subscribeWithRetry(consumer, [dlqTopic], deps.logger)).then(
|
|
1653
|
+
() => consumer.run({
|
|
1654
|
+
eachMessage: async ({ partition, message }) => {
|
|
1655
|
+
if (!message.value) return;
|
|
1656
|
+
const offset = parseInt(message.offset, 10);
|
|
1657
|
+
processedOffsets.set(partition, offset);
|
|
1658
|
+
const headers = decodeHeaders(message.headers);
|
|
1659
|
+
const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
|
|
1660
|
+
const originalHeaders = Object.fromEntries(
|
|
1661
|
+
Object.entries(headers).filter(([k]) => !deps.dlqHeaderKeys.has(k))
|
|
1662
|
+
);
|
|
1663
|
+
const value = message.value.toString();
|
|
1664
|
+
const shouldProcess = !options.filter || options.filter(headers, value);
|
|
1665
|
+
if (!targetTopic || !shouldProcess) {
|
|
1666
|
+
skipped++;
|
|
1667
|
+
} else if (options.dryRun) {
|
|
1668
|
+
deps.logger.log(`[DLQ replay dry-run] Would replay to "${targetTopic}"`);
|
|
1669
|
+
replayed++;
|
|
1670
|
+
} else {
|
|
1671
|
+
await deps.send(targetTopic, [{ value, headers: originalHeaders }]);
|
|
1672
|
+
replayed++;
|
|
1673
|
+
}
|
|
1674
|
+
const allDone = Array.from(highWatermarks.entries()).every(
|
|
1675
|
+
([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
|
|
1676
|
+
);
|
|
1677
|
+
if (allDone) {
|
|
1678
|
+
cleanup();
|
|
1679
|
+
resolve();
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
})
|
|
1683
|
+
).catch((err) => {
|
|
1684
|
+
cleanup();
|
|
1685
|
+
reject(err);
|
|
1686
|
+
});
|
|
1687
|
+
});
|
|
1688
|
+
deps.logger.log(`replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`);
|
|
1689
|
+
return { replayed, skipped };
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/client/kafka.client/infra/metrics-manager.ts
|
|
1693
|
+
var MetricsManager = class {
|
|
1694
|
+
constructor(deps) {
|
|
1695
|
+
this.deps = deps;
|
|
1696
|
+
}
|
|
1697
|
+
topicMetrics = /* @__PURE__ */ new Map();
|
|
1698
|
+
metricsFor(topic2) {
|
|
1699
|
+
let m = this.topicMetrics.get(topic2);
|
|
1700
|
+
if (!m) {
|
|
1701
|
+
m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1702
|
+
this.topicMetrics.set(topic2, m);
|
|
1703
|
+
}
|
|
1704
|
+
return m;
|
|
1705
|
+
}
|
|
1706
|
+
/** Fire `afterSend` instrumentation hooks for each message in a batch. */
|
|
1707
|
+
notifyAfterSend(topic2, count) {
|
|
1708
|
+
for (let i = 0; i < count; i++)
|
|
1709
|
+
for (const inst of this.deps.instrumentation) inst.afterSend?.(topic2);
|
|
1710
|
+
}
|
|
1711
|
+
notifyRetry(envelope, attempt, maxRetries) {
|
|
1712
|
+
this.metricsFor(envelope.topic).retryCount++;
|
|
1713
|
+
for (const inst of this.deps.instrumentation) inst.onRetry?.(envelope, attempt, maxRetries);
|
|
1714
|
+
}
|
|
1715
|
+
notifyDlq(envelope, reason, gid) {
|
|
1716
|
+
this.metricsFor(envelope.topic).dlqCount++;
|
|
1717
|
+
for (const inst of this.deps.instrumentation) inst.onDlq?.(envelope, reason);
|
|
1718
|
+
if (gid) this.deps.onCircuitFailure(envelope, gid);
|
|
1719
|
+
}
|
|
1720
|
+
notifyDuplicate(envelope, strategy) {
|
|
1721
|
+
this.metricsFor(envelope.topic).dedupCount++;
|
|
1722
|
+
for (const inst of this.deps.instrumentation) inst.onDuplicate?.(envelope, strategy);
|
|
1723
|
+
}
|
|
1724
|
+
notifyMessage(envelope, gid) {
|
|
1725
|
+
this.metricsFor(envelope.topic).processedCount++;
|
|
1726
|
+
for (const inst of this.deps.instrumentation) inst.onMessage?.(envelope);
|
|
1727
|
+
if (gid) this.deps.onCircuitSuccess(envelope, gid);
|
|
1728
|
+
}
|
|
1729
|
+
getMetrics(topic2) {
|
|
1730
|
+
if (topic2 !== void 0) {
|
|
1731
|
+
const m = this.topicMetrics.get(topic2);
|
|
1732
|
+
return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1733
|
+
}
|
|
1734
|
+
const agg = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1735
|
+
for (const m of this.topicMetrics.values()) {
|
|
1736
|
+
agg.processedCount += m.processedCount;
|
|
1737
|
+
agg.retryCount += m.retryCount;
|
|
1738
|
+
agg.dlqCount += m.dlqCount;
|
|
1739
|
+
agg.dedupCount += m.dedupCount;
|
|
1740
|
+
}
|
|
1741
|
+
return agg;
|
|
1742
|
+
}
|
|
1743
|
+
resetMetrics(topic2) {
|
|
1744
|
+
if (topic2 !== void 0) {
|
|
1745
|
+
this.topicMetrics.delete(topic2);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
this.topicMetrics.clear();
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
// src/client/kafka.client/infra/inflight-tracker.ts
|
|
1753
|
+
var InFlightTracker = class {
|
|
1754
|
+
constructor(warn) {
|
|
1755
|
+
this.warn = warn;
|
|
1756
|
+
}
|
|
1757
|
+
inFlightTotal = 0;
|
|
1758
|
+
drainResolvers = [];
|
|
1759
|
+
track(fn) {
|
|
1760
|
+
this.inFlightTotal++;
|
|
1761
|
+
return fn().finally(() => {
|
|
1762
|
+
this.inFlightTotal--;
|
|
1763
|
+
if (this.inFlightTotal === 0) this.drainResolvers.splice(0).forEach((r) => r());
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
waitForDrain(timeoutMs) {
|
|
1767
|
+
if (this.inFlightTotal === 0) return Promise.resolve();
|
|
1768
|
+
return new Promise((resolve) => {
|
|
1769
|
+
let handle;
|
|
1770
|
+
const onDrain = () => {
|
|
1771
|
+
clearTimeout(handle);
|
|
1772
|
+
resolve();
|
|
1773
|
+
};
|
|
1774
|
+
this.drainResolvers.push(onDrain);
|
|
1775
|
+
handle = setTimeout(() => {
|
|
1776
|
+
const idx = this.drainResolvers.indexOf(onDrain);
|
|
1777
|
+
if (idx !== -1) this.drainResolvers.splice(idx, 1);
|
|
1778
|
+
this.warn(
|
|
1779
|
+
`Drain timed out after ${timeoutMs}ms \u2014 ${this.inFlightTotal} handler(s) still in flight`
|
|
1780
|
+
);
|
|
1781
|
+
resolve();
|
|
1782
|
+
}, timeoutMs);
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
|
|
1787
|
+
// src/client/kafka.client/index.ts
|
|
1788
|
+
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS2;
|
|
1789
|
+
var _activeTransactionalIds = /* @__PURE__ */ new Set();
|
|
1790
|
+
var KafkaClient = class _KafkaClient {
|
|
1791
|
+
kafka;
|
|
1792
|
+
producer;
|
|
1793
|
+
txProducer;
|
|
1794
|
+
txProducerInitPromise;
|
|
1795
|
+
/** Maps transactionalId → Producer for each active retry level consumer. */
|
|
1796
|
+
retryTxProducers = /* @__PURE__ */ new Map();
|
|
1797
|
+
consumers = /* @__PURE__ */ new Map();
|
|
1798
|
+
logger;
|
|
1799
|
+
autoCreateTopicsEnabled;
|
|
1800
|
+
strictSchemasEnabled;
|
|
1801
|
+
numPartitions;
|
|
1802
|
+
ensuredTopics = /* @__PURE__ */ new Set();
|
|
1803
|
+
/** Pending topic-creation promises keyed by topic name. Prevents duplicate createTopics calls. */
|
|
1804
|
+
ensureTopicPromises = /* @__PURE__ */ new Map();
|
|
1805
|
+
defaultGroupId;
|
|
1806
|
+
schemaRegistry = /* @__PURE__ */ new Map();
|
|
1807
|
+
runningConsumers = /* @__PURE__ */ new Map();
|
|
1808
|
+
consumerCreationOptions = /* @__PURE__ */ new Map();
|
|
1809
|
+
/** Maps each main consumer groupId to its companion retry level groupIds. */
|
|
1810
|
+
companionGroupIds = /* @__PURE__ */ new Map();
|
|
1811
|
+
instrumentation;
|
|
1812
|
+
onMessageLost;
|
|
1813
|
+
onTtlExpired;
|
|
1814
|
+
onRebalance;
|
|
1815
|
+
/** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
|
|
1816
|
+
txId;
|
|
1817
|
+
/** Monotonically increasing Lamport clock stamped on every outgoing message. */
|
|
1818
|
+
_lamportClock = 0;
|
|
1819
|
+
/** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
|
|
1820
|
+
dedupStates = /* @__PURE__ */ new Map();
|
|
1821
|
+
circuitBreaker;
|
|
1822
|
+
adminOps;
|
|
1823
|
+
metrics;
|
|
1824
|
+
inFlight;
|
|
1825
|
+
clientId;
|
|
1826
|
+
_producerOpsDeps;
|
|
1827
|
+
_consumerOpsDeps;
|
|
1828
|
+
_retryTopicDeps;
|
|
1829
|
+
/** DLQ header keys added by the pipeline — stripped before re-publishing. */
|
|
1830
|
+
static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
|
|
1831
|
+
"x-dlq-original-topic",
|
|
1832
|
+
"x-dlq-failed-at",
|
|
1833
|
+
"x-dlq-error-message",
|
|
1834
|
+
"x-dlq-error-stack",
|
|
1835
|
+
"x-dlq-attempt-count"
|
|
1836
|
+
]);
|
|
1837
|
+
constructor(clientId, groupId, brokers, options) {
|
|
1838
|
+
this.clientId = clientId;
|
|
1839
|
+
this.defaultGroupId = groupId;
|
|
1840
|
+
this.logger = options?.logger ?? {
|
|
1841
|
+
log: (msg) => console.log(`[KafkaClient:${clientId}] ${msg}`),
|
|
1842
|
+
warn: (msg, ...args) => console.warn(`[KafkaClient:${clientId}] ${msg}`, ...args),
|
|
1843
|
+
error: (msg, ...args) => console.error(`[KafkaClient:${clientId}] ${msg}`, ...args),
|
|
1844
|
+
debug: (msg, ...args) => console.debug(`[KafkaClient:${clientId}] ${msg}`, ...args)
|
|
1845
|
+
};
|
|
1846
|
+
this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
|
|
1847
|
+
this.strictSchemasEnabled = options?.strictSchemas ?? true;
|
|
1848
|
+
this.numPartitions = options?.numPartitions ?? 1;
|
|
1849
|
+
this.instrumentation = options?.instrumentation ?? [];
|
|
1850
|
+
this.onMessageLost = options?.onMessageLost;
|
|
1851
|
+
this.onTtlExpired = options?.onTtlExpired;
|
|
1852
|
+
this.onRebalance = options?.onRebalance;
|
|
1853
|
+
this.txId = options?.transactionalId ?? `${clientId}-tx`;
|
|
1854
|
+
this.kafka = new KafkaClass({
|
|
1855
|
+
kafkaJS: {
|
|
1856
|
+
clientId: this.clientId,
|
|
1857
|
+
brokers,
|
|
1858
|
+
logLevel: KafkaLogLevel.ERROR
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
this.producer = this.kafka.producer({ kafkaJS: { acks: -1 } });
|
|
1862
|
+
this.adminOps = new AdminOps({
|
|
1863
|
+
admin: this.kafka.admin(),
|
|
1864
|
+
logger: this.logger,
|
|
1865
|
+
runningConsumers: this.runningConsumers,
|
|
1866
|
+
defaultGroupId: this.defaultGroupId,
|
|
1867
|
+
clientId: this.clientId
|
|
1868
|
+
});
|
|
1869
|
+
this.circuitBreaker = new CircuitBreakerManager({
|
|
1870
|
+
pauseConsumer: (gid, assignments) => this.pauseConsumer(gid, assignments),
|
|
1871
|
+
resumeConsumer: (gid, assignments) => this.resumeConsumer(gid, assignments),
|
|
1872
|
+
logger: this.logger,
|
|
1873
|
+
instrumentation: this.instrumentation
|
|
1874
|
+
});
|
|
1875
|
+
this.metrics = new MetricsManager({
|
|
1876
|
+
instrumentation: this.instrumentation,
|
|
1877
|
+
onCircuitFailure: (envelope, gid) => this.circuitBreaker.onFailure(envelope, gid),
|
|
1878
|
+
onCircuitSuccess: (envelope, gid) => this.circuitBreaker.onSuccess(envelope, gid)
|
|
1879
|
+
});
|
|
1880
|
+
this.inFlight = new InFlightTracker((msg) => this.logger.warn(msg));
|
|
1881
|
+
this._producerOpsDeps = {
|
|
1882
|
+
schemaRegistry: this.schemaRegistry,
|
|
1883
|
+
strictSchemasEnabled: this.strictSchemasEnabled,
|
|
1884
|
+
instrumentation: this.instrumentation,
|
|
1885
|
+
logger: this.logger,
|
|
1886
|
+
nextLamportClock: () => ++this._lamportClock
|
|
1887
|
+
};
|
|
1888
|
+
this._consumerOpsDeps = {
|
|
1889
|
+
consumers: this.consumers,
|
|
1890
|
+
consumerCreationOptions: this.consumerCreationOptions,
|
|
1891
|
+
kafka: this.kafka,
|
|
1892
|
+
onRebalance: this.onRebalance,
|
|
1893
|
+
logger: this.logger
|
|
1894
|
+
};
|
|
1895
|
+
this._retryTopicDeps = this.buildRetryTopicDeps();
|
|
1896
|
+
}
|
|
1897
|
+
async sendMessage(topicOrDesc, message, options = {}) {
|
|
1898
|
+
const payload = await this.preparePayload(
|
|
1899
|
+
topicOrDesc,
|
|
1900
|
+
[
|
|
1901
|
+
{
|
|
1902
|
+
value: message,
|
|
1903
|
+
key: options.key,
|
|
1904
|
+
headers: options.headers,
|
|
1905
|
+
correlationId: options.correlationId,
|
|
1906
|
+
schemaVersion: options.schemaVersion,
|
|
1907
|
+
eventId: options.eventId
|
|
1908
|
+
}
|
|
1909
|
+
],
|
|
1910
|
+
options.compression
|
|
1911
|
+
);
|
|
1912
|
+
await this.producer.send(payload);
|
|
1913
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Send a null-value (tombstone) message. Used with log-compacted topics to signal
|
|
1917
|
+
* that a key's record should be removed during the next compaction cycle.
|
|
1918
|
+
*
|
|
1919
|
+
* Tombstones skip envelope headers, schema validation, and Lamport clock stamping.
|
|
1920
|
+
* Both `beforeSend` and `afterSend` instrumentation hooks are still called so tracing works correctly.
|
|
1921
|
+
*
|
|
1922
|
+
* @param topic Topic name.
|
|
1923
|
+
* @param key Partition key identifying the record to tombstone.
|
|
1924
|
+
* @param headers Optional custom Kafka headers.
|
|
1925
|
+
*/
|
|
1926
|
+
async sendTombstone(topic2, key, headers) {
|
|
1927
|
+
const hdrs = { ...headers };
|
|
1928
|
+
for (const inst of this.instrumentation) inst.beforeSend?.(topic2, hdrs);
|
|
1929
|
+
await this.ensureTopic(topic2);
|
|
1930
|
+
await this.producer.send({ topic: topic2, messages: [{ value: null, key, headers: hdrs }] });
|
|
1931
|
+
for (const inst of this.instrumentation) inst.afterSend?.(topic2);
|
|
1932
|
+
}
|
|
1933
|
+
async sendBatch(topicOrDesc, messages, options) {
|
|
1934
|
+
const payload = await this.preparePayload(topicOrDesc, messages, options?.compression);
|
|
1935
|
+
await this.producer.send(payload);
|
|
1936
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1937
|
+
}
|
|
1938
|
+
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
1939
|
+
async transaction(fn) {
|
|
1940
|
+
if (!this.txProducerInitPromise) {
|
|
1941
|
+
if (_activeTransactionalIds.has(this.txId)) {
|
|
1942
|
+
this.logger.warn(
|
|
1943
|
+
`transactionalId "${this.txId}" is already in use by another KafkaClient in this process. Kafka will fence one of the producers. Set a unique \`transactionalId\` (or distinct \`clientId\`) per instance.`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
const initPromise = (async () => {
|
|
1947
|
+
const p = this.kafka.producer({
|
|
1948
|
+
kafkaJS: { acks: -1, idempotent: true, transactionalId: this.txId, maxInFlightRequests: 1 }
|
|
1949
|
+
});
|
|
1950
|
+
await p.connect();
|
|
1951
|
+
_activeTransactionalIds.add(this.txId);
|
|
1952
|
+
return p;
|
|
1953
|
+
})();
|
|
1954
|
+
this.txProducerInitPromise = initPromise.catch((err) => {
|
|
1955
|
+
this.txProducerInitPromise = void 0;
|
|
1956
|
+
throw err;
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
this.txProducer = await this.txProducerInitPromise;
|
|
1960
|
+
const tx = await this.txProducer.transaction();
|
|
1961
|
+
try {
|
|
1962
|
+
const ctx = {
|
|
1963
|
+
send: async (topicOrDesc, message, options = {}) => {
|
|
1964
|
+
const payload = await this.preparePayload(topicOrDesc, [
|
|
1965
|
+
{
|
|
1966
|
+
value: message,
|
|
1967
|
+
key: options.key,
|
|
1968
|
+
headers: options.headers,
|
|
1969
|
+
correlationId: options.correlationId,
|
|
1970
|
+
schemaVersion: options.schemaVersion,
|
|
1971
|
+
eventId: options.eventId
|
|
1972
|
+
}
|
|
1973
|
+
]);
|
|
1974
|
+
await tx.send(payload);
|
|
1975
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1976
|
+
},
|
|
1977
|
+
sendBatch: async (topicOrDesc, messages) => {
|
|
1978
|
+
const payload = await this.preparePayload(topicOrDesc, messages);
|
|
1979
|
+
await tx.send(payload);
|
|
1980
|
+
this.metrics.notifyAfterSend(payload.topic, payload.messages.length);
|
|
1981
|
+
}
|
|
1982
|
+
};
|
|
1983
|
+
await fn(ctx);
|
|
1984
|
+
await tx.commit();
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
try {
|
|
1987
|
+
await tx.abort();
|
|
1988
|
+
} catch (abortError) {
|
|
1989
|
+
this.logger.error("Failed to abort transaction:", toError(abortError).message);
|
|
1990
|
+
}
|
|
1991
|
+
throw error;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
// ── Producer lifecycle ───────────────────────────────────────────
|
|
1995
|
+
/**
|
|
1996
|
+
* Connect the idempotent producer. Called automatically by `KafkaModule.register()`.
|
|
1997
|
+
* @internal Not part of `IKafkaClient` — use `disconnect()` for full teardown.
|
|
1998
|
+
*/
|
|
1999
|
+
async connectProducer() {
|
|
2000
|
+
await this.producer.connect();
|
|
2001
|
+
this.logger.log("Producer connected");
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* @internal Not part of `IKafkaClient` — use `disconnect()` for full teardown.
|
|
2005
|
+
*/
|
|
2006
|
+
async disconnectProducer() {
|
|
2007
|
+
await this.producer.disconnect();
|
|
2008
|
+
this.logger.log("Producer disconnected");
|
|
2009
|
+
}
|
|
2010
|
+
async startConsumer(topics, handleMessage, options = {}) {
|
|
2011
|
+
this.validateTopicConsumerOpts(topics, options);
|
|
2012
|
+
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
2013
|
+
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", setupOptions);
|
|
2014
|
+
if (options.circuitBreaker) this.circuitBreaker.setConfig(gid, options.circuitBreaker);
|
|
2015
|
+
const deps = this.messageDepsFor(gid);
|
|
2016
|
+
const eosMainContext = await this.makeEosMainContext(gid, consumer, options);
|
|
2017
|
+
await consumer.run({
|
|
2018
|
+
eachMessage: (payload) => this.inFlight.track(
|
|
2019
|
+
() => handleEachMessage(
|
|
2020
|
+
payload,
|
|
2021
|
+
{
|
|
2022
|
+
schemaMap,
|
|
2023
|
+
handleMessage,
|
|
2024
|
+
interceptors,
|
|
2025
|
+
dlq,
|
|
2026
|
+
retry,
|
|
2027
|
+
retryTopics: options.retryTopics,
|
|
2028
|
+
timeoutMs: options.handlerTimeoutMs,
|
|
2029
|
+
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
2030
|
+
deduplication: this.resolveDeduplicationContext(gid, options.deduplication),
|
|
2031
|
+
messageTtlMs: options.messageTtlMs,
|
|
2032
|
+
onTtlExpired: options.onTtlExpired,
|
|
2033
|
+
eosMainContext
|
|
1439
2034
|
},
|
|
1440
2035
|
deps
|
|
1441
2036
|
)
|
|
@@ -1443,54 +2038,24 @@ var KafkaClient = class _KafkaClient {
|
|
|
1443
2038
|
});
|
|
1444
2039
|
this.runningConsumers.set(gid, "eachMessage");
|
|
1445
2040
|
if (options.retryTopics && retry) {
|
|
1446
|
-
|
|
1447
|
-
await this.validateRetryTopicsExist(topicNames, retry.maxRetries);
|
|
1448
|
-
}
|
|
1449
|
-
const companions = await startRetryTopicConsumers(
|
|
1450
|
-
topicNames,
|
|
1451
|
-
gid,
|
|
1452
|
-
handleMessage,
|
|
1453
|
-
retry,
|
|
1454
|
-
dlq,
|
|
1455
|
-
interceptors,
|
|
1456
|
-
schemaMap,
|
|
1457
|
-
this.retryTopicDeps,
|
|
1458
|
-
options.retryTopicAssignmentTimeoutMs
|
|
1459
|
-
);
|
|
1460
|
-
this.companionGroupIds.set(gid, companions);
|
|
2041
|
+
await this.launchRetryChain(gid, topicNames, handleMessage, retry, dlq, interceptors, schemaMap, options.retryTopicAssignmentTimeoutMs);
|
|
1461
2042
|
}
|
|
1462
2043
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
1463
2044
|
}
|
|
1464
2045
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
1468
|
-
);
|
|
1469
|
-
}
|
|
1470
|
-
if (options.retryTopics) {
|
|
1471
|
-
} else if (options.autoCommit !== false) {
|
|
2046
|
+
this.validateTopicConsumerOpts(topics, options);
|
|
2047
|
+
if (!options.retryTopics && options.autoCommit !== false) {
|
|
1472
2048
|
this.logger.debug?.(
|
|
1473
2049
|
`startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
|
|
1474
2050
|
);
|
|
1475
2051
|
}
|
|
1476
2052
|
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1477
2053
|
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", setupOptions);
|
|
1478
|
-
if (options.circuitBreaker)
|
|
1479
|
-
this.circuitConfigs.set(gid, options.circuitBreaker);
|
|
2054
|
+
if (options.circuitBreaker) this.circuitBreaker.setConfig(gid, options.circuitBreaker);
|
|
1480
2055
|
const deps = this.messageDepsFor(gid);
|
|
1481
|
-
const
|
|
1482
|
-
const deduplication = this.resolveDeduplicationContext(
|
|
1483
|
-
gid,
|
|
1484
|
-
options.deduplication
|
|
1485
|
-
);
|
|
1486
|
-
let eosMainContext;
|
|
1487
|
-
if (options.retryTopics && retry) {
|
|
1488
|
-
const mainTxId = `${gid}-main-tx`;
|
|
1489
|
-
const txProducer = await this.createRetryTxProducer(mainTxId);
|
|
1490
|
-
eosMainContext = { txProducer, consumer };
|
|
1491
|
-
}
|
|
2056
|
+
const eosMainContext = await this.makeEosMainContext(gid, consumer, options);
|
|
1492
2057
|
await consumer.run({
|
|
1493
|
-
eachBatch: (payload) => this.
|
|
2058
|
+
eachBatch: (payload) => this.inFlight.track(
|
|
1494
2059
|
() => handleEachBatch(
|
|
1495
2060
|
payload,
|
|
1496
2061
|
{
|
|
@@ -1500,10 +2065,11 @@ var KafkaClient = class _KafkaClient {
|
|
|
1500
2065
|
dlq,
|
|
1501
2066
|
retry,
|
|
1502
2067
|
retryTopics: options.retryTopics,
|
|
1503
|
-
timeoutMs,
|
|
2068
|
+
timeoutMs: options.handlerTimeoutMs,
|
|
1504
2069
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1505
|
-
deduplication,
|
|
2070
|
+
deduplication: this.resolveDeduplicationContext(gid, options.deduplication),
|
|
1506
2071
|
messageTtlMs: options.messageTtlMs,
|
|
2072
|
+
onTtlExpired: options.onTtlExpired,
|
|
1507
2073
|
eosMainContext
|
|
1508
2074
|
},
|
|
1509
2075
|
deps
|
|
@@ -1512,9 +2078,6 @@ var KafkaClient = class _KafkaClient {
|
|
|
1512
2078
|
});
|
|
1513
2079
|
this.runningConsumers.set(gid, "eachBatch");
|
|
1514
2080
|
if (options.retryTopics && retry) {
|
|
1515
|
-
if (!this.autoCreateTopicsEnabled) {
|
|
1516
|
-
await this.validateRetryTopicsExist(topicNames, retry.maxRetries);
|
|
1517
|
-
}
|
|
1518
2081
|
const handleMessageForRetry = (env) => handleBatch([env], {
|
|
1519
2082
|
partition: env.partition,
|
|
1520
2083
|
highWatermark: null,
|
|
@@ -1525,18 +2088,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1525
2088
|
commitOffsetsIfNecessary: async () => {
|
|
1526
2089
|
}
|
|
1527
2090
|
});
|
|
1528
|
-
|
|
1529
|
-
topicNames,
|
|
1530
|
-
gid,
|
|
1531
|
-
handleMessageForRetry,
|
|
1532
|
-
retry,
|
|
1533
|
-
dlq,
|
|
1534
|
-
interceptors,
|
|
1535
|
-
schemaMap,
|
|
1536
|
-
this.retryTopicDeps,
|
|
1537
|
-
options.retryTopicAssignmentTimeoutMs
|
|
1538
|
-
);
|
|
1539
|
-
this.companionGroupIds.set(gid, companions);
|
|
2091
|
+
await this.launchRetryChain(gid, topicNames, handleMessageForRetry, retry, dlq, interceptors, schemaMap, options.retryTopicAssignmentTimeoutMs);
|
|
1540
2092
|
}
|
|
1541
2093
|
return { groupId: gid, stop: () => this.stopConsumer(gid) };
|
|
1542
2094
|
}
|
|
@@ -1550,6 +2102,11 @@ var KafkaClient = class _KafkaClient {
|
|
|
1550
2102
|
* }
|
|
1551
2103
|
*/
|
|
1552
2104
|
consume(topic2, options) {
|
|
2105
|
+
if (options?.retryTopics) {
|
|
2106
|
+
throw new Error(
|
|
2107
|
+
"consume() does not support retryTopics (EOS retry chains). Use startConsumer() with retryTopics: true for guaranteed retry delivery."
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
1553
2110
|
const gid = options?.groupId ?? this.defaultGroupId;
|
|
1554
2111
|
const queue = new AsyncQueue(
|
|
1555
2112
|
options?.queueHighWaterMark,
|
|
@@ -1578,42 +2135,32 @@ var KafkaClient = class _KafkaClient {
|
|
|
1578
2135
|
};
|
|
1579
2136
|
}
|
|
1580
2137
|
// ── Consumer lifecycle ───────────────────────────────────────────
|
|
2138
|
+
/**
|
|
2139
|
+
* Stop all consumers or a specific group.
|
|
2140
|
+
*
|
|
2141
|
+
* If `groupId` is unspecified, all active consumers are stopped.
|
|
2142
|
+
* If `groupId` is specified, only the consumer with that group ID is stopped.
|
|
2143
|
+
*
|
|
2144
|
+
* @throws {Error} if the consumer fails to disconnect.
|
|
2145
|
+
*/
|
|
1581
2146
|
async stopConsumer(groupId) {
|
|
1582
2147
|
if (groupId !== void 0) {
|
|
1583
2148
|
const consumer = this.consumers.get(groupId);
|
|
1584
2149
|
if (!consumer) {
|
|
1585
|
-
this.logger.warn(
|
|
1586
|
-
`stopConsumer: no active consumer for group "${groupId}"`
|
|
1587
|
-
);
|
|
2150
|
+
this.logger.warn(`stopConsumer: no active consumer for group "${groupId}"`);
|
|
1588
2151
|
return;
|
|
1589
2152
|
}
|
|
1590
|
-
await consumer.disconnect().catch(
|
|
1591
|
-
(e) => this.logger.warn(
|
|
1592
|
-
`Error disconnecting consumer "${groupId}":`,
|
|
1593
|
-
toError(e).message
|
|
1594
|
-
)
|
|
1595
|
-
);
|
|
2153
|
+
await consumer.disconnect().catch((e) => this.logger.warn(`Error disconnecting consumer "${groupId}":`, toError(e).message));
|
|
1596
2154
|
this.consumers.delete(groupId);
|
|
1597
2155
|
this.runningConsumers.delete(groupId);
|
|
1598
2156
|
this.consumerCreationOptions.delete(groupId);
|
|
1599
2157
|
this.dedupStates.delete(groupId);
|
|
1600
|
-
|
|
1601
|
-
if (key.startsWith(`${groupId}:`)) {
|
|
1602
|
-
clearTimeout(this.circuitStates.get(key).timer);
|
|
1603
|
-
this.circuitStates.delete(key);
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
this.circuitConfigs.delete(groupId);
|
|
2158
|
+
this.circuitBreaker.removeGroup(groupId);
|
|
1607
2159
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
1608
2160
|
const mainTxId = `${groupId}-main-tx`;
|
|
1609
2161
|
const mainTxProducer = this.retryTxProducers.get(mainTxId);
|
|
1610
2162
|
if (mainTxProducer) {
|
|
1611
|
-
await mainTxProducer.disconnect().catch(
|
|
1612
|
-
(e) => this.logger.warn(
|
|
1613
|
-
`Error disconnecting main tx producer "${mainTxId}":`,
|
|
1614
|
-
toError(e).message
|
|
1615
|
-
)
|
|
1616
|
-
);
|
|
2163
|
+
await mainTxProducer.disconnect().catch((e) => this.logger.warn(`Error disconnecting main tx producer "${mainTxId}":`, toError(e).message));
|
|
1617
2164
|
_activeTransactionalIds.delete(mainTxId);
|
|
1618
2165
|
this.retryTxProducers.delete(mainTxId);
|
|
1619
2166
|
}
|
|
@@ -1621,12 +2168,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1621
2168
|
for (const cGroupId of companions) {
|
|
1622
2169
|
const cConsumer = this.consumers.get(cGroupId);
|
|
1623
2170
|
if (cConsumer) {
|
|
1624
|
-
await cConsumer.disconnect().catch(
|
|
1625
|
-
(e) => this.logger.warn(
|
|
1626
|
-
`Error disconnecting retry consumer "${cGroupId}":`,
|
|
1627
|
-
toError(e).message
|
|
1628
|
-
)
|
|
1629
|
-
);
|
|
2171
|
+
await cConsumer.disconnect().catch((e) => this.logger.warn(`Error disconnecting retry consumer "${cGroupId}":`, toError(e).message));
|
|
1630
2172
|
this.consumers.delete(cGroupId);
|
|
1631
2173
|
this.runningConsumers.delete(cGroupId);
|
|
1632
2174
|
this.consumerCreationOptions.delete(cGroupId);
|
|
@@ -1635,12 +2177,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1635
2177
|
const txId = `${cGroupId}-tx`;
|
|
1636
2178
|
const txProducer = this.retryTxProducers.get(txId);
|
|
1637
2179
|
if (txProducer) {
|
|
1638
|
-
await txProducer.disconnect().catch(
|
|
1639
|
-
(e) => this.logger.warn(
|
|
1640
|
-
`Error disconnecting retry tx producer "${txId}":`,
|
|
1641
|
-
toError(e).message
|
|
1642
|
-
)
|
|
1643
|
-
);
|
|
2180
|
+
await txProducer.disconnect().catch((e) => this.logger.warn(`Error disconnecting retry tx producer "${txId}":`, toError(e).message));
|
|
1644
2181
|
_activeTransactionalIds.delete(txId);
|
|
1645
2182
|
this.retryTxProducers.delete(txId);
|
|
1646
2183
|
}
|
|
@@ -1648,14 +2185,10 @@ var KafkaClient = class _KafkaClient {
|
|
|
1648
2185
|
this.companionGroupIds.delete(groupId);
|
|
1649
2186
|
} else {
|
|
1650
2187
|
const tasks = [
|
|
1651
|
-
...Array.from(this.consumers.values()).map(
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
)
|
|
1655
|
-
...Array.from(this.retryTxProducers.values()).map(
|
|
1656
|
-
(p) => p.disconnect().catch(() => {
|
|
1657
|
-
})
|
|
1658
|
-
)
|
|
2188
|
+
...Array.from(this.consumers.values()).map((c) => c.disconnect().catch(() => {
|
|
2189
|
+
})),
|
|
2190
|
+
...Array.from(this.retryTxProducers.values()).map((p) => p.disconnect().catch(() => {
|
|
2191
|
+
}))
|
|
1659
2192
|
];
|
|
1660
2193
|
await Promise.allSettled(tasks);
|
|
1661
2194
|
this.consumers.clear();
|
|
@@ -1664,13 +2197,16 @@ var KafkaClient = class _KafkaClient {
|
|
|
1664
2197
|
this.companionGroupIds.clear();
|
|
1665
2198
|
this.retryTxProducers.clear();
|
|
1666
2199
|
this.dedupStates.clear();
|
|
1667
|
-
|
|
1668
|
-
clearTimeout(state.timer);
|
|
1669
|
-
this.circuitStates.clear();
|
|
1670
|
-
this.circuitConfigs.clear();
|
|
2200
|
+
this.circuitBreaker.clear();
|
|
1671
2201
|
this.logger.log("All consumers disconnected");
|
|
1672
2202
|
}
|
|
1673
2203
|
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Temporarily stop delivering messages from specific partitions without disconnecting the consumer.
|
|
2206
|
+
*
|
|
2207
|
+
* @param groupId Consumer group to pause. Defaults to the client's default groupId.
|
|
2208
|
+
* @param assignments Topic-partition pairs to pause.
|
|
2209
|
+
*/
|
|
1674
2210
|
pauseConsumer(groupId, assignments) {
|
|
1675
2211
|
const gid = groupId ?? this.defaultGroupId;
|
|
1676
2212
|
const consumer = this.consumers.get(gid);
|
|
@@ -1679,11 +2215,15 @@ var KafkaClient = class _KafkaClient {
|
|
|
1679
2215
|
return;
|
|
1680
2216
|
}
|
|
1681
2217
|
consumer.pause(
|
|
1682
|
-
assignments.flatMap(
|
|
1683
|
-
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1684
|
-
)
|
|
2218
|
+
assignments.flatMap(({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] })))
|
|
1685
2219
|
);
|
|
1686
2220
|
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Resume message delivery for previously paused topic-partitions.
|
|
2223
|
+
*
|
|
2224
|
+
* @param {string|undefined} groupId Consumer group to resume. Defaults to the client's default groupId.
|
|
2225
|
+
* @param {Array<{ topic: string; partitions: number[] }>} assignments Topic-partition pairs to resume.
|
|
2226
|
+
*/
|
|
1687
2227
|
resumeConsumer(groupId, assignments) {
|
|
1688
2228
|
const gid = groupId ?? this.defaultGroupId;
|
|
1689
2229
|
const consumer = this.consumers.get(gid);
|
|
@@ -1692,9 +2232,7 @@ var KafkaClient = class _KafkaClient {
|
|
|
1692
2232
|
return;
|
|
1693
2233
|
}
|
|
1694
2234
|
consumer.resume(
|
|
1695
|
-
assignments.flatMap(
|
|
1696
|
-
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1697
|
-
)
|
|
2235
|
+
assignments.flatMap(({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] })))
|
|
1698
2236
|
);
|
|
1699
2237
|
}
|
|
1700
2238
|
/** Pause all assigned partitions of a topic for a consumer group (used for queue backpressure). */
|
|
@@ -1715,115 +2253,39 @@ var KafkaClient = class _KafkaClient {
|
|
|
1715
2253
|
if (partitions.length > 0)
|
|
1716
2254
|
consumer.resume(partitions.map((p) => ({ topic: topic2, partitions: [p] })));
|
|
1717
2255
|
}
|
|
1718
|
-
/**
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
2256
|
+
/**
|
|
2257
|
+
* Re-publish messages from a dead letter queue back to the original topic.
|
|
2258
|
+
*
|
|
2259
|
+
* Messages are consumed from `<topic>.dlq` and re-published to `<topic>`.
|
|
2260
|
+
* The original topic is determined by the `x-dlq-original-topic` header.
|
|
2261
|
+
* The `x-dlq-*` headers are stripped before re-publishing.
|
|
2262
|
+
*
|
|
2263
|
+
* @param topic - The topic to replay from `<topic>.dlq`
|
|
2264
|
+
* @param options - Options for replay
|
|
2265
|
+
* @returns { replayed: number; skipped: number } - counts of re-published vs skipped messages
|
|
2266
|
+
*/
|
|
1726
2267
|
async replayDlq(topic2, options = {}) {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
}
|
|
1737
|
-
const highWatermarks = new Map(
|
|
1738
|
-
activePartitions.map(({ partition, high }) => [
|
|
1739
|
-
partition,
|
|
1740
|
-
parseInt(high, 10)
|
|
1741
|
-
])
|
|
1742
|
-
);
|
|
1743
|
-
const processedOffsets = /* @__PURE__ */ new Map();
|
|
1744
|
-
let replayed = 0;
|
|
1745
|
-
let skipped = 0;
|
|
1746
|
-
const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
|
|
1747
|
-
await new Promise((resolve, reject) => {
|
|
1748
|
-
const consumer = getOrCreateConsumer(
|
|
1749
|
-
tempGroupId,
|
|
1750
|
-
true,
|
|
1751
|
-
true,
|
|
1752
|
-
this.consumerOpsDeps
|
|
1753
|
-
);
|
|
1754
|
-
const cleanup = () => {
|
|
2268
|
+
await this.adminOps.ensureConnected();
|
|
2269
|
+
return replayDlqTopic(topic2, options, {
|
|
2270
|
+
logger: this.logger,
|
|
2271
|
+
fetchTopicOffsets: (t) => this.adminOps.admin.fetchTopicOffsets(t),
|
|
2272
|
+
send: async (t, messages) => {
|
|
2273
|
+
await this.producer.send({ topic: t, messages });
|
|
2274
|
+
},
|
|
2275
|
+
createConsumer: (gid) => getOrCreateConsumer(gid, true, true, this._consumerOpsDeps),
|
|
2276
|
+
cleanupConsumer: (consumer, gid) => {
|
|
1755
2277
|
consumer.disconnect().catch(() => {
|
|
1756
2278
|
}).finally(() => {
|
|
1757
|
-
this.consumers.delete(
|
|
1758
|
-
this.runningConsumers.delete(
|
|
1759
|
-
this.consumerCreationOptions.delete(
|
|
2279
|
+
this.consumers.delete(gid);
|
|
2280
|
+
this.runningConsumers.delete(gid);
|
|
2281
|
+
this.consumerCreationOptions.delete(gid);
|
|
1760
2282
|
});
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
() => consumer.run({
|
|
1764
|
-
eachMessage: async ({ partition, message }) => {
|
|
1765
|
-
if (!message.value) return;
|
|
1766
|
-
const offset = parseInt(message.offset, 10);
|
|
1767
|
-
processedOffsets.set(partition, offset);
|
|
1768
|
-
const headers = decodeHeaders(message.headers);
|
|
1769
|
-
const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
|
|
1770
|
-
const originalHeaders = Object.fromEntries(
|
|
1771
|
-
Object.entries(headers).filter(
|
|
1772
|
-
([k]) => !_KafkaClient.DLQ_HEADER_KEYS.has(k)
|
|
1773
|
-
)
|
|
1774
|
-
);
|
|
1775
|
-
const value = message.value.toString();
|
|
1776
|
-
const shouldProcess = !options.filter || options.filter(headers, value);
|
|
1777
|
-
if (!targetTopic || !shouldProcess) {
|
|
1778
|
-
skipped++;
|
|
1779
|
-
} else if (options.dryRun) {
|
|
1780
|
-
this.logger.log(
|
|
1781
|
-
`[DLQ replay dry-run] Would replay to "${targetTopic}"`
|
|
1782
|
-
);
|
|
1783
|
-
replayed++;
|
|
1784
|
-
} else {
|
|
1785
|
-
await this.producer.send({
|
|
1786
|
-
topic: targetTopic,
|
|
1787
|
-
messages: [{ value, headers: originalHeaders }]
|
|
1788
|
-
});
|
|
1789
|
-
replayed++;
|
|
1790
|
-
}
|
|
1791
|
-
const allDone = Array.from(highWatermarks.entries()).every(
|
|
1792
|
-
([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
|
|
1793
|
-
);
|
|
1794
|
-
if (allDone) {
|
|
1795
|
-
cleanup();
|
|
1796
|
-
resolve();
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
})
|
|
1800
|
-
).catch((err) => {
|
|
1801
|
-
cleanup();
|
|
1802
|
-
reject(err);
|
|
1803
|
-
});
|
|
2283
|
+
},
|
|
2284
|
+
dlqHeaderKeys: _KafkaClient.DLQ_HEADER_KEYS
|
|
1804
2285
|
});
|
|
1805
|
-
this.logger.log(
|
|
1806
|
-
`replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
|
|
1807
|
-
);
|
|
1808
|
-
return { replayed, skipped };
|
|
1809
2286
|
}
|
|
1810
2287
|
async resetOffsets(groupId, topic2, position) {
|
|
1811
|
-
|
|
1812
|
-
if (this.runningConsumers.has(gid)) {
|
|
1813
|
-
throw new Error(
|
|
1814
|
-
`resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
|
|
1815
|
-
);
|
|
1816
|
-
}
|
|
1817
|
-
await this.ensureAdminConnected();
|
|
1818
|
-
const partitionOffsets = await this.admin.fetchTopicOffsets(topic2);
|
|
1819
|
-
const partitions = partitionOffsets.map(({ partition, low, high }) => ({
|
|
1820
|
-
partition,
|
|
1821
|
-
offset: position === "earliest" ? low : high
|
|
1822
|
-
}));
|
|
1823
|
-
await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
|
|
1824
|
-
this.logger.log(
|
|
1825
|
-
`Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
|
|
1826
|
-
);
|
|
2288
|
+
return this.adminOps.resetOffsets(groupId, topic2, position);
|
|
1827
2289
|
}
|
|
1828
2290
|
/**
|
|
1829
2291
|
* Seek specific topic-partition pairs to explicit offsets for a stopped consumer group.
|
|
@@ -1831,68 +2293,32 @@ var KafkaClient = class _KafkaClient {
|
|
|
1831
2293
|
* Assignments are grouped by topic and committed via `admin.setOffsets`.
|
|
1832
2294
|
*/
|
|
1833
2295
|
async seekToOffset(groupId, assignments) {
|
|
1834
|
-
|
|
1835
|
-
if (this.runningConsumers.has(gid)) {
|
|
1836
|
-
throw new Error(
|
|
1837
|
-
`seekToOffset: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
|
|
1838
|
-
);
|
|
1839
|
-
}
|
|
1840
|
-
await this.ensureAdminConnected();
|
|
1841
|
-
const byTopic = /* @__PURE__ */ new Map();
|
|
1842
|
-
for (const { topic: topic2, partition, offset } of assignments) {
|
|
1843
|
-
const list = byTopic.get(topic2) ?? [];
|
|
1844
|
-
list.push({ partition, offset });
|
|
1845
|
-
byTopic.set(topic2, list);
|
|
1846
|
-
}
|
|
1847
|
-
for (const [topic2, partitions] of byTopic) {
|
|
1848
|
-
await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
|
|
1849
|
-
this.logger.log(
|
|
1850
|
-
`Offsets set for group "${gid}" on "${topic2}": ${JSON.stringify(partitions)}`
|
|
1851
|
-
);
|
|
1852
|
-
}
|
|
2296
|
+
return this.adminOps.seekToOffset(groupId, assignments);
|
|
1853
2297
|
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Seek specific topic-partition pairs to the offset nearest to a given timestamp
|
|
2300
|
+
* (in milliseconds) for a stopped consumer group.
|
|
2301
|
+
* Throws if the group is still running — call `stopConsumer(groupId)` first.
|
|
2302
|
+
* Assignments are grouped by topic and committed via `admin.setOffsets`.
|
|
2303
|
+
* If no offset exists at the requested timestamp (e.g. empty partition or
|
|
2304
|
+
* future timestamp), the partition falls back to `-1` (end of topic — new messages only).
|
|
2305
|
+
*/
|
|
1854
2306
|
async seekToTimestamp(groupId, assignments) {
|
|
1855
|
-
|
|
1856
|
-
if (this.runningConsumers.has(gid)) {
|
|
1857
|
-
throw new Error(
|
|
1858
|
-
`seekToTimestamp: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before seeking offsets.`
|
|
1859
|
-
);
|
|
1860
|
-
}
|
|
1861
|
-
await this.ensureAdminConnected();
|
|
1862
|
-
const byTopic = /* @__PURE__ */ new Map();
|
|
1863
|
-
for (const { topic: topic2, partition, timestamp } of assignments) {
|
|
1864
|
-
const list = byTopic.get(topic2) ?? [];
|
|
1865
|
-
list.push({ partition, timestamp });
|
|
1866
|
-
byTopic.set(topic2, list);
|
|
1867
|
-
}
|
|
1868
|
-
for (const [topic2, parts] of byTopic) {
|
|
1869
|
-
const offsets = await Promise.all(
|
|
1870
|
-
parts.map(async ({ partition, timestamp }) => {
|
|
1871
|
-
const results = await this.admin.fetchTopicOffsetsByTime(
|
|
1872
|
-
topic2,
|
|
1873
|
-
timestamp
|
|
1874
|
-
);
|
|
1875
|
-
const found = results.find(
|
|
1876
|
-
(r) => r.partition === partition
|
|
1877
|
-
);
|
|
1878
|
-
return { partition, offset: found?.offset ?? "-1" };
|
|
1879
|
-
})
|
|
1880
|
-
);
|
|
1881
|
-
await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions: offsets });
|
|
1882
|
-
this.logger.log(
|
|
1883
|
-
`Offsets set by timestamp for group "${gid}" on "${topic2}": ${JSON.stringify(offsets)}`
|
|
1884
|
-
);
|
|
1885
|
-
}
|
|
2307
|
+
return this.adminOps.seekToTimestamp(groupId, assignments);
|
|
1886
2308
|
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Returns the current circuit breaker state for a specific topic partition.
|
|
2311
|
+
* Returns `undefined` when no circuit state exists — either `circuitBreaker` is not
|
|
2312
|
+
* configured for the group, or the circuit has never been tripped.
|
|
2313
|
+
*
|
|
2314
|
+
* @param topic Topic name.
|
|
2315
|
+
* @param partition Partition index.
|
|
2316
|
+
* @param groupId Consumer group. Defaults to the client's default groupId.
|
|
2317
|
+
*
|
|
2318
|
+
* @returns `{ status, failures, windowSize }` snapshot for a given partition or `undefined` if no state exists.
|
|
2319
|
+
*/
|
|
1887
2320
|
getCircuitState(topic2, partition, groupId) {
|
|
1888
|
-
|
|
1889
|
-
const state = this.circuitStates.get(`${gid}:${topic2}:${partition}`);
|
|
1890
|
-
if (!state) return void 0;
|
|
1891
|
-
return {
|
|
1892
|
-
status: state.status,
|
|
1893
|
-
failures: state.window.filter((v) => !v).length,
|
|
1894
|
-
windowSize: state.window.length
|
|
1895
|
-
};
|
|
2321
|
+
return this.circuitBreaker.getState(topic2, partition, groupId ?? this.defaultGroupId);
|
|
1896
2322
|
}
|
|
1897
2323
|
/**
|
|
1898
2324
|
* Query consumer group lag per partition.
|
|
@@ -1906,73 +2332,60 @@ var KafkaClient = class _KafkaClient {
|
|
|
1906
2332
|
* committed offset. Use `checkStatus()` to verify broker connectivity in that case.
|
|
1907
2333
|
*/
|
|
1908
2334
|
async getConsumerLag(groupId) {
|
|
1909
|
-
|
|
1910
|
-
await this.ensureAdminConnected();
|
|
1911
|
-
const committedByTopic = await this.admin.fetchOffsets({ groupId: gid });
|
|
1912
|
-
const brokerOffsetsAll = await Promise.all(
|
|
1913
|
-
committedByTopic.map(({ topic: topic2 }) => this.admin.fetchTopicOffsets(topic2))
|
|
1914
|
-
);
|
|
1915
|
-
const result = [];
|
|
1916
|
-
for (let i = 0; i < committedByTopic.length; i++) {
|
|
1917
|
-
const { topic: topic2, partitions } = committedByTopic[i];
|
|
1918
|
-
const brokerOffsets = brokerOffsetsAll[i];
|
|
1919
|
-
for (const { partition, offset } of partitions) {
|
|
1920
|
-
const broker = brokerOffsets.find((o) => o.partition === partition);
|
|
1921
|
-
if (!broker) continue;
|
|
1922
|
-
const committed = parseInt(offset, 10);
|
|
1923
|
-
const high = parseInt(broker.high, 10);
|
|
1924
|
-
const lag = committed === -1 ? high : Math.max(0, high - committed);
|
|
1925
|
-
result.push({ topic: topic2, partition, lag });
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
return result;
|
|
2335
|
+
return this.adminOps.getConsumerLag(groupId);
|
|
1929
2336
|
}
|
|
1930
2337
|
/** Check broker connectivity. Never throws — returns a discriminated union. */
|
|
1931
2338
|
async checkStatus() {
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1941
|
-
};
|
|
1942
|
-
}
|
|
2339
|
+
return this.adminOps.checkStatus();
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* List all consumer groups known to the broker.
|
|
2343
|
+
* Useful for monitoring which groups are active and their current state.
|
|
2344
|
+
*/
|
|
2345
|
+
async listConsumerGroups() {
|
|
2346
|
+
return this.adminOps.listConsumerGroups();
|
|
1943
2347
|
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Describe topics — returns partition layout, leader, replicas, and ISR.
|
|
2350
|
+
* @param topics Topic names to describe. Omit to describe all topics.
|
|
2351
|
+
*/
|
|
2352
|
+
async describeTopics(topics) {
|
|
2353
|
+
return this.adminOps.describeTopics(topics);
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Delete records from a topic up to (but not including) the given offsets.
|
|
2357
|
+
* All messages with offsets **before** the given offset are deleted.
|
|
2358
|
+
*/
|
|
2359
|
+
async deleteRecords(topic2, partitions) {
|
|
2360
|
+
return this.adminOps.deleteRecords(topic2, partitions);
|
|
2361
|
+
}
|
|
2362
|
+
/** Return the client ID provided during `KafkaClient` construction. */
|
|
1944
2363
|
getClientId() {
|
|
1945
2364
|
return this.clientId;
|
|
1946
2365
|
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Return a snapshot of internal event counters accumulated since client creation
|
|
2368
|
+
* (or since the last `resetMetrics()` call).
|
|
2369
|
+
*
|
|
2370
|
+
* @param topic Topic name to scope the snapshot to. When omitted, counters are
|
|
2371
|
+
* aggregated across all topics. If the topic has no recorded events yet, returns
|
|
2372
|
+
* a zero-valued snapshot.
|
|
2373
|
+
* @returns Read-only `KafkaMetrics` snapshot: `processedCount`, `retryCount`, `dlqCount`, `dedupCount`.
|
|
2374
|
+
*/
|
|
1947
2375
|
getMetrics(topic2) {
|
|
1948
|
-
|
|
1949
|
-
const m = this._topicMetrics.get(topic2);
|
|
1950
|
-
return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1951
|
-
}
|
|
1952
|
-
const agg = {
|
|
1953
|
-
processedCount: 0,
|
|
1954
|
-
retryCount: 0,
|
|
1955
|
-
dlqCount: 0,
|
|
1956
|
-
dedupCount: 0
|
|
1957
|
-
};
|
|
1958
|
-
for (const m of this._topicMetrics.values()) {
|
|
1959
|
-
agg.processedCount += m.processedCount;
|
|
1960
|
-
agg.retryCount += m.retryCount;
|
|
1961
|
-
agg.dlqCount += m.dlqCount;
|
|
1962
|
-
agg.dedupCount += m.dedupCount;
|
|
1963
|
-
}
|
|
1964
|
-
return agg;
|
|
2376
|
+
return this.metrics.getMetrics(topic2);
|
|
1965
2377
|
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Reset internal event counters to zero.
|
|
2380
|
+
*
|
|
2381
|
+
* @param topic Topic name to reset. When omitted, all topics are reset.
|
|
2382
|
+
*/
|
|
1966
2383
|
resetMetrics(topic2) {
|
|
1967
|
-
|
|
1968
|
-
this._topicMetrics.delete(topic2);
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
|
-
this._topicMetrics.clear();
|
|
2384
|
+
this.metrics.resetMetrics(topic2);
|
|
1972
2385
|
}
|
|
1973
2386
|
/** Gracefully disconnect producer, all consumers, and admin. */
|
|
1974
2387
|
async disconnect(drainTimeoutMs = 3e4) {
|
|
1975
|
-
await this.waitForDrain(drainTimeoutMs);
|
|
2388
|
+
await this.inFlight.waitForDrain(drainTimeoutMs);
|
|
1976
2389
|
const tasks = [this.producer.disconnect()];
|
|
1977
2390
|
if (this.txProducer) {
|
|
1978
2391
|
tasks.push(this.txProducer.disconnect());
|
|
@@ -1980,28 +2393,17 @@ var KafkaClient = class _KafkaClient {
|
|
|
1980
2393
|
this.txProducer = void 0;
|
|
1981
2394
|
this.txProducerInitPromise = void 0;
|
|
1982
2395
|
}
|
|
1983
|
-
for (const txId of this.retryTxProducers.keys())
|
|
1984
|
-
|
|
1985
|
-
}
|
|
1986
|
-
for (const p of this.retryTxProducers.values()) {
|
|
1987
|
-
tasks.push(p.disconnect());
|
|
1988
|
-
}
|
|
2396
|
+
for (const txId of this.retryTxProducers.keys()) _activeTransactionalIds.delete(txId);
|
|
2397
|
+
for (const p of this.retryTxProducers.values()) tasks.push(p.disconnect());
|
|
1989
2398
|
this.retryTxProducers.clear();
|
|
1990
|
-
for (const consumer of this.consumers.values())
|
|
1991
|
-
|
|
1992
|
-
}
|
|
1993
|
-
if (this.isAdminConnected) {
|
|
1994
|
-
tasks.push(this.admin.disconnect());
|
|
1995
|
-
this.isAdminConnected = false;
|
|
1996
|
-
}
|
|
2399
|
+
for (const consumer of this.consumers.values()) tasks.push(consumer.disconnect());
|
|
2400
|
+
tasks.push(this.adminOps.disconnect());
|
|
1997
2401
|
await Promise.allSettled(tasks);
|
|
1998
2402
|
this.consumers.clear();
|
|
1999
2403
|
this.runningConsumers.clear();
|
|
2000
2404
|
this.consumerCreationOptions.clear();
|
|
2001
2405
|
this.companionGroupIds.clear();
|
|
2002
|
-
|
|
2003
|
-
this.circuitStates.clear();
|
|
2004
|
-
this.circuitConfigs.clear();
|
|
2406
|
+
this.circuitBreaker.clear();
|
|
2005
2407
|
this.logger.log("All connections closed");
|
|
2006
2408
|
}
|
|
2007
2409
|
// ── Graceful shutdown ────────────────────────────────────────────
|
|
@@ -2020,183 +2422,20 @@ var KafkaClient = class _KafkaClient {
|
|
|
2020
2422
|
*/
|
|
2021
2423
|
enableGracefulShutdown(signals = ["SIGTERM", "SIGINT"], drainTimeoutMs = 3e4) {
|
|
2022
2424
|
const handler = () => {
|
|
2023
|
-
this.logger.log(
|
|
2024
|
-
"Shutdown signal received \u2014 draining in-flight handlers..."
|
|
2025
|
-
);
|
|
2425
|
+
this.logger.log("Shutdown signal received \u2014 draining in-flight handlers...");
|
|
2026
2426
|
this.disconnect(drainTimeoutMs).catch(
|
|
2027
|
-
(err) => this.logger.error(
|
|
2028
|
-
"Error during graceful shutdown:",
|
|
2029
|
-
toError(err).message
|
|
2030
|
-
)
|
|
2427
|
+
(err) => this.logger.error("Error during graceful shutdown:", toError(err).message)
|
|
2031
2428
|
);
|
|
2032
2429
|
};
|
|
2033
|
-
for (const signal of signals)
|
|
2034
|
-
process.once(signal, handler);
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
trackInFlight(fn) {
|
|
2038
|
-
this.inFlightTotal++;
|
|
2039
|
-
return fn().finally(() => {
|
|
2040
|
-
this.inFlightTotal--;
|
|
2041
|
-
if (this.inFlightTotal === 0) {
|
|
2042
|
-
this.drainResolvers.splice(0).forEach((r) => r());
|
|
2043
|
-
}
|
|
2044
|
-
});
|
|
2045
|
-
}
|
|
2046
|
-
waitForDrain(timeoutMs) {
|
|
2047
|
-
if (this.inFlightTotal === 0) return Promise.resolve();
|
|
2048
|
-
return new Promise((resolve) => {
|
|
2049
|
-
let handle;
|
|
2050
|
-
const onDrain = () => {
|
|
2051
|
-
clearTimeout(handle);
|
|
2052
|
-
resolve();
|
|
2053
|
-
};
|
|
2054
|
-
this.drainResolvers.push(onDrain);
|
|
2055
|
-
handle = setTimeout(() => {
|
|
2056
|
-
const idx = this.drainResolvers.indexOf(onDrain);
|
|
2057
|
-
if (idx !== -1) this.drainResolvers.splice(idx, 1);
|
|
2058
|
-
this.logger.warn(
|
|
2059
|
-
`Drain timed out after ${timeoutMs}ms \u2014 ${this.inFlightTotal} handler(s) still in flight`
|
|
2060
|
-
);
|
|
2061
|
-
resolve();
|
|
2062
|
-
}, timeoutMs);
|
|
2063
|
-
});
|
|
2430
|
+
for (const signal of signals) process.once(signal, handler);
|
|
2064
2431
|
}
|
|
2065
2432
|
// ── Private helpers ──────────────────────────────────────────────
|
|
2066
|
-
async preparePayload(topicOrDesc, messages) {
|
|
2433
|
+
async preparePayload(topicOrDesc, messages, compression) {
|
|
2067
2434
|
registerSchema(topicOrDesc, this.schemaRegistry, this.logger);
|
|
2068
|
-
const payload = await buildSendPayload(
|
|
2069
|
-
topicOrDesc,
|
|
2070
|
-
messages,
|
|
2071
|
-
this.producerOpsDeps
|
|
2072
|
-
);
|
|
2435
|
+
const payload = await buildSendPayload(topicOrDesc, messages, this._producerOpsDeps, compression);
|
|
2073
2436
|
await this.ensureTopic(payload.topic);
|
|
2074
2437
|
return payload;
|
|
2075
2438
|
}
|
|
2076
|
-
// afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
|
|
2077
|
-
notifyAfterSend(topic2, count) {
|
|
2078
|
-
for (let i = 0; i < count; i++) {
|
|
2079
|
-
for (const inst of this.instrumentation) {
|
|
2080
|
-
inst.afterSend?.(topic2);
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
metricsFor(topic2) {
|
|
2085
|
-
let m = this._topicMetrics.get(topic2);
|
|
2086
|
-
if (!m) {
|
|
2087
|
-
m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
2088
|
-
this._topicMetrics.set(topic2, m);
|
|
2089
|
-
}
|
|
2090
|
-
return m;
|
|
2091
|
-
}
|
|
2092
|
-
notifyRetry(envelope, attempt, maxRetries) {
|
|
2093
|
-
this.metricsFor(envelope.topic).retryCount++;
|
|
2094
|
-
for (const inst of this.instrumentation) {
|
|
2095
|
-
inst.onRetry?.(envelope, attempt, maxRetries);
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
notifyDlq(envelope, reason, gid) {
|
|
2099
|
-
this.metricsFor(envelope.topic).dlqCount++;
|
|
2100
|
-
for (const inst of this.instrumentation) {
|
|
2101
|
-
inst.onDlq?.(envelope, reason);
|
|
2102
|
-
}
|
|
2103
|
-
if (!gid) return;
|
|
2104
|
-
const cfg = this.circuitConfigs.get(gid);
|
|
2105
|
-
if (!cfg) return;
|
|
2106
|
-
const threshold = cfg.threshold ?? 5;
|
|
2107
|
-
const recoveryMs = cfg.recoveryMs ?? 3e4;
|
|
2108
|
-
const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
|
|
2109
|
-
let state = this.circuitStates.get(stateKey);
|
|
2110
|
-
if (!state) {
|
|
2111
|
-
state = { status: "closed", window: [], successes: 0 };
|
|
2112
|
-
this.circuitStates.set(stateKey, state);
|
|
2113
|
-
}
|
|
2114
|
-
if (state.status === "open") return;
|
|
2115
|
-
const openCircuit = () => {
|
|
2116
|
-
state.status = "open";
|
|
2117
|
-
state.window = [];
|
|
2118
|
-
state.successes = 0;
|
|
2119
|
-
clearTimeout(state.timer);
|
|
2120
|
-
for (const inst of this.instrumentation)
|
|
2121
|
-
inst.onCircuitOpen?.(envelope.topic, envelope.partition);
|
|
2122
|
-
this.pauseConsumer(gid, [
|
|
2123
|
-
{ topic: envelope.topic, partitions: [envelope.partition] }
|
|
2124
|
-
]);
|
|
2125
|
-
state.timer = setTimeout(() => {
|
|
2126
|
-
state.status = "half-open";
|
|
2127
|
-
state.successes = 0;
|
|
2128
|
-
this.logger.log(
|
|
2129
|
-
`[CircuitBreaker] HALF-OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
|
|
2130
|
-
);
|
|
2131
|
-
for (const inst of this.instrumentation)
|
|
2132
|
-
inst.onCircuitHalfOpen?.(envelope.topic, envelope.partition);
|
|
2133
|
-
this.resumeConsumer(gid, [
|
|
2134
|
-
{ topic: envelope.topic, partitions: [envelope.partition] }
|
|
2135
|
-
]);
|
|
2136
|
-
}, recoveryMs);
|
|
2137
|
-
};
|
|
2138
|
-
if (state.status === "half-open") {
|
|
2139
|
-
clearTimeout(state.timer);
|
|
2140
|
-
this.logger.warn(
|
|
2141
|
-
`[CircuitBreaker] OPEN (half-open failure) \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
|
|
2142
|
-
);
|
|
2143
|
-
openCircuit();
|
|
2144
|
-
return;
|
|
2145
|
-
}
|
|
2146
|
-
const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
|
|
2147
|
-
state.window = [...state.window, false];
|
|
2148
|
-
if (state.window.length > windowSize) {
|
|
2149
|
-
state.window = state.window.slice(state.window.length - windowSize);
|
|
2150
|
-
}
|
|
2151
|
-
const failures = state.window.filter((v) => !v).length;
|
|
2152
|
-
if (failures >= threshold) {
|
|
2153
|
-
this.logger.warn(
|
|
2154
|
-
`[CircuitBreaker] OPEN \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition} (${failures}/${state.window.length} failures, threshold=${threshold})`
|
|
2155
|
-
);
|
|
2156
|
-
openCircuit();
|
|
2157
|
-
}
|
|
2158
|
-
}
|
|
2159
|
-
notifyDuplicate(envelope, strategy) {
|
|
2160
|
-
this.metricsFor(envelope.topic).dedupCount++;
|
|
2161
|
-
for (const inst of this.instrumentation) {
|
|
2162
|
-
inst.onDuplicate?.(envelope, strategy);
|
|
2163
|
-
}
|
|
2164
|
-
}
|
|
2165
|
-
notifyMessage(envelope, gid) {
|
|
2166
|
-
this.metricsFor(envelope.topic).processedCount++;
|
|
2167
|
-
for (const inst of this.instrumentation) {
|
|
2168
|
-
inst.onMessage?.(envelope);
|
|
2169
|
-
}
|
|
2170
|
-
if (!gid) return;
|
|
2171
|
-
const cfg = this.circuitConfigs.get(gid);
|
|
2172
|
-
if (!cfg) return;
|
|
2173
|
-
const stateKey = `${gid}:${envelope.topic}:${envelope.partition}`;
|
|
2174
|
-
const state = this.circuitStates.get(stateKey);
|
|
2175
|
-
if (!state) return;
|
|
2176
|
-
const halfOpenSuccesses = cfg.halfOpenSuccesses ?? 1;
|
|
2177
|
-
if (state.status === "half-open") {
|
|
2178
|
-
state.successes++;
|
|
2179
|
-
if (state.successes >= halfOpenSuccesses) {
|
|
2180
|
-
clearTimeout(state.timer);
|
|
2181
|
-
state.timer = void 0;
|
|
2182
|
-
state.status = "closed";
|
|
2183
|
-
state.window = [];
|
|
2184
|
-
state.successes = 0;
|
|
2185
|
-
this.logger.log(
|
|
2186
|
-
`[CircuitBreaker] CLOSED \u2014 group="${gid}" topic="${envelope.topic}" partition=${envelope.partition}`
|
|
2187
|
-
);
|
|
2188
|
-
for (const inst of this.instrumentation)
|
|
2189
|
-
inst.onCircuitClose?.(envelope.topic, envelope.partition);
|
|
2190
|
-
}
|
|
2191
|
-
} else if (state.status === "closed") {
|
|
2192
|
-
const threshold = cfg.threshold ?? 5;
|
|
2193
|
-
const windowSize = cfg.windowSize ?? Math.max(threshold * 2, 10);
|
|
2194
|
-
state.window = [...state.window, true];
|
|
2195
|
-
if (state.window.length > windowSize) {
|
|
2196
|
-
state.window = state.window.slice(state.window.length - windowSize);
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
2439
|
/**
|
|
2201
2440
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
2202
2441
|
* The handler itself is not cancelled — the warning is diagnostic only.
|
|
@@ -2207,79 +2446,10 @@ var KafkaClient = class _KafkaClient {
|
|
|
2207
2446
|
if (timer !== void 0) clearTimeout(timer);
|
|
2208
2447
|
});
|
|
2209
2448
|
timer = setTimeout(() => {
|
|
2210
|
-
this.logger.warn(
|
|
2211
|
-
`Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`
|
|
2212
|
-
);
|
|
2449
|
+
this.logger.warn(`Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`);
|
|
2213
2450
|
}, timeoutMs);
|
|
2214
2451
|
return promise;
|
|
2215
2452
|
}
|
|
2216
|
-
/**
|
|
2217
|
-
* When `retryTopics: true` and `autoCreateTopics: false`, verify that every
|
|
2218
|
-
* `<topic>.retry.<level>` topic already exists. Throws a clear error at startup
|
|
2219
|
-
* rather than silently discovering missing topics on the first handler failure.
|
|
2220
|
-
*/
|
|
2221
|
-
async validateRetryTopicsExist(topicNames, maxRetries) {
|
|
2222
|
-
await this.ensureAdminConnected();
|
|
2223
|
-
const existing = new Set(await this.admin.listTopics());
|
|
2224
|
-
const missing = [];
|
|
2225
|
-
for (const t of topicNames) {
|
|
2226
|
-
for (let level = 1; level <= maxRetries; level++) {
|
|
2227
|
-
const retryTopic = `${t}.retry.${level}`;
|
|
2228
|
-
if (!existing.has(retryTopic)) missing.push(retryTopic);
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
if (missing.length > 0) {
|
|
2232
|
-
throw new Error(
|
|
2233
|
-
`retryTopics: true but the following retry topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
|
|
2234
|
-
);
|
|
2235
|
-
}
|
|
2236
|
-
}
|
|
2237
|
-
/**
|
|
2238
|
-
* When `autoCreateTopics` is disabled, verify that `<topic>.dlq` exists for every
|
|
2239
|
-
* consumed topic. Throws a clear error at startup rather than silently discovering
|
|
2240
|
-
* missing DLQ topics on the first handler failure.
|
|
2241
|
-
*/
|
|
2242
|
-
async validateDlqTopicsExist(topicNames) {
|
|
2243
|
-
await this.ensureAdminConnected();
|
|
2244
|
-
const existing = new Set(await this.admin.listTopics());
|
|
2245
|
-
const missing = topicNames.filter((t) => !existing.has(`${t}.dlq`)).map((t) => `${t}.dlq`);
|
|
2246
|
-
if (missing.length > 0) {
|
|
2247
|
-
throw new Error(
|
|
2248
|
-
`dlq: true but the following DLQ topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
|
|
2249
|
-
);
|
|
2250
|
-
}
|
|
2251
|
-
}
|
|
2252
|
-
/**
|
|
2253
|
-
* When `deduplication.strategy: 'topic'` and `autoCreateTopics: false`, verify
|
|
2254
|
-
* that every `<topic>.duplicates` destination topic already exists. Throws a
|
|
2255
|
-
* clear error at startup rather than silently dropping duplicates on first hit.
|
|
2256
|
-
*/
|
|
2257
|
-
async validateDuplicatesTopicsExist(topicNames, customDestination) {
|
|
2258
|
-
await this.ensureAdminConnected();
|
|
2259
|
-
const existing = new Set(await this.admin.listTopics());
|
|
2260
|
-
const toCheck = customDestination ? [customDestination] : topicNames.map((t) => `${t}.duplicates`);
|
|
2261
|
-
const missing = toCheck.filter((t) => !existing.has(t));
|
|
2262
|
-
if (missing.length > 0) {
|
|
2263
|
-
throw new Error(
|
|
2264
|
-
`deduplication.strategy: 'topic' but the following duplicate-routing topics do not exist: ${missing.join(", ")}. Create them manually or set autoCreateTopics: true.`
|
|
2265
|
-
);
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
/**
|
|
2269
|
-
* Connect the admin client if not already connected.
|
|
2270
|
-
* The flag is only set to `true` after a successful connect — if `admin.connect()`
|
|
2271
|
-
* throws the flag remains `false` so the next call will retry the connection.
|
|
2272
|
-
*/
|
|
2273
|
-
async ensureAdminConnected() {
|
|
2274
|
-
if (this.isAdminConnected) return;
|
|
2275
|
-
try {
|
|
2276
|
-
await this.admin.connect();
|
|
2277
|
-
this.isAdminConnected = true;
|
|
2278
|
-
} catch (err) {
|
|
2279
|
-
this.isAdminConnected = false;
|
|
2280
|
-
throw err;
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
2453
|
/**
|
|
2284
2454
|
* Create and connect a transactional producer for EOS retry routing.
|
|
2285
2455
|
* Each retry level consumer gets its own producer with a unique `transactionalId`
|
|
@@ -2292,25 +2462,25 @@ var KafkaClient = class _KafkaClient {
|
|
|
2292
2462
|
);
|
|
2293
2463
|
}
|
|
2294
2464
|
const p = this.kafka.producer({
|
|
2295
|
-
kafkaJS: {
|
|
2296
|
-
acks: -1,
|
|
2297
|
-
idempotent: true,
|
|
2298
|
-
transactionalId,
|
|
2299
|
-
maxInFlightRequests: 1
|
|
2300
|
-
}
|
|
2465
|
+
kafkaJS: { acks: -1, idempotent: true, transactionalId, maxInFlightRequests: 1 }
|
|
2301
2466
|
});
|
|
2302
2467
|
await p.connect();
|
|
2303
2468
|
_activeTransactionalIds.add(transactionalId);
|
|
2304
2469
|
this.retryTxProducers.set(transactionalId, p);
|
|
2305
2470
|
return p;
|
|
2306
2471
|
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Ensure that a topic exists by creating it if it doesn't already exist.
|
|
2474
|
+
* If `autoCreateTopics` is disabled, returns immediately.
|
|
2475
|
+
* Concurrent calls for the same topic are deduplicated.
|
|
2476
|
+
*/
|
|
2307
2477
|
async ensureTopic(topic2) {
|
|
2308
2478
|
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
2309
2479
|
let p = this.ensureTopicPromises.get(topic2);
|
|
2310
2480
|
if (!p) {
|
|
2311
2481
|
p = (async () => {
|
|
2312
|
-
await this.
|
|
2313
|
-
await this.admin.createTopics({
|
|
2482
|
+
await this.adminOps.ensureConnected();
|
|
2483
|
+
await this.adminOps.admin.createTopics({
|
|
2314
2484
|
topics: [{ topic: topic2, numPartitions: this.numPartitions }]
|
|
2315
2485
|
});
|
|
2316
2486
|
this.ensuredTopics.add(topic2);
|
|
@@ -2329,6 +2499,9 @@ var KafkaClient = class _KafkaClient {
|
|
|
2329
2499
|
interceptors = [],
|
|
2330
2500
|
schemas: optionSchemas
|
|
2331
2501
|
} = options;
|
|
2502
|
+
const stringTopics = topics.filter((t) => !(t instanceof RegExp));
|
|
2503
|
+
const regexTopics = topics.filter((t) => t instanceof RegExp);
|
|
2504
|
+
const hasRegex = regexTopics.length > 0;
|
|
2332
2505
|
const gid = optGroupId || this.defaultGroupId;
|
|
2333
2506
|
const existingMode = this.runningConsumers.get(gid);
|
|
2334
2507
|
const oppositeMode = mode === "eachMessage" ? "eachBatch" : "eachMessage";
|
|
@@ -2347,75 +2520,78 @@ var KafkaClient = class _KafkaClient {
|
|
|
2347
2520
|
gid,
|
|
2348
2521
|
fromBeginning,
|
|
2349
2522
|
options.autoCommit ?? true,
|
|
2350
|
-
this.
|
|
2351
|
-
|
|
2352
|
-
const schemaMap = buildSchemaMap(
|
|
2353
|
-
topics,
|
|
2354
|
-
this.schemaRegistry,
|
|
2355
|
-
optionSchemas,
|
|
2356
|
-
this.logger
|
|
2523
|
+
this._consumerOpsDeps,
|
|
2524
|
+
options.partitionAssigner
|
|
2357
2525
|
);
|
|
2358
|
-
const
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2526
|
+
const schemaMap = buildSchemaMap(stringTopics, this.schemaRegistry, optionSchemas, this.logger);
|
|
2527
|
+
const topicNames = stringTopics.map((t) => resolveTopicName(t));
|
|
2528
|
+
const subscribeTopics = [...topicNames, ...regexTopics];
|
|
2529
|
+
for (const t of topicNames) await this.ensureTopic(t);
|
|
2362
2530
|
if (dlq) {
|
|
2363
|
-
for (const t of topicNames) {
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
if (!this.autoCreateTopicsEnabled) {
|
|
2367
|
-
await this.validateDlqTopicsExist(topicNames);
|
|
2531
|
+
for (const t of topicNames) await this.ensureTopic(`${t}.dlq`);
|
|
2532
|
+
if (!this.autoCreateTopicsEnabled && topicNames.length > 0) {
|
|
2533
|
+
await this.adminOps.validateDlqTopicsExist(topicNames);
|
|
2368
2534
|
}
|
|
2369
2535
|
}
|
|
2370
2536
|
if (options.deduplication?.strategy === "topic") {
|
|
2371
2537
|
const dest = options.deduplication.duplicatesTopic;
|
|
2372
2538
|
if (this.autoCreateTopicsEnabled) {
|
|
2373
|
-
for (const t of topicNames) {
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
} else {
|
|
2377
|
-
await this.validateDuplicatesTopicsExist(topicNames, dest);
|
|
2539
|
+
for (const t of topicNames) await this.ensureTopic(dest ?? `${t}.duplicates`);
|
|
2540
|
+
} else if (topicNames.length > 0) {
|
|
2541
|
+
await this.adminOps.validateDuplicatesTopicsExist(topicNames, dest);
|
|
2378
2542
|
}
|
|
2379
2543
|
}
|
|
2380
2544
|
await consumer.connect();
|
|
2381
|
-
await subscribeWithRetry(
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
options.subscribeRetry
|
|
2386
|
-
);
|
|
2387
|
-
this.logger.log(
|
|
2388
|
-
`${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
|
|
2389
|
-
);
|
|
2390
|
-
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
|
|
2545
|
+
await subscribeWithRetry(consumer, subscribeTopics, this.logger, options.subscribeRetry);
|
|
2546
|
+
const displayTopics = subscribeTopics.map((t) => t instanceof RegExp ? t.toString() : t).join(", ");
|
|
2547
|
+
this.logger.log(`${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${displayTopics}`);
|
|
2548
|
+
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry, hasRegex };
|
|
2391
2549
|
}
|
|
2392
2550
|
/** Create or retrieve the deduplication context for a consumer group. */
|
|
2393
2551
|
resolveDeduplicationContext(groupId, options) {
|
|
2394
2552
|
if (!options) return void 0;
|
|
2395
|
-
if (!this.dedupStates.has(groupId))
|
|
2396
|
-
this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
|
|
2397
|
-
}
|
|
2553
|
+
if (!this.dedupStates.has(groupId)) this.dedupStates.set(groupId, /* @__PURE__ */ new Map());
|
|
2398
2554
|
return { options, state: this.dedupStates.get(groupId) };
|
|
2399
2555
|
}
|
|
2400
|
-
// ──
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2556
|
+
// ── Shared consumer setup helpers ────────────────────────────────
|
|
2557
|
+
/** Guard checks shared by startConsumer and startBatchConsumer. */
|
|
2558
|
+
validateTopicConsumerOpts(topics, options) {
|
|
2559
|
+
if (options.retryTopics && !options.retry) {
|
|
2560
|
+
throw new Error(
|
|
2561
|
+
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
if (options.retryTopics && topics.some((t) => t instanceof RegExp)) {
|
|
2565
|
+
throw new Error(
|
|
2566
|
+
"retryTopics is incompatible with regex topic patterns \u2014 retry topics require a fixed topic name to build the retry chain."
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2409
2569
|
}
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2570
|
+
/** Create EOS transactional producer context for atomic main → retry.1 routing. */
|
|
2571
|
+
async makeEosMainContext(gid, consumer, options) {
|
|
2572
|
+
if (!options.retryTopics || !options.retry) return void 0;
|
|
2573
|
+
const txProducer = await this.createRetryTxProducer(`${gid}-main-tx`);
|
|
2574
|
+
return { txProducer, consumer };
|
|
2575
|
+
}
|
|
2576
|
+
/** Start companion retry-level consumers and register them under the main groupId. */
|
|
2577
|
+
async launchRetryChain(gid, topicNames, handleMessage, retry, dlq, interceptors, schemaMap, assignmentTimeoutMs) {
|
|
2578
|
+
if (!this.autoCreateTopicsEnabled) {
|
|
2579
|
+
await this.adminOps.validateRetryTopicsExist(topicNames, retry.maxRetries);
|
|
2580
|
+
}
|
|
2581
|
+
const companions = await startRetryTopicConsumers(
|
|
2582
|
+
topicNames,
|
|
2583
|
+
gid,
|
|
2584
|
+
handleMessage,
|
|
2585
|
+
retry,
|
|
2586
|
+
dlq,
|
|
2587
|
+
interceptors,
|
|
2588
|
+
schemaMap,
|
|
2589
|
+
this._retryTopicDeps,
|
|
2590
|
+
assignmentTimeoutMs
|
|
2591
|
+
);
|
|
2592
|
+
this.companionGroupIds.set(gid, companions);
|
|
2418
2593
|
}
|
|
2594
|
+
// ── Deps object builders ─────────────────────────────────────────
|
|
2419
2595
|
/** Build MessageHandlerDeps with circuit breaker callbacks bound to the given groupId. */
|
|
2420
2596
|
messageDepsFor(gid) {
|
|
2421
2597
|
return {
|
|
@@ -2424,23 +2600,24 @@ var KafkaClient = class _KafkaClient {
|
|
|
2424
2600
|
instrumentation: this.instrumentation,
|
|
2425
2601
|
onMessageLost: this.onMessageLost,
|
|
2426
2602
|
onTtlExpired: this.onTtlExpired,
|
|
2427
|
-
onRetry: this.notifyRetry.bind(this),
|
|
2428
|
-
onDlq: (envelope, reason) => this.notifyDlq(envelope, reason, gid),
|
|
2429
|
-
onDuplicate: this.notifyDuplicate.bind(this),
|
|
2430
|
-
onMessage: (envelope) => this.notifyMessage(envelope, gid)
|
|
2603
|
+
onRetry: this.metrics.notifyRetry.bind(this.metrics),
|
|
2604
|
+
onDlq: (envelope, reason) => this.metrics.notifyDlq(envelope, reason, gid),
|
|
2605
|
+
onDuplicate: this.metrics.notifyDuplicate.bind(this.metrics),
|
|
2606
|
+
onMessage: (envelope) => this.metrics.notifyMessage(envelope, gid)
|
|
2431
2607
|
};
|
|
2432
2608
|
}
|
|
2433
|
-
|
|
2609
|
+
/** Build the deps object passed to retry topic consumers. */
|
|
2610
|
+
buildRetryTopicDeps() {
|
|
2434
2611
|
return {
|
|
2435
2612
|
logger: this.logger,
|
|
2436
2613
|
producer: this.producer,
|
|
2437
2614
|
instrumentation: this.instrumentation,
|
|
2438
2615
|
onMessageLost: this.onMessageLost,
|
|
2439
|
-
onRetry: this.notifyRetry.bind(this),
|
|
2440
|
-
onDlq: this.notifyDlq.bind(this),
|
|
2441
|
-
onMessage: this.notifyMessage.bind(this),
|
|
2616
|
+
onRetry: this.metrics.notifyRetry.bind(this.metrics),
|
|
2617
|
+
onDlq: this.metrics.notifyDlq.bind(this.metrics),
|
|
2618
|
+
onMessage: this.metrics.notifyMessage.bind(this.metrics),
|
|
2442
2619
|
ensureTopic: (t) => this.ensureTopic(t),
|
|
2443
|
-
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.
|
|
2620
|
+
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this._consumerOpsDeps),
|
|
2444
2621
|
runningConsumers: this.runningConsumers,
|
|
2445
2622
|
createRetryTxProducer: (txId) => this.createRetryTxProducer(txId)
|
|
2446
2623
|
};
|
|
@@ -2481,4 +2658,4 @@ export {
|
|
|
2481
2658
|
KafkaClient,
|
|
2482
2659
|
topic
|
|
2483
2660
|
};
|
|
2484
|
-
//# sourceMappingURL=chunk-
|
|
2661
|
+
//# sourceMappingURL=chunk-XP7LLRGQ.mjs.map
|