@drarzter/kafka-client 0.6.6 → 0.6.9
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 +126 -4
- package/dist/{chunk-KCUKXR6B.mjs → chunk-4526Y4PV.mjs} +458 -40
- package/dist/chunk-4526Y4PV.mjs.map +1 -0
- package/dist/core.d.mts +29 -3
- package/dist/core.d.ts +29 -3
- package/dist/core.js +457 -39
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +457 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -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/testing.js +11 -0
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +11 -0
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-CTwLrJVU.d.mts → types-736Gj0J3.d.mts} +142 -3
- package/dist/{types-CTwLrJVU.d.ts → types-736Gj0J3.d.ts} +142 -3
- package/package.json +1 -1
- package/dist/chunk-KCUKXR6B.mjs.map +0 -1
package/dist/core.js
CHANGED
|
@@ -359,7 +359,10 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
|
|
|
359
359
|
deps.logger.warn(`Message sent to DLQ: ${payload.topic}`);
|
|
360
360
|
} catch (error) {
|
|
361
361
|
const err = toError(error);
|
|
362
|
-
deps.logger.error(
|
|
362
|
+
deps.logger.error(
|
|
363
|
+
`Failed to send message to DLQ ${payload.topic}:`,
|
|
364
|
+
err.stack
|
|
365
|
+
);
|
|
363
366
|
await deps.onMessageLost?.({
|
|
364
367
|
topic: topic2,
|
|
365
368
|
error: err,
|
|
@@ -374,14 +377,9 @@ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
|
|
|
374
377
|
var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
|
|
375
378
|
function buildRetryTopicPayload(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders) {
|
|
376
379
|
const retryTopic = `${originalTopic}.retry.${attempt}`;
|
|
380
|
+
const STRIP = /* @__PURE__ */ new Set([RETRY_HEADER_ATTEMPT, RETRY_HEADER_AFTER, RETRY_HEADER_MAX_RETRIES, RETRY_HEADER_ORIGINAL_TOPIC]);
|
|
377
381
|
function buildHeaders(hdr) {
|
|
378
|
-
const
|
|
379
|
-
[RETRY_HEADER_ATTEMPT]: _a,
|
|
380
|
-
[RETRY_HEADER_AFTER]: _b,
|
|
381
|
-
[RETRY_HEADER_MAX_RETRIES]: _c,
|
|
382
|
-
[RETRY_HEADER_ORIGINAL_TOPIC]: _d,
|
|
383
|
-
...userHeaders
|
|
384
|
-
} = hdr;
|
|
382
|
+
const userHeaders = Object.fromEntries(Object.entries(hdr).filter(([k]) => !STRIP.has(k)));
|
|
385
383
|
return {
|
|
386
384
|
...userHeaders,
|
|
387
385
|
[RETRY_HEADER_ATTEMPT]: String(attempt),
|
|
@@ -437,10 +435,18 @@ function buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, m
|
|
|
437
435
|
"x-duplicate-incoming-clock": String(meta?.incomingClock ?? 0),
|
|
438
436
|
"x-duplicate-last-processed-clock": String(meta?.lastProcessedClock ?? 0)
|
|
439
437
|
};
|
|
440
|
-
return {
|
|
438
|
+
return {
|
|
439
|
+
topic: destinationTopic,
|
|
440
|
+
messages: [{ value: rawMessage, headers }]
|
|
441
|
+
};
|
|
441
442
|
}
|
|
442
443
|
async function sendToDuplicatesTopic(sourceTopic, rawMessage, destinationTopic, deps, meta) {
|
|
443
|
-
const payload = buildDuplicateTopicPayload(
|
|
444
|
+
const payload = buildDuplicateTopicPayload(
|
|
445
|
+
sourceTopic,
|
|
446
|
+
rawMessage,
|
|
447
|
+
destinationTopic,
|
|
448
|
+
meta
|
|
449
|
+
);
|
|
444
450
|
try {
|
|
445
451
|
await deps.producer.send(payload);
|
|
446
452
|
deps.logger.warn(`Duplicate message forwarded to ${destinationTopic}`);
|
|
@@ -532,7 +538,21 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
532
538
|
interceptors,
|
|
533
539
|
deps.instrumentation
|
|
534
540
|
);
|
|
535
|
-
if (!error)
|
|
541
|
+
if (!error) {
|
|
542
|
+
if (deps.eosCommitOnSuccess) {
|
|
543
|
+
try {
|
|
544
|
+
await deps.eosCommitOnSuccess();
|
|
545
|
+
} catch (commitErr) {
|
|
546
|
+
deps.logger.error(
|
|
547
|
+
`EOS offset commit failed after successful handler \u2014 message will be redelivered:`,
|
|
548
|
+
toError(commitErr).stack
|
|
549
|
+
);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const env of envelopes) deps.onMessage?.(env);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
536
556
|
const isLastAttempt = attempt === maxAttempts;
|
|
537
557
|
const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
|
|
538
558
|
topic2,
|
|
@@ -548,15 +568,28 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
548
568
|
if (retryTopics && retry) {
|
|
549
569
|
const cap = Math.min(backoffMs, maxBackoffMs);
|
|
550
570
|
const delay = Math.floor(Math.random() * cap);
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
571
|
+
if (deps.eosRouteToRetry) {
|
|
572
|
+
try {
|
|
573
|
+
await deps.eosRouteToRetry(rawMessages, envelopes, delay);
|
|
574
|
+
deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
|
|
575
|
+
} catch (txErr) {
|
|
576
|
+
deps.logger.error(
|
|
577
|
+
`EOS routing to retry topic failed \u2014 message will be redelivered:`,
|
|
578
|
+
toError(txErr).stack
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
await sendToRetryTopic(
|
|
583
|
+
topic2,
|
|
584
|
+
rawMessages,
|
|
585
|
+
1,
|
|
586
|
+
retry.maxRetries,
|
|
587
|
+
delay,
|
|
588
|
+
isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
|
|
589
|
+
deps
|
|
590
|
+
);
|
|
591
|
+
deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
|
|
592
|
+
}
|
|
560
593
|
} else if (isLastAttempt) {
|
|
561
594
|
if (dlq) {
|
|
562
595
|
for (let i = 0; i < rawMessages.length; i++) {
|
|
@@ -565,6 +598,7 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
565
598
|
attempt,
|
|
566
599
|
originalHeaders: envelopes[i]?.headers
|
|
567
600
|
});
|
|
601
|
+
deps.onDlq?.(envelopes[i] ?? envelopes[0], "handler-error");
|
|
568
602
|
}
|
|
569
603
|
} else {
|
|
570
604
|
await deps.onMessageLost?.({
|
|
@@ -576,6 +610,7 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
576
610
|
}
|
|
577
611
|
} else {
|
|
578
612
|
const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
|
|
613
|
+
deps.onRetry?.(envelopes[0], attempt, maxAttempts - 1);
|
|
579
614
|
await sleep(Math.floor(Math.random() * cap));
|
|
580
615
|
}
|
|
581
616
|
}
|
|
@@ -599,6 +634,7 @@ async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
|
|
|
599
634
|
deps.logger.warn(
|
|
600
635
|
`Duplicate message on ${envelope.topic}[${envelope.partition}]: clock=${incomingClock} <= last=${lastProcessedClock} \u2014 strategy=${strategy}`
|
|
601
636
|
);
|
|
637
|
+
deps.onDuplicate?.(envelope, strategy);
|
|
602
638
|
if (strategy === "dlq" && dlq) {
|
|
603
639
|
const augmentedHeaders = {
|
|
604
640
|
...envelope.headers,
|
|
@@ -653,6 +689,43 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
653
689
|
timeoutMs,
|
|
654
690
|
wrapWithTimeout
|
|
655
691
|
} = opts;
|
|
692
|
+
const eos = opts.eosMainContext;
|
|
693
|
+
const nextOffsetStr = (parseInt(message.offset, 10) + 1).toString();
|
|
694
|
+
const commitOffset = eos ? async () => {
|
|
695
|
+
await eos.consumer.commitOffsets([
|
|
696
|
+
{ topic: topic2, partition, offset: nextOffsetStr }
|
|
697
|
+
]);
|
|
698
|
+
} : void 0;
|
|
699
|
+
const eosRouteToRetry = eos && retry ? async (rawMsgs, envelopes, delay) => {
|
|
700
|
+
const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
|
|
701
|
+
topic2,
|
|
702
|
+
rawMsgs,
|
|
703
|
+
1,
|
|
704
|
+
retry.maxRetries,
|
|
705
|
+
delay,
|
|
706
|
+
envelopes[0]?.headers ?? {}
|
|
707
|
+
);
|
|
708
|
+
const tx = await eos.txProducer.transaction();
|
|
709
|
+
try {
|
|
710
|
+
await tx.send({ topic: rtTopic, messages: rtMsgs });
|
|
711
|
+
await tx.sendOffsets({
|
|
712
|
+
consumer: eos.consumer,
|
|
713
|
+
topics: [
|
|
714
|
+
{
|
|
715
|
+
topic: topic2,
|
|
716
|
+
partitions: [{ partition, offset: nextOffsetStr }]
|
|
717
|
+
}
|
|
718
|
+
]
|
|
719
|
+
});
|
|
720
|
+
await tx.commit();
|
|
721
|
+
} catch (txErr) {
|
|
722
|
+
try {
|
|
723
|
+
await tx.abort();
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
throw txErr;
|
|
727
|
+
}
|
|
728
|
+
} : void 0;
|
|
656
729
|
const envelope = await parseSingleMessage(
|
|
657
730
|
message,
|
|
658
731
|
topic2,
|
|
@@ -662,7 +735,10 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
662
735
|
dlq,
|
|
663
736
|
deps
|
|
664
737
|
);
|
|
665
|
-
if (envelope === null)
|
|
738
|
+
if (envelope === null) {
|
|
739
|
+
await commitOffset?.();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
666
742
|
if (opts.deduplication) {
|
|
667
743
|
const isDuplicate = await applyDeduplication(
|
|
668
744
|
envelope,
|
|
@@ -671,7 +747,10 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
671
747
|
dlq,
|
|
672
748
|
deps
|
|
673
749
|
);
|
|
674
|
-
if (isDuplicate)
|
|
750
|
+
if (isDuplicate) {
|
|
751
|
+
await commitOffset?.();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
675
754
|
}
|
|
676
755
|
await executeWithRetry(
|
|
677
756
|
() => {
|
|
@@ -692,7 +771,7 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
692
771
|
retry,
|
|
693
772
|
retryTopics
|
|
694
773
|
},
|
|
695
|
-
deps
|
|
774
|
+
{ ...deps, eosRouteToRetry, eosCommitOnSuccess: commitOffset }
|
|
696
775
|
);
|
|
697
776
|
}
|
|
698
777
|
async function handleEachBatch(payload, opts, deps) {
|
|
@@ -707,6 +786,50 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
707
786
|
timeoutMs,
|
|
708
787
|
wrapWithTimeout
|
|
709
788
|
} = opts;
|
|
789
|
+
const eos = opts.eosMainContext;
|
|
790
|
+
const lastRawOffset = batch.messages.length > 0 ? batch.messages[batch.messages.length - 1].offset : void 0;
|
|
791
|
+
const batchNextOffsetStr = lastRawOffset ? (parseInt(lastRawOffset, 10) + 1).toString() : void 0;
|
|
792
|
+
const commitBatchOffset = eos && batchNextOffsetStr ? async () => {
|
|
793
|
+
await eos.consumer.commitOffsets([
|
|
794
|
+
{
|
|
795
|
+
topic: batch.topic,
|
|
796
|
+
partition: batch.partition,
|
|
797
|
+
offset: batchNextOffsetStr
|
|
798
|
+
}
|
|
799
|
+
]);
|
|
800
|
+
} : void 0;
|
|
801
|
+
const eosRouteToRetry = eos && retry && batchNextOffsetStr ? async (rawMsgs, envelopes2, delay) => {
|
|
802
|
+
const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
|
|
803
|
+
batch.topic,
|
|
804
|
+
rawMsgs,
|
|
805
|
+
1,
|
|
806
|
+
retry.maxRetries,
|
|
807
|
+
delay,
|
|
808
|
+
envelopes2.map((e) => e.headers)
|
|
809
|
+
);
|
|
810
|
+
const tx = await eos.txProducer.transaction();
|
|
811
|
+
try {
|
|
812
|
+
await tx.send({ topic: rtTopic, messages: rtMsgs });
|
|
813
|
+
await tx.sendOffsets({
|
|
814
|
+
consumer: eos.consumer,
|
|
815
|
+
topics: [
|
|
816
|
+
{
|
|
817
|
+
topic: batch.topic,
|
|
818
|
+
partitions: [
|
|
819
|
+
{ partition: batch.partition, offset: batchNextOffsetStr }
|
|
820
|
+
]
|
|
821
|
+
}
|
|
822
|
+
]
|
|
823
|
+
});
|
|
824
|
+
await tx.commit();
|
|
825
|
+
} catch (txErr) {
|
|
826
|
+
try {
|
|
827
|
+
await tx.abort();
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
throw txErr;
|
|
831
|
+
}
|
|
832
|
+
} : void 0;
|
|
710
833
|
const envelopes = [];
|
|
711
834
|
const rawMessages = [];
|
|
712
835
|
for (const message of batch.messages) {
|
|
@@ -734,7 +857,10 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
734
857
|
envelopes.push(envelope);
|
|
735
858
|
rawMessages.push(message.value.toString());
|
|
736
859
|
}
|
|
737
|
-
if (envelopes.length === 0)
|
|
860
|
+
if (envelopes.length === 0) {
|
|
861
|
+
await commitBatchOffset?.();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
738
864
|
const meta = {
|
|
739
865
|
partition: batch.partition,
|
|
740
866
|
highWatermark: batch.highWatermark,
|
|
@@ -756,7 +882,7 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
756
882
|
isBatch: true,
|
|
757
883
|
retryTopics
|
|
758
884
|
},
|
|
759
|
-
deps
|
|
885
|
+
{ ...deps, eosRouteToRetry, eosCommitOnSuccess: commitBatchOffset }
|
|
760
886
|
);
|
|
761
887
|
}
|
|
762
888
|
|
|
@@ -802,6 +928,9 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
802
928
|
producer,
|
|
803
929
|
instrumentation,
|
|
804
930
|
onMessageLost,
|
|
931
|
+
onRetry,
|
|
932
|
+
onDlq,
|
|
933
|
+
onMessage,
|
|
805
934
|
ensureTopic,
|
|
806
935
|
getOrCreateConsumer: getOrCreateConsumer2,
|
|
807
936
|
runningConsumers,
|
|
@@ -883,6 +1012,7 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
883
1012
|
instrumentation
|
|
884
1013
|
);
|
|
885
1014
|
if (!error) {
|
|
1015
|
+
onMessage?.(envelope);
|
|
886
1016
|
await consumer.commitOffsets([nextOffset]);
|
|
887
1017
|
return;
|
|
888
1018
|
}
|
|
@@ -915,12 +1045,23 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
915
1045
|
await tx.send({ topic: rtTopic, messages: rtMsgs });
|
|
916
1046
|
await tx.sendOffsets({
|
|
917
1047
|
consumer,
|
|
918
|
-
topics: [
|
|
1048
|
+
topics: [
|
|
1049
|
+
{
|
|
1050
|
+
topic: nextOffset.topic,
|
|
1051
|
+
partitions: [
|
|
1052
|
+
{
|
|
1053
|
+
partition: nextOffset.partition,
|
|
1054
|
+
offset: nextOffset.offset
|
|
1055
|
+
}
|
|
1056
|
+
]
|
|
1057
|
+
}
|
|
1058
|
+
]
|
|
919
1059
|
});
|
|
920
1060
|
await tx.commit();
|
|
921
1061
|
logger.warn(
|
|
922
1062
|
`Message routed to ${rtTopic} (EOS, level ${nextLevel}/${currentMaxRetries})`
|
|
923
1063
|
);
|
|
1064
|
+
onRetry?.(envelope, nextLevel, currentMaxRetries);
|
|
924
1065
|
} catch (txErr) {
|
|
925
1066
|
try {
|
|
926
1067
|
await tx.abort();
|
|
@@ -948,10 +1089,21 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
948
1089
|
await tx.send({ topic: dTopic, messages: dMsgs });
|
|
949
1090
|
await tx.sendOffsets({
|
|
950
1091
|
consumer,
|
|
951
|
-
topics: [
|
|
1092
|
+
topics: [
|
|
1093
|
+
{
|
|
1094
|
+
topic: nextOffset.topic,
|
|
1095
|
+
partitions: [
|
|
1096
|
+
{
|
|
1097
|
+
partition: nextOffset.partition,
|
|
1098
|
+
offset: nextOffset.offset
|
|
1099
|
+
}
|
|
1100
|
+
]
|
|
1101
|
+
}
|
|
1102
|
+
]
|
|
952
1103
|
});
|
|
953
1104
|
await tx.commit();
|
|
954
1105
|
logger.warn(`Message sent to DLQ: ${dTopic} (EOS)`);
|
|
1106
|
+
onDlq?.(envelope, "handler-error");
|
|
955
1107
|
} catch (txErr) {
|
|
956
1108
|
try {
|
|
957
1109
|
await tx.abort();
|
|
@@ -975,7 +1127,12 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
975
1127
|
}
|
|
976
1128
|
});
|
|
977
1129
|
runningConsumers.set(levelGroupId, "eachMessage");
|
|
978
|
-
await waitForPartitionAssignment(
|
|
1130
|
+
await waitForPartitionAssignment(
|
|
1131
|
+
consumer,
|
|
1132
|
+
levelTopics,
|
|
1133
|
+
logger,
|
|
1134
|
+
assignmentTimeoutMs
|
|
1135
|
+
);
|
|
979
1136
|
logger.log(
|
|
980
1137
|
`Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
|
|
981
1138
|
);
|
|
@@ -1005,7 +1162,8 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
|
|
|
1005
1162
|
|
|
1006
1163
|
// src/client/kafka.client/index.ts
|
|
1007
1164
|
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
|
|
1008
|
-
var
|
|
1165
|
+
var _activeTransactionalIds = /* @__PURE__ */ new Set();
|
|
1166
|
+
var KafkaClient = class _KafkaClient {
|
|
1009
1167
|
kafka;
|
|
1010
1168
|
producer;
|
|
1011
1169
|
txProducer;
|
|
@@ -1030,6 +1188,10 @@ var KafkaClient = class {
|
|
|
1030
1188
|
instrumentation;
|
|
1031
1189
|
onMessageLost;
|
|
1032
1190
|
onRebalance;
|
|
1191
|
+
/** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
|
|
1192
|
+
txId;
|
|
1193
|
+
/** Per-topic event counters, lazily created on first event. Aggregated by `getMetrics()`. */
|
|
1194
|
+
_topicMetrics = /* @__PURE__ */ new Map();
|
|
1033
1195
|
/** Monotonically increasing Lamport clock stamped on every outgoing message. */
|
|
1034
1196
|
_lamportClock = 0;
|
|
1035
1197
|
/** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
|
|
@@ -1053,6 +1215,7 @@ var KafkaClient = class {
|
|
|
1053
1215
|
this.instrumentation = options?.instrumentation ?? [];
|
|
1054
1216
|
this.onMessageLost = options?.onMessageLost;
|
|
1055
1217
|
this.onRebalance = options?.onRebalance;
|
|
1218
|
+
this.txId = options?.transactionalId ?? `${clientId}-tx`;
|
|
1056
1219
|
this.kafka = new KafkaClass({
|
|
1057
1220
|
kafkaJS: {
|
|
1058
1221
|
clientId: this.clientId,
|
|
@@ -1089,16 +1252,22 @@ var KafkaClient = class {
|
|
|
1089
1252
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
1090
1253
|
async transaction(fn) {
|
|
1091
1254
|
if (!this.txProducerInitPromise) {
|
|
1255
|
+
if (_activeTransactionalIds.has(this.txId)) {
|
|
1256
|
+
this.logger.warn(
|
|
1257
|
+
`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.`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1092
1260
|
const initPromise = (async () => {
|
|
1093
1261
|
const p = this.kafka.producer({
|
|
1094
1262
|
kafkaJS: {
|
|
1095
1263
|
acks: -1,
|
|
1096
1264
|
idempotent: true,
|
|
1097
|
-
transactionalId:
|
|
1265
|
+
transactionalId: this.txId,
|
|
1098
1266
|
maxInFlightRequests: 1
|
|
1099
1267
|
}
|
|
1100
1268
|
});
|
|
1101
1269
|
await p.connect();
|
|
1270
|
+
_activeTransactionalIds.add(this.txId);
|
|
1102
1271
|
return p;
|
|
1103
1272
|
})();
|
|
1104
1273
|
this.txProducerInitPromise = initPromise.catch((err) => {
|
|
@@ -1166,10 +1335,20 @@ var KafkaClient = class {
|
|
|
1166
1335
|
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
1167
1336
|
);
|
|
1168
1337
|
}
|
|
1169
|
-
const
|
|
1338
|
+
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1339
|
+
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", setupOptions);
|
|
1170
1340
|
const deps = this.messageDeps;
|
|
1171
1341
|
const timeoutMs = options.handlerTimeoutMs;
|
|
1172
|
-
const deduplication = this.resolveDeduplicationContext(
|
|
1342
|
+
const deduplication = this.resolveDeduplicationContext(
|
|
1343
|
+
gid,
|
|
1344
|
+
options.deduplication
|
|
1345
|
+
);
|
|
1346
|
+
let eosMainContext;
|
|
1347
|
+
if (options.retryTopics && retry) {
|
|
1348
|
+
const mainTxId = `${gid}-main-tx`;
|
|
1349
|
+
const txProducer = await this.createRetryTxProducer(mainTxId);
|
|
1350
|
+
eosMainContext = { txProducer, consumer };
|
|
1351
|
+
}
|
|
1173
1352
|
await consumer.run({
|
|
1174
1353
|
eachMessage: (payload) => this.trackInFlight(
|
|
1175
1354
|
() => handleEachMessage(
|
|
@@ -1183,7 +1362,8 @@ var KafkaClient = class {
|
|
|
1183
1362
|
retryTopics: options.retryTopics,
|
|
1184
1363
|
timeoutMs,
|
|
1185
1364
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1186
|
-
deduplication
|
|
1365
|
+
deduplication,
|
|
1366
|
+
eosMainContext
|
|
1187
1367
|
},
|
|
1188
1368
|
deps
|
|
1189
1369
|
)
|
|
@@ -1215,15 +1395,26 @@ var KafkaClient = class {
|
|
|
1215
1395
|
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
1216
1396
|
);
|
|
1217
1397
|
}
|
|
1218
|
-
if (options.
|
|
1398
|
+
if (options.retryTopics) {
|
|
1399
|
+
} else if (options.autoCommit !== false) {
|
|
1219
1400
|
this.logger.debug?.(
|
|
1220
1401
|
`startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
|
|
1221
1402
|
);
|
|
1222
1403
|
}
|
|
1223
|
-
const
|
|
1404
|
+
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1405
|
+
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", setupOptions);
|
|
1224
1406
|
const deps = this.messageDeps;
|
|
1225
1407
|
const timeoutMs = options.handlerTimeoutMs;
|
|
1226
|
-
const deduplication = this.resolveDeduplicationContext(
|
|
1408
|
+
const deduplication = this.resolveDeduplicationContext(
|
|
1409
|
+
gid,
|
|
1410
|
+
options.deduplication
|
|
1411
|
+
);
|
|
1412
|
+
let eosMainContext;
|
|
1413
|
+
if (options.retryTopics && retry) {
|
|
1414
|
+
const mainTxId = `${gid}-main-tx`;
|
|
1415
|
+
const txProducer = await this.createRetryTxProducer(mainTxId);
|
|
1416
|
+
eosMainContext = { txProducer, consumer };
|
|
1417
|
+
}
|
|
1227
1418
|
await consumer.run({
|
|
1228
1419
|
eachBatch: (payload) => this.trackInFlight(
|
|
1229
1420
|
() => handleEachBatch(
|
|
@@ -1237,7 +1428,8 @@ var KafkaClient = class {
|
|
|
1237
1428
|
retryTopics: options.retryTopics,
|
|
1238
1429
|
timeoutMs,
|
|
1239
1430
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1240
|
-
deduplication
|
|
1431
|
+
deduplication,
|
|
1432
|
+
eosMainContext
|
|
1241
1433
|
},
|
|
1242
1434
|
deps
|
|
1243
1435
|
)
|
|
@@ -1250,7 +1442,7 @@ var KafkaClient = class {
|
|
|
1250
1442
|
}
|
|
1251
1443
|
const handleMessageForRetry = (env) => handleBatch([env], {
|
|
1252
1444
|
partition: env.partition,
|
|
1253
|
-
highWatermark:
|
|
1445
|
+
highWatermark: null,
|
|
1254
1446
|
heartbeat: async () => {
|
|
1255
1447
|
},
|
|
1256
1448
|
resolveOffset: () => {
|
|
@@ -1294,6 +1486,18 @@ var KafkaClient = class {
|
|
|
1294
1486
|
this.consumerCreationOptions.delete(groupId);
|
|
1295
1487
|
this.dedupStates.delete(groupId);
|
|
1296
1488
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
1489
|
+
const mainTxId = `${groupId}-main-tx`;
|
|
1490
|
+
const mainTxProducer = this.retryTxProducers.get(mainTxId);
|
|
1491
|
+
if (mainTxProducer) {
|
|
1492
|
+
await mainTxProducer.disconnect().catch(
|
|
1493
|
+
(e) => this.logger.warn(
|
|
1494
|
+
`Error disconnecting main tx producer "${mainTxId}":`,
|
|
1495
|
+
toError(e).message
|
|
1496
|
+
)
|
|
1497
|
+
);
|
|
1498
|
+
_activeTransactionalIds.delete(mainTxId);
|
|
1499
|
+
this.retryTxProducers.delete(mainTxId);
|
|
1500
|
+
}
|
|
1297
1501
|
const companions = this.companionGroupIds.get(groupId) ?? [];
|
|
1298
1502
|
for (const cGroupId of companions) {
|
|
1299
1503
|
const cConsumer = this.consumers.get(cGroupId);
|
|
@@ -1318,6 +1522,7 @@ var KafkaClient = class {
|
|
|
1318
1522
|
toError(e).message
|
|
1319
1523
|
)
|
|
1320
1524
|
);
|
|
1525
|
+
_activeTransactionalIds.delete(txId);
|
|
1321
1526
|
this.retryTxProducers.delete(txId);
|
|
1322
1527
|
}
|
|
1323
1528
|
}
|
|
@@ -1343,6 +1548,144 @@ var KafkaClient = class {
|
|
|
1343
1548
|
this.logger.log("All consumers disconnected");
|
|
1344
1549
|
}
|
|
1345
1550
|
}
|
|
1551
|
+
pauseConsumer(groupId, assignments) {
|
|
1552
|
+
const gid = groupId ?? this.defaultGroupId;
|
|
1553
|
+
const consumer = this.consumers.get(gid);
|
|
1554
|
+
if (!consumer) {
|
|
1555
|
+
this.logger.warn(`pauseConsumer: no active consumer for group "${gid}"`);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
consumer.pause(
|
|
1559
|
+
assignments.flatMap(
|
|
1560
|
+
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1561
|
+
)
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
resumeConsumer(groupId, assignments) {
|
|
1565
|
+
const gid = groupId ?? this.defaultGroupId;
|
|
1566
|
+
const consumer = this.consumers.get(gid);
|
|
1567
|
+
if (!consumer) {
|
|
1568
|
+
this.logger.warn(`resumeConsumer: no active consumer for group "${gid}"`);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
consumer.resume(
|
|
1572
|
+
assignments.flatMap(
|
|
1573
|
+
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1574
|
+
)
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
/** DLQ header keys added by `sendToDlq` — stripped before re-publishing. */
|
|
1578
|
+
static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
|
|
1579
|
+
"x-dlq-original-topic",
|
|
1580
|
+
"x-dlq-failed-at",
|
|
1581
|
+
"x-dlq-error-message",
|
|
1582
|
+
"x-dlq-error-stack",
|
|
1583
|
+
"x-dlq-attempt-count"
|
|
1584
|
+
]);
|
|
1585
|
+
async replayDlq(topic2, options = {}) {
|
|
1586
|
+
const dlqTopic = `${topic2}.dlq`;
|
|
1587
|
+
await this.ensureAdminConnected();
|
|
1588
|
+
const partitionOffsets = await this.admin.fetchTopicOffsets(dlqTopic);
|
|
1589
|
+
const activePartitions = partitionOffsets.filter(
|
|
1590
|
+
(p) => parseInt(p.high, 10) > 0
|
|
1591
|
+
);
|
|
1592
|
+
if (activePartitions.length === 0) {
|
|
1593
|
+
this.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
|
|
1594
|
+
return { replayed: 0, skipped: 0 };
|
|
1595
|
+
}
|
|
1596
|
+
const highWatermarks = new Map(
|
|
1597
|
+
activePartitions.map(({ partition, high }) => [
|
|
1598
|
+
partition,
|
|
1599
|
+
parseInt(high, 10)
|
|
1600
|
+
])
|
|
1601
|
+
);
|
|
1602
|
+
const processedOffsets = /* @__PURE__ */ new Map();
|
|
1603
|
+
let replayed = 0;
|
|
1604
|
+
let skipped = 0;
|
|
1605
|
+
const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
|
|
1606
|
+
await new Promise((resolve, reject) => {
|
|
1607
|
+
const consumer = getOrCreateConsumer(
|
|
1608
|
+
tempGroupId,
|
|
1609
|
+
true,
|
|
1610
|
+
true,
|
|
1611
|
+
this.consumerOpsDeps
|
|
1612
|
+
);
|
|
1613
|
+
const cleanup = () => {
|
|
1614
|
+
consumer.disconnect().catch(() => {
|
|
1615
|
+
}).finally(() => {
|
|
1616
|
+
this.consumers.delete(tempGroupId);
|
|
1617
|
+
this.runningConsumers.delete(tempGroupId);
|
|
1618
|
+
this.consumerCreationOptions.delete(tempGroupId);
|
|
1619
|
+
});
|
|
1620
|
+
};
|
|
1621
|
+
consumer.connect().then(
|
|
1622
|
+
() => subscribeWithRetry(consumer, [dlqTopic], this.logger)
|
|
1623
|
+
).then(
|
|
1624
|
+
() => consumer.run({
|
|
1625
|
+
eachMessage: async ({ partition, message }) => {
|
|
1626
|
+
if (!message.value) return;
|
|
1627
|
+
const offset = parseInt(message.offset, 10);
|
|
1628
|
+
processedOffsets.set(partition, offset);
|
|
1629
|
+
const headers = decodeHeaders(message.headers);
|
|
1630
|
+
const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
|
|
1631
|
+
const originalHeaders = Object.fromEntries(
|
|
1632
|
+
Object.entries(headers).filter(
|
|
1633
|
+
([k]) => !_KafkaClient.DLQ_HEADER_KEYS.has(k)
|
|
1634
|
+
)
|
|
1635
|
+
);
|
|
1636
|
+
const value = message.value.toString();
|
|
1637
|
+
const shouldProcess = !options.filter || options.filter(headers, value);
|
|
1638
|
+
if (!targetTopic || !shouldProcess) {
|
|
1639
|
+
skipped++;
|
|
1640
|
+
} else if (options.dryRun) {
|
|
1641
|
+
this.logger.log(
|
|
1642
|
+
`[DLQ replay dry-run] Would replay to "${targetTopic}"`
|
|
1643
|
+
);
|
|
1644
|
+
replayed++;
|
|
1645
|
+
} else {
|
|
1646
|
+
await this.producer.send({
|
|
1647
|
+
topic: targetTopic,
|
|
1648
|
+
messages: [{ value, headers: originalHeaders }]
|
|
1649
|
+
});
|
|
1650
|
+
replayed++;
|
|
1651
|
+
}
|
|
1652
|
+
const allDone = Array.from(highWatermarks.entries()).every(
|
|
1653
|
+
([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
|
|
1654
|
+
);
|
|
1655
|
+
if (allDone) {
|
|
1656
|
+
cleanup();
|
|
1657
|
+
resolve();
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
})
|
|
1661
|
+
).catch((err) => {
|
|
1662
|
+
cleanup();
|
|
1663
|
+
reject(err);
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
this.logger.log(
|
|
1667
|
+
`replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
|
|
1668
|
+
);
|
|
1669
|
+
return { replayed, skipped };
|
|
1670
|
+
}
|
|
1671
|
+
async resetOffsets(groupId, topic2, position) {
|
|
1672
|
+
const gid = groupId ?? this.defaultGroupId;
|
|
1673
|
+
if (this.runningConsumers.has(gid)) {
|
|
1674
|
+
throw new Error(
|
|
1675
|
+
`resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
await this.ensureAdminConnected();
|
|
1679
|
+
const partitionOffsets = await this.admin.fetchTopicOffsets(topic2);
|
|
1680
|
+
const partitions = partitionOffsets.map(({ partition, low, high }) => ({
|
|
1681
|
+
partition,
|
|
1682
|
+
offset: position === "earliest" ? low : high
|
|
1683
|
+
}));
|
|
1684
|
+
await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
|
|
1685
|
+
this.logger.log(
|
|
1686
|
+
`Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1346
1689
|
/**
|
|
1347
1690
|
* Query consumer group lag per partition.
|
|
1348
1691
|
* Lag = broker high-watermark − last committed offset.
|
|
@@ -1393,15 +1736,45 @@ var KafkaClient = class {
|
|
|
1393
1736
|
getClientId() {
|
|
1394
1737
|
return this.clientId;
|
|
1395
1738
|
}
|
|
1739
|
+
getMetrics(topic2) {
|
|
1740
|
+
if (topic2 !== void 0) {
|
|
1741
|
+
const m = this._topicMetrics.get(topic2);
|
|
1742
|
+
return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1743
|
+
}
|
|
1744
|
+
const agg = {
|
|
1745
|
+
processedCount: 0,
|
|
1746
|
+
retryCount: 0,
|
|
1747
|
+
dlqCount: 0,
|
|
1748
|
+
dedupCount: 0
|
|
1749
|
+
};
|
|
1750
|
+
for (const m of this._topicMetrics.values()) {
|
|
1751
|
+
agg.processedCount += m.processedCount;
|
|
1752
|
+
agg.retryCount += m.retryCount;
|
|
1753
|
+
agg.dlqCount += m.dlqCount;
|
|
1754
|
+
agg.dedupCount += m.dedupCount;
|
|
1755
|
+
}
|
|
1756
|
+
return agg;
|
|
1757
|
+
}
|
|
1758
|
+
resetMetrics(topic2) {
|
|
1759
|
+
if (topic2 !== void 0) {
|
|
1760
|
+
this._topicMetrics.delete(topic2);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
this._topicMetrics.clear();
|
|
1764
|
+
}
|
|
1396
1765
|
/** Gracefully disconnect producer, all consumers, and admin. */
|
|
1397
1766
|
async disconnect(drainTimeoutMs = 3e4) {
|
|
1398
1767
|
await this.waitForDrain(drainTimeoutMs);
|
|
1399
1768
|
const tasks = [this.producer.disconnect()];
|
|
1400
1769
|
if (this.txProducer) {
|
|
1401
1770
|
tasks.push(this.txProducer.disconnect());
|
|
1771
|
+
_activeTransactionalIds.delete(this.txId);
|
|
1402
1772
|
this.txProducer = void 0;
|
|
1403
1773
|
this.txProducerInitPromise = void 0;
|
|
1404
1774
|
}
|
|
1775
|
+
for (const txId of this.retryTxProducers.keys()) {
|
|
1776
|
+
_activeTransactionalIds.delete(txId);
|
|
1777
|
+
}
|
|
1405
1778
|
for (const p of this.retryTxProducers.values()) {
|
|
1406
1779
|
tasks.push(p.disconnect());
|
|
1407
1780
|
}
|
|
@@ -1497,6 +1870,38 @@ var KafkaClient = class {
|
|
|
1497
1870
|
}
|
|
1498
1871
|
}
|
|
1499
1872
|
}
|
|
1873
|
+
metricsFor(topic2) {
|
|
1874
|
+
let m = this._topicMetrics.get(topic2);
|
|
1875
|
+
if (!m) {
|
|
1876
|
+
m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1877
|
+
this._topicMetrics.set(topic2, m);
|
|
1878
|
+
}
|
|
1879
|
+
return m;
|
|
1880
|
+
}
|
|
1881
|
+
notifyRetry(envelope, attempt, maxRetries) {
|
|
1882
|
+
this.metricsFor(envelope.topic).retryCount++;
|
|
1883
|
+
for (const inst of this.instrumentation) {
|
|
1884
|
+
inst.onRetry?.(envelope, attempt, maxRetries);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
notifyDlq(envelope, reason) {
|
|
1888
|
+
this.metricsFor(envelope.topic).dlqCount++;
|
|
1889
|
+
for (const inst of this.instrumentation) {
|
|
1890
|
+
inst.onDlq?.(envelope, reason);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
notifyDuplicate(envelope, strategy) {
|
|
1894
|
+
this.metricsFor(envelope.topic).dedupCount++;
|
|
1895
|
+
for (const inst of this.instrumentation) {
|
|
1896
|
+
inst.onDuplicate?.(envelope, strategy);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
notifyMessage(envelope) {
|
|
1900
|
+
this.metricsFor(envelope.topic).processedCount++;
|
|
1901
|
+
for (const inst of this.instrumentation) {
|
|
1902
|
+
inst.onMessage?.(envelope);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1500
1905
|
/**
|
|
1501
1906
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
1502
1907
|
* The handler itself is not cancelled — the warning is diagnostic only.
|
|
@@ -1586,6 +1991,11 @@ var KafkaClient = class {
|
|
|
1586
1991
|
* so Kafka can fence stale producers on restart without affecting other levels.
|
|
1587
1992
|
*/
|
|
1588
1993
|
async createRetryTxProducer(transactionalId) {
|
|
1994
|
+
if (_activeTransactionalIds.has(transactionalId)) {
|
|
1995
|
+
this.logger.warn(
|
|
1996
|
+
`transactionalId "${transactionalId}" 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.`
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1589
1999
|
const p = this.kafka.producer({
|
|
1590
2000
|
kafkaJS: {
|
|
1591
2001
|
acks: -1,
|
|
@@ -1595,6 +2005,7 @@ var KafkaClient = class {
|
|
|
1595
2005
|
}
|
|
1596
2006
|
});
|
|
1597
2007
|
await p.connect();
|
|
2008
|
+
_activeTransactionalIds.add(transactionalId);
|
|
1598
2009
|
this.retryTxProducers.set(transactionalId, p);
|
|
1599
2010
|
return p;
|
|
1600
2011
|
}
|
|
@@ -1715,7 +2126,11 @@ var KafkaClient = class {
|
|
|
1715
2126
|
logger: this.logger,
|
|
1716
2127
|
producer: this.producer,
|
|
1717
2128
|
instrumentation: this.instrumentation,
|
|
1718
|
-
onMessageLost: this.onMessageLost
|
|
2129
|
+
onMessageLost: this.onMessageLost,
|
|
2130
|
+
onRetry: this.notifyRetry.bind(this),
|
|
2131
|
+
onDlq: this.notifyDlq.bind(this),
|
|
2132
|
+
onDuplicate: this.notifyDuplicate.bind(this),
|
|
2133
|
+
onMessage: this.notifyMessage.bind(this)
|
|
1719
2134
|
};
|
|
1720
2135
|
}
|
|
1721
2136
|
get retryTopicDeps() {
|
|
@@ -1724,6 +2139,9 @@ var KafkaClient = class {
|
|
|
1724
2139
|
producer: this.producer,
|
|
1725
2140
|
instrumentation: this.instrumentation,
|
|
1726
2141
|
onMessageLost: this.onMessageLost,
|
|
2142
|
+
onRetry: this.notifyRetry.bind(this),
|
|
2143
|
+
onDlq: this.notifyDlq.bind(this),
|
|
2144
|
+
onMessage: this.notifyMessage.bind(this),
|
|
1727
2145
|
ensureTopic: (t) => this.ensureTopic(t),
|
|
1728
2146
|
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
|
|
1729
2147
|
runningConsumers: this.runningConsumers,
|