@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/index.js
CHANGED
|
@@ -376,7 +376,10 @@ async function sendToDlq(topic2, rawMessage, deps, meta) {
|
|
|
376
376
|
deps.logger.warn(`Message sent to DLQ: ${payload.topic}`);
|
|
377
377
|
} catch (error) {
|
|
378
378
|
const err = toError(error);
|
|
379
|
-
deps.logger.error(
|
|
379
|
+
deps.logger.error(
|
|
380
|
+
`Failed to send message to DLQ ${payload.topic}:`,
|
|
381
|
+
err.stack
|
|
382
|
+
);
|
|
380
383
|
await deps.onMessageLost?.({
|
|
381
384
|
topic: topic2,
|
|
382
385
|
error: err,
|
|
@@ -391,14 +394,9 @@ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
|
|
|
391
394
|
var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
|
|
392
395
|
function buildRetryTopicPayload(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders) {
|
|
393
396
|
const retryTopic = `${originalTopic}.retry.${attempt}`;
|
|
397
|
+
const STRIP = /* @__PURE__ */ new Set([RETRY_HEADER_ATTEMPT, RETRY_HEADER_AFTER, RETRY_HEADER_MAX_RETRIES, RETRY_HEADER_ORIGINAL_TOPIC]);
|
|
394
398
|
function buildHeaders(hdr) {
|
|
395
|
-
const
|
|
396
|
-
[RETRY_HEADER_ATTEMPT]: _a,
|
|
397
|
-
[RETRY_HEADER_AFTER]: _b,
|
|
398
|
-
[RETRY_HEADER_MAX_RETRIES]: _c,
|
|
399
|
-
[RETRY_HEADER_ORIGINAL_TOPIC]: _d,
|
|
400
|
-
...userHeaders
|
|
401
|
-
} = hdr;
|
|
399
|
+
const userHeaders = Object.fromEntries(Object.entries(hdr).filter(([k]) => !STRIP.has(k)));
|
|
402
400
|
return {
|
|
403
401
|
...userHeaders,
|
|
404
402
|
[RETRY_HEADER_ATTEMPT]: String(attempt),
|
|
@@ -454,10 +452,18 @@ function buildDuplicateTopicPayload(sourceTopic, rawMessage, destinationTopic, m
|
|
|
454
452
|
"x-duplicate-incoming-clock": String(meta?.incomingClock ?? 0),
|
|
455
453
|
"x-duplicate-last-processed-clock": String(meta?.lastProcessedClock ?? 0)
|
|
456
454
|
};
|
|
457
|
-
return {
|
|
455
|
+
return {
|
|
456
|
+
topic: destinationTopic,
|
|
457
|
+
messages: [{ value: rawMessage, headers }]
|
|
458
|
+
};
|
|
458
459
|
}
|
|
459
460
|
async function sendToDuplicatesTopic(sourceTopic, rawMessage, destinationTopic, deps, meta) {
|
|
460
|
-
const payload = buildDuplicateTopicPayload(
|
|
461
|
+
const payload = buildDuplicateTopicPayload(
|
|
462
|
+
sourceTopic,
|
|
463
|
+
rawMessage,
|
|
464
|
+
destinationTopic,
|
|
465
|
+
meta
|
|
466
|
+
);
|
|
461
467
|
try {
|
|
462
468
|
await deps.producer.send(payload);
|
|
463
469
|
deps.logger.warn(`Duplicate message forwarded to ${destinationTopic}`);
|
|
@@ -549,7 +555,21 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
549
555
|
interceptors,
|
|
550
556
|
deps.instrumentation
|
|
551
557
|
);
|
|
552
|
-
if (!error)
|
|
558
|
+
if (!error) {
|
|
559
|
+
if (deps.eosCommitOnSuccess) {
|
|
560
|
+
try {
|
|
561
|
+
await deps.eosCommitOnSuccess();
|
|
562
|
+
} catch (commitErr) {
|
|
563
|
+
deps.logger.error(
|
|
564
|
+
`EOS offset commit failed after successful handler \u2014 message will be redelivered:`,
|
|
565
|
+
toError(commitErr).stack
|
|
566
|
+
);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
for (const env of envelopes) deps.onMessage?.(env);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
553
573
|
const isLastAttempt = attempt === maxAttempts;
|
|
554
574
|
const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
|
|
555
575
|
topic2,
|
|
@@ -565,15 +585,28 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
565
585
|
if (retryTopics && retry) {
|
|
566
586
|
const cap = Math.min(backoffMs, maxBackoffMs);
|
|
567
587
|
const delay = Math.floor(Math.random() * cap);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
588
|
+
if (deps.eosRouteToRetry) {
|
|
589
|
+
try {
|
|
590
|
+
await deps.eosRouteToRetry(rawMessages, envelopes, delay);
|
|
591
|
+
deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
|
|
592
|
+
} catch (txErr) {
|
|
593
|
+
deps.logger.error(
|
|
594
|
+
`EOS routing to retry topic failed \u2014 message will be redelivered:`,
|
|
595
|
+
toError(txErr).stack
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
await sendToRetryTopic(
|
|
600
|
+
topic2,
|
|
601
|
+
rawMessages,
|
|
602
|
+
1,
|
|
603
|
+
retry.maxRetries,
|
|
604
|
+
delay,
|
|
605
|
+
isBatch ? envelopes.map((e) => e.headers) : envelopes[0]?.headers ?? {},
|
|
606
|
+
deps
|
|
607
|
+
);
|
|
608
|
+
deps.onRetry?.(envelopes[0], 1, retry.maxRetries);
|
|
609
|
+
}
|
|
577
610
|
} else if (isLastAttempt) {
|
|
578
611
|
if (dlq) {
|
|
579
612
|
for (let i = 0; i < rawMessages.length; i++) {
|
|
@@ -582,6 +615,7 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
582
615
|
attempt,
|
|
583
616
|
originalHeaders: envelopes[i]?.headers
|
|
584
617
|
});
|
|
618
|
+
deps.onDlq?.(envelopes[i] ?? envelopes[0], "handler-error");
|
|
585
619
|
}
|
|
586
620
|
} else {
|
|
587
621
|
await deps.onMessageLost?.({
|
|
@@ -593,6 +627,7 @@ async function executeWithRetry(fn, ctx, deps) {
|
|
|
593
627
|
}
|
|
594
628
|
} else {
|
|
595
629
|
const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
|
|
630
|
+
deps.onRetry?.(envelopes[0], attempt, maxAttempts - 1);
|
|
596
631
|
await sleep(Math.floor(Math.random() * cap));
|
|
597
632
|
}
|
|
598
633
|
}
|
|
@@ -616,6 +651,7 @@ async function applyDeduplication(envelope, raw, dedup, dlq, deps) {
|
|
|
616
651
|
deps.logger.warn(
|
|
617
652
|
`Duplicate message on ${envelope.topic}[${envelope.partition}]: clock=${incomingClock} <= last=${lastProcessedClock} \u2014 strategy=${strategy}`
|
|
618
653
|
);
|
|
654
|
+
deps.onDuplicate?.(envelope, strategy);
|
|
619
655
|
if (strategy === "dlq" && dlq) {
|
|
620
656
|
const augmentedHeaders = {
|
|
621
657
|
...envelope.headers,
|
|
@@ -670,6 +706,43 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
670
706
|
timeoutMs,
|
|
671
707
|
wrapWithTimeout
|
|
672
708
|
} = opts;
|
|
709
|
+
const eos = opts.eosMainContext;
|
|
710
|
+
const nextOffsetStr = (parseInt(message.offset, 10) + 1).toString();
|
|
711
|
+
const commitOffset = eos ? async () => {
|
|
712
|
+
await eos.consumer.commitOffsets([
|
|
713
|
+
{ topic: topic2, partition, offset: nextOffsetStr }
|
|
714
|
+
]);
|
|
715
|
+
} : void 0;
|
|
716
|
+
const eosRouteToRetry = eos && retry ? async (rawMsgs, envelopes, delay) => {
|
|
717
|
+
const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
|
|
718
|
+
topic2,
|
|
719
|
+
rawMsgs,
|
|
720
|
+
1,
|
|
721
|
+
retry.maxRetries,
|
|
722
|
+
delay,
|
|
723
|
+
envelopes[0]?.headers ?? {}
|
|
724
|
+
);
|
|
725
|
+
const tx = await eos.txProducer.transaction();
|
|
726
|
+
try {
|
|
727
|
+
await tx.send({ topic: rtTopic, messages: rtMsgs });
|
|
728
|
+
await tx.sendOffsets({
|
|
729
|
+
consumer: eos.consumer,
|
|
730
|
+
topics: [
|
|
731
|
+
{
|
|
732
|
+
topic: topic2,
|
|
733
|
+
partitions: [{ partition, offset: nextOffsetStr }]
|
|
734
|
+
}
|
|
735
|
+
]
|
|
736
|
+
});
|
|
737
|
+
await tx.commit();
|
|
738
|
+
} catch (txErr) {
|
|
739
|
+
try {
|
|
740
|
+
await tx.abort();
|
|
741
|
+
} catch {
|
|
742
|
+
}
|
|
743
|
+
throw txErr;
|
|
744
|
+
}
|
|
745
|
+
} : void 0;
|
|
673
746
|
const envelope = await parseSingleMessage(
|
|
674
747
|
message,
|
|
675
748
|
topic2,
|
|
@@ -679,7 +752,10 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
679
752
|
dlq,
|
|
680
753
|
deps
|
|
681
754
|
);
|
|
682
|
-
if (envelope === null)
|
|
755
|
+
if (envelope === null) {
|
|
756
|
+
await commitOffset?.();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
683
759
|
if (opts.deduplication) {
|
|
684
760
|
const isDuplicate = await applyDeduplication(
|
|
685
761
|
envelope,
|
|
@@ -688,7 +764,10 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
688
764
|
dlq,
|
|
689
765
|
deps
|
|
690
766
|
);
|
|
691
|
-
if (isDuplicate)
|
|
767
|
+
if (isDuplicate) {
|
|
768
|
+
await commitOffset?.();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
692
771
|
}
|
|
693
772
|
await executeWithRetry(
|
|
694
773
|
() => {
|
|
@@ -709,7 +788,7 @@ async function handleEachMessage(payload, opts, deps) {
|
|
|
709
788
|
retry,
|
|
710
789
|
retryTopics
|
|
711
790
|
},
|
|
712
|
-
deps
|
|
791
|
+
{ ...deps, eosRouteToRetry, eosCommitOnSuccess: commitOffset }
|
|
713
792
|
);
|
|
714
793
|
}
|
|
715
794
|
async function handleEachBatch(payload, opts, deps) {
|
|
@@ -724,6 +803,50 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
724
803
|
timeoutMs,
|
|
725
804
|
wrapWithTimeout
|
|
726
805
|
} = opts;
|
|
806
|
+
const eos = opts.eosMainContext;
|
|
807
|
+
const lastRawOffset = batch.messages.length > 0 ? batch.messages[batch.messages.length - 1].offset : void 0;
|
|
808
|
+
const batchNextOffsetStr = lastRawOffset ? (parseInt(lastRawOffset, 10) + 1).toString() : void 0;
|
|
809
|
+
const commitBatchOffset = eos && batchNextOffsetStr ? async () => {
|
|
810
|
+
await eos.consumer.commitOffsets([
|
|
811
|
+
{
|
|
812
|
+
topic: batch.topic,
|
|
813
|
+
partition: batch.partition,
|
|
814
|
+
offset: batchNextOffsetStr
|
|
815
|
+
}
|
|
816
|
+
]);
|
|
817
|
+
} : void 0;
|
|
818
|
+
const eosRouteToRetry = eos && retry && batchNextOffsetStr ? async (rawMsgs, envelopes2, delay) => {
|
|
819
|
+
const { topic: rtTopic, messages: rtMsgs } = buildRetryTopicPayload(
|
|
820
|
+
batch.topic,
|
|
821
|
+
rawMsgs,
|
|
822
|
+
1,
|
|
823
|
+
retry.maxRetries,
|
|
824
|
+
delay,
|
|
825
|
+
envelopes2.map((e) => e.headers)
|
|
826
|
+
);
|
|
827
|
+
const tx = await eos.txProducer.transaction();
|
|
828
|
+
try {
|
|
829
|
+
await tx.send({ topic: rtTopic, messages: rtMsgs });
|
|
830
|
+
await tx.sendOffsets({
|
|
831
|
+
consumer: eos.consumer,
|
|
832
|
+
topics: [
|
|
833
|
+
{
|
|
834
|
+
topic: batch.topic,
|
|
835
|
+
partitions: [
|
|
836
|
+
{ partition: batch.partition, offset: batchNextOffsetStr }
|
|
837
|
+
]
|
|
838
|
+
}
|
|
839
|
+
]
|
|
840
|
+
});
|
|
841
|
+
await tx.commit();
|
|
842
|
+
} catch (txErr) {
|
|
843
|
+
try {
|
|
844
|
+
await tx.abort();
|
|
845
|
+
} catch {
|
|
846
|
+
}
|
|
847
|
+
throw txErr;
|
|
848
|
+
}
|
|
849
|
+
} : void 0;
|
|
727
850
|
const envelopes = [];
|
|
728
851
|
const rawMessages = [];
|
|
729
852
|
for (const message of batch.messages) {
|
|
@@ -751,7 +874,10 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
751
874
|
envelopes.push(envelope);
|
|
752
875
|
rawMessages.push(message.value.toString());
|
|
753
876
|
}
|
|
754
|
-
if (envelopes.length === 0)
|
|
877
|
+
if (envelopes.length === 0) {
|
|
878
|
+
await commitBatchOffset?.();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
755
881
|
const meta = {
|
|
756
882
|
partition: batch.partition,
|
|
757
883
|
highWatermark: batch.highWatermark,
|
|
@@ -773,7 +899,7 @@ async function handleEachBatch(payload, opts, deps) {
|
|
|
773
899
|
isBatch: true,
|
|
774
900
|
retryTopics
|
|
775
901
|
},
|
|
776
|
-
deps
|
|
902
|
+
{ ...deps, eosRouteToRetry, eosCommitOnSuccess: commitBatchOffset }
|
|
777
903
|
);
|
|
778
904
|
}
|
|
779
905
|
|
|
@@ -819,6 +945,9 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
819
945
|
producer,
|
|
820
946
|
instrumentation,
|
|
821
947
|
onMessageLost,
|
|
948
|
+
onRetry,
|
|
949
|
+
onDlq,
|
|
950
|
+
onMessage,
|
|
822
951
|
ensureTopic,
|
|
823
952
|
getOrCreateConsumer: getOrCreateConsumer2,
|
|
824
953
|
runningConsumers,
|
|
@@ -900,6 +1029,7 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
900
1029
|
instrumentation
|
|
901
1030
|
);
|
|
902
1031
|
if (!error) {
|
|
1032
|
+
onMessage?.(envelope);
|
|
903
1033
|
await consumer.commitOffsets([nextOffset]);
|
|
904
1034
|
return;
|
|
905
1035
|
}
|
|
@@ -932,12 +1062,23 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
932
1062
|
await tx.send({ topic: rtTopic, messages: rtMsgs });
|
|
933
1063
|
await tx.sendOffsets({
|
|
934
1064
|
consumer,
|
|
935
|
-
topics: [
|
|
1065
|
+
topics: [
|
|
1066
|
+
{
|
|
1067
|
+
topic: nextOffset.topic,
|
|
1068
|
+
partitions: [
|
|
1069
|
+
{
|
|
1070
|
+
partition: nextOffset.partition,
|
|
1071
|
+
offset: nextOffset.offset
|
|
1072
|
+
}
|
|
1073
|
+
]
|
|
1074
|
+
}
|
|
1075
|
+
]
|
|
936
1076
|
});
|
|
937
1077
|
await tx.commit();
|
|
938
1078
|
logger.warn(
|
|
939
1079
|
`Message routed to ${rtTopic} (EOS, level ${nextLevel}/${currentMaxRetries})`
|
|
940
1080
|
);
|
|
1081
|
+
onRetry?.(envelope, nextLevel, currentMaxRetries);
|
|
941
1082
|
} catch (txErr) {
|
|
942
1083
|
try {
|
|
943
1084
|
await tx.abort();
|
|
@@ -965,10 +1106,21 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
965
1106
|
await tx.send({ topic: dTopic, messages: dMsgs });
|
|
966
1107
|
await tx.sendOffsets({
|
|
967
1108
|
consumer,
|
|
968
|
-
topics: [
|
|
1109
|
+
topics: [
|
|
1110
|
+
{
|
|
1111
|
+
topic: nextOffset.topic,
|
|
1112
|
+
partitions: [
|
|
1113
|
+
{
|
|
1114
|
+
partition: nextOffset.partition,
|
|
1115
|
+
offset: nextOffset.offset
|
|
1116
|
+
}
|
|
1117
|
+
]
|
|
1118
|
+
}
|
|
1119
|
+
]
|
|
969
1120
|
});
|
|
970
1121
|
await tx.commit();
|
|
971
1122
|
logger.warn(`Message sent to DLQ: ${dTopic} (EOS)`);
|
|
1123
|
+
onDlq?.(envelope, "handler-error");
|
|
972
1124
|
} catch (txErr) {
|
|
973
1125
|
try {
|
|
974
1126
|
await tx.abort();
|
|
@@ -992,7 +1144,12 @@ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopi
|
|
|
992
1144
|
}
|
|
993
1145
|
});
|
|
994
1146
|
runningConsumers.set(levelGroupId, "eachMessage");
|
|
995
|
-
await waitForPartitionAssignment(
|
|
1147
|
+
await waitForPartitionAssignment(
|
|
1148
|
+
consumer,
|
|
1149
|
+
levelTopics,
|
|
1150
|
+
logger,
|
|
1151
|
+
assignmentTimeoutMs
|
|
1152
|
+
);
|
|
996
1153
|
logger.log(
|
|
997
1154
|
`Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
|
|
998
1155
|
);
|
|
@@ -1022,7 +1179,8 @@ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleM
|
|
|
1022
1179
|
|
|
1023
1180
|
// src/client/kafka.client/index.ts
|
|
1024
1181
|
var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
|
|
1025
|
-
var
|
|
1182
|
+
var _activeTransactionalIds = /* @__PURE__ */ new Set();
|
|
1183
|
+
var KafkaClient = class _KafkaClient {
|
|
1026
1184
|
kafka;
|
|
1027
1185
|
producer;
|
|
1028
1186
|
txProducer;
|
|
@@ -1047,6 +1205,10 @@ var KafkaClient = class {
|
|
|
1047
1205
|
instrumentation;
|
|
1048
1206
|
onMessageLost;
|
|
1049
1207
|
onRebalance;
|
|
1208
|
+
/** Transactional producer ID — configurable via `KafkaClientOptions.transactionalId`. */
|
|
1209
|
+
txId;
|
|
1210
|
+
/** Per-topic event counters, lazily created on first event. Aggregated by `getMetrics()`. */
|
|
1211
|
+
_topicMetrics = /* @__PURE__ */ new Map();
|
|
1050
1212
|
/** Monotonically increasing Lamport clock stamped on every outgoing message. */
|
|
1051
1213
|
_lamportClock = 0;
|
|
1052
1214
|
/** Per-groupId deduplication state: `"topic:partition"` → last processed clock. */
|
|
@@ -1070,6 +1232,7 @@ var KafkaClient = class {
|
|
|
1070
1232
|
this.instrumentation = options?.instrumentation ?? [];
|
|
1071
1233
|
this.onMessageLost = options?.onMessageLost;
|
|
1072
1234
|
this.onRebalance = options?.onRebalance;
|
|
1235
|
+
this.txId = options?.transactionalId ?? `${clientId}-tx`;
|
|
1073
1236
|
this.kafka = new KafkaClass({
|
|
1074
1237
|
kafkaJS: {
|
|
1075
1238
|
clientId: this.clientId,
|
|
@@ -1106,16 +1269,22 @@ var KafkaClient = class {
|
|
|
1106
1269
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
1107
1270
|
async transaction(fn) {
|
|
1108
1271
|
if (!this.txProducerInitPromise) {
|
|
1272
|
+
if (_activeTransactionalIds.has(this.txId)) {
|
|
1273
|
+
this.logger.warn(
|
|
1274
|
+
`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.`
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1109
1277
|
const initPromise = (async () => {
|
|
1110
1278
|
const p = this.kafka.producer({
|
|
1111
1279
|
kafkaJS: {
|
|
1112
1280
|
acks: -1,
|
|
1113
1281
|
idempotent: true,
|
|
1114
|
-
transactionalId:
|
|
1282
|
+
transactionalId: this.txId,
|
|
1115
1283
|
maxInFlightRequests: 1
|
|
1116
1284
|
}
|
|
1117
1285
|
});
|
|
1118
1286
|
await p.connect();
|
|
1287
|
+
_activeTransactionalIds.add(this.txId);
|
|
1119
1288
|
return p;
|
|
1120
1289
|
})();
|
|
1121
1290
|
this.txProducerInitPromise = initPromise.catch((err) => {
|
|
@@ -1183,10 +1352,20 @@ var KafkaClient = class {
|
|
|
1183
1352
|
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
1184
1353
|
);
|
|
1185
1354
|
}
|
|
1186
|
-
const
|
|
1355
|
+
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1356
|
+
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", setupOptions);
|
|
1187
1357
|
const deps = this.messageDeps;
|
|
1188
1358
|
const timeoutMs = options.handlerTimeoutMs;
|
|
1189
|
-
const deduplication = this.resolveDeduplicationContext(
|
|
1359
|
+
const deduplication = this.resolveDeduplicationContext(
|
|
1360
|
+
gid,
|
|
1361
|
+
options.deduplication
|
|
1362
|
+
);
|
|
1363
|
+
let eosMainContext;
|
|
1364
|
+
if (options.retryTopics && retry) {
|
|
1365
|
+
const mainTxId = `${gid}-main-tx`;
|
|
1366
|
+
const txProducer = await this.createRetryTxProducer(mainTxId);
|
|
1367
|
+
eosMainContext = { txProducer, consumer };
|
|
1368
|
+
}
|
|
1190
1369
|
await consumer.run({
|
|
1191
1370
|
eachMessage: (payload) => this.trackInFlight(
|
|
1192
1371
|
() => handleEachMessage(
|
|
@@ -1200,7 +1379,8 @@ var KafkaClient = class {
|
|
|
1200
1379
|
retryTopics: options.retryTopics,
|
|
1201
1380
|
timeoutMs,
|
|
1202
1381
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1203
|
-
deduplication
|
|
1382
|
+
deduplication,
|
|
1383
|
+
eosMainContext
|
|
1204
1384
|
},
|
|
1205
1385
|
deps
|
|
1206
1386
|
)
|
|
@@ -1232,15 +1412,26 @@ var KafkaClient = class {
|
|
|
1232
1412
|
"retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
|
|
1233
1413
|
);
|
|
1234
1414
|
}
|
|
1235
|
-
if (options.
|
|
1415
|
+
if (options.retryTopics) {
|
|
1416
|
+
} else if (options.autoCommit !== false) {
|
|
1236
1417
|
this.logger.debug?.(
|
|
1237
1418
|
`startBatchConsumer: autoCommit is enabled (default true). If your handler calls resolveOffset() or commitOffsetsIfNecessary(), set autoCommit: false to avoid offset conflicts.`
|
|
1238
1419
|
);
|
|
1239
1420
|
}
|
|
1240
|
-
const
|
|
1421
|
+
const setupOptions = options.retryTopics ? { ...options, autoCommit: false } : options;
|
|
1422
|
+
const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", setupOptions);
|
|
1241
1423
|
const deps = this.messageDeps;
|
|
1242
1424
|
const timeoutMs = options.handlerTimeoutMs;
|
|
1243
|
-
const deduplication = this.resolveDeduplicationContext(
|
|
1425
|
+
const deduplication = this.resolveDeduplicationContext(
|
|
1426
|
+
gid,
|
|
1427
|
+
options.deduplication
|
|
1428
|
+
);
|
|
1429
|
+
let eosMainContext;
|
|
1430
|
+
if (options.retryTopics && retry) {
|
|
1431
|
+
const mainTxId = `${gid}-main-tx`;
|
|
1432
|
+
const txProducer = await this.createRetryTxProducer(mainTxId);
|
|
1433
|
+
eosMainContext = { txProducer, consumer };
|
|
1434
|
+
}
|
|
1244
1435
|
await consumer.run({
|
|
1245
1436
|
eachBatch: (payload) => this.trackInFlight(
|
|
1246
1437
|
() => handleEachBatch(
|
|
@@ -1254,7 +1445,8 @@ var KafkaClient = class {
|
|
|
1254
1445
|
retryTopics: options.retryTopics,
|
|
1255
1446
|
timeoutMs,
|
|
1256
1447
|
wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this),
|
|
1257
|
-
deduplication
|
|
1448
|
+
deduplication,
|
|
1449
|
+
eosMainContext
|
|
1258
1450
|
},
|
|
1259
1451
|
deps
|
|
1260
1452
|
)
|
|
@@ -1267,7 +1459,7 @@ var KafkaClient = class {
|
|
|
1267
1459
|
}
|
|
1268
1460
|
const handleMessageForRetry = (env) => handleBatch([env], {
|
|
1269
1461
|
partition: env.partition,
|
|
1270
|
-
highWatermark:
|
|
1462
|
+
highWatermark: null,
|
|
1271
1463
|
heartbeat: async () => {
|
|
1272
1464
|
},
|
|
1273
1465
|
resolveOffset: () => {
|
|
@@ -1311,6 +1503,18 @@ var KafkaClient = class {
|
|
|
1311
1503
|
this.consumerCreationOptions.delete(groupId);
|
|
1312
1504
|
this.dedupStates.delete(groupId);
|
|
1313
1505
|
this.logger.log(`Consumer disconnected: group "${groupId}"`);
|
|
1506
|
+
const mainTxId = `${groupId}-main-tx`;
|
|
1507
|
+
const mainTxProducer = this.retryTxProducers.get(mainTxId);
|
|
1508
|
+
if (mainTxProducer) {
|
|
1509
|
+
await mainTxProducer.disconnect().catch(
|
|
1510
|
+
(e) => this.logger.warn(
|
|
1511
|
+
`Error disconnecting main tx producer "${mainTxId}":`,
|
|
1512
|
+
toError(e).message
|
|
1513
|
+
)
|
|
1514
|
+
);
|
|
1515
|
+
_activeTransactionalIds.delete(mainTxId);
|
|
1516
|
+
this.retryTxProducers.delete(mainTxId);
|
|
1517
|
+
}
|
|
1314
1518
|
const companions = this.companionGroupIds.get(groupId) ?? [];
|
|
1315
1519
|
for (const cGroupId of companions) {
|
|
1316
1520
|
const cConsumer = this.consumers.get(cGroupId);
|
|
@@ -1335,6 +1539,7 @@ var KafkaClient = class {
|
|
|
1335
1539
|
toError(e).message
|
|
1336
1540
|
)
|
|
1337
1541
|
);
|
|
1542
|
+
_activeTransactionalIds.delete(txId);
|
|
1338
1543
|
this.retryTxProducers.delete(txId);
|
|
1339
1544
|
}
|
|
1340
1545
|
}
|
|
@@ -1360,6 +1565,144 @@ var KafkaClient = class {
|
|
|
1360
1565
|
this.logger.log("All consumers disconnected");
|
|
1361
1566
|
}
|
|
1362
1567
|
}
|
|
1568
|
+
pauseConsumer(groupId, assignments) {
|
|
1569
|
+
const gid = groupId ?? this.defaultGroupId;
|
|
1570
|
+
const consumer = this.consumers.get(gid);
|
|
1571
|
+
if (!consumer) {
|
|
1572
|
+
this.logger.warn(`pauseConsumer: no active consumer for group "${gid}"`);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
consumer.pause(
|
|
1576
|
+
assignments.flatMap(
|
|
1577
|
+
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1578
|
+
)
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
resumeConsumer(groupId, assignments) {
|
|
1582
|
+
const gid = groupId ?? this.defaultGroupId;
|
|
1583
|
+
const consumer = this.consumers.get(gid);
|
|
1584
|
+
if (!consumer) {
|
|
1585
|
+
this.logger.warn(`resumeConsumer: no active consumer for group "${gid}"`);
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
consumer.resume(
|
|
1589
|
+
assignments.flatMap(
|
|
1590
|
+
({ topic: topic2, partitions }) => partitions.map((p) => ({ topic: topic2, partitions: [p] }))
|
|
1591
|
+
)
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
/** DLQ header keys added by `sendToDlq` — stripped before re-publishing. */
|
|
1595
|
+
static DLQ_HEADER_KEYS = /* @__PURE__ */ new Set([
|
|
1596
|
+
"x-dlq-original-topic",
|
|
1597
|
+
"x-dlq-failed-at",
|
|
1598
|
+
"x-dlq-error-message",
|
|
1599
|
+
"x-dlq-error-stack",
|
|
1600
|
+
"x-dlq-attempt-count"
|
|
1601
|
+
]);
|
|
1602
|
+
async replayDlq(topic2, options = {}) {
|
|
1603
|
+
const dlqTopic = `${topic2}.dlq`;
|
|
1604
|
+
await this.ensureAdminConnected();
|
|
1605
|
+
const partitionOffsets = await this.admin.fetchTopicOffsets(dlqTopic);
|
|
1606
|
+
const activePartitions = partitionOffsets.filter(
|
|
1607
|
+
(p) => parseInt(p.high, 10) > 0
|
|
1608
|
+
);
|
|
1609
|
+
if (activePartitions.length === 0) {
|
|
1610
|
+
this.logger.log(`replayDlq: "${dlqTopic}" is empty \u2014 nothing to replay`);
|
|
1611
|
+
return { replayed: 0, skipped: 0 };
|
|
1612
|
+
}
|
|
1613
|
+
const highWatermarks = new Map(
|
|
1614
|
+
activePartitions.map(({ partition, high }) => [
|
|
1615
|
+
partition,
|
|
1616
|
+
parseInt(high, 10)
|
|
1617
|
+
])
|
|
1618
|
+
);
|
|
1619
|
+
const processedOffsets = /* @__PURE__ */ new Map();
|
|
1620
|
+
let replayed = 0;
|
|
1621
|
+
let skipped = 0;
|
|
1622
|
+
const tempGroupId = `${dlqTopic}-replay-${Date.now()}`;
|
|
1623
|
+
await new Promise((resolve, reject) => {
|
|
1624
|
+
const consumer = getOrCreateConsumer(
|
|
1625
|
+
tempGroupId,
|
|
1626
|
+
true,
|
|
1627
|
+
true,
|
|
1628
|
+
this.consumerOpsDeps
|
|
1629
|
+
);
|
|
1630
|
+
const cleanup = () => {
|
|
1631
|
+
consumer.disconnect().catch(() => {
|
|
1632
|
+
}).finally(() => {
|
|
1633
|
+
this.consumers.delete(tempGroupId);
|
|
1634
|
+
this.runningConsumers.delete(tempGroupId);
|
|
1635
|
+
this.consumerCreationOptions.delete(tempGroupId);
|
|
1636
|
+
});
|
|
1637
|
+
};
|
|
1638
|
+
consumer.connect().then(
|
|
1639
|
+
() => subscribeWithRetry(consumer, [dlqTopic], this.logger)
|
|
1640
|
+
).then(
|
|
1641
|
+
() => consumer.run({
|
|
1642
|
+
eachMessage: async ({ partition, message }) => {
|
|
1643
|
+
if (!message.value) return;
|
|
1644
|
+
const offset = parseInt(message.offset, 10);
|
|
1645
|
+
processedOffsets.set(partition, offset);
|
|
1646
|
+
const headers = decodeHeaders(message.headers);
|
|
1647
|
+
const targetTopic = options.targetTopic ?? headers["x-dlq-original-topic"];
|
|
1648
|
+
const originalHeaders = Object.fromEntries(
|
|
1649
|
+
Object.entries(headers).filter(
|
|
1650
|
+
([k]) => !_KafkaClient.DLQ_HEADER_KEYS.has(k)
|
|
1651
|
+
)
|
|
1652
|
+
);
|
|
1653
|
+
const value = message.value.toString();
|
|
1654
|
+
const shouldProcess = !options.filter || options.filter(headers, value);
|
|
1655
|
+
if (!targetTopic || !shouldProcess) {
|
|
1656
|
+
skipped++;
|
|
1657
|
+
} else if (options.dryRun) {
|
|
1658
|
+
this.logger.log(
|
|
1659
|
+
`[DLQ replay dry-run] Would replay to "${targetTopic}"`
|
|
1660
|
+
);
|
|
1661
|
+
replayed++;
|
|
1662
|
+
} else {
|
|
1663
|
+
await this.producer.send({
|
|
1664
|
+
topic: targetTopic,
|
|
1665
|
+
messages: [{ value, headers: originalHeaders }]
|
|
1666
|
+
});
|
|
1667
|
+
replayed++;
|
|
1668
|
+
}
|
|
1669
|
+
const allDone = Array.from(highWatermarks.entries()).every(
|
|
1670
|
+
([p, hwm]) => (processedOffsets.get(p) ?? -1) >= hwm - 1
|
|
1671
|
+
);
|
|
1672
|
+
if (allDone) {
|
|
1673
|
+
cleanup();
|
|
1674
|
+
resolve();
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
})
|
|
1678
|
+
).catch((err) => {
|
|
1679
|
+
cleanup();
|
|
1680
|
+
reject(err);
|
|
1681
|
+
});
|
|
1682
|
+
});
|
|
1683
|
+
this.logger.log(
|
|
1684
|
+
`replayDlq: replayed ${replayed}, skipped ${skipped} from "${dlqTopic}"`
|
|
1685
|
+
);
|
|
1686
|
+
return { replayed, skipped };
|
|
1687
|
+
}
|
|
1688
|
+
async resetOffsets(groupId, topic2, position) {
|
|
1689
|
+
const gid = groupId ?? this.defaultGroupId;
|
|
1690
|
+
if (this.runningConsumers.has(gid)) {
|
|
1691
|
+
throw new Error(
|
|
1692
|
+
`resetOffsets: consumer group "${gid}" is still running. Call stopConsumer("${gid}") before resetting offsets.`
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
await this.ensureAdminConnected();
|
|
1696
|
+
const partitionOffsets = await this.admin.fetchTopicOffsets(topic2);
|
|
1697
|
+
const partitions = partitionOffsets.map(({ partition, low, high }) => ({
|
|
1698
|
+
partition,
|
|
1699
|
+
offset: position === "earliest" ? low : high
|
|
1700
|
+
}));
|
|
1701
|
+
await this.admin.setOffsets({ groupId: gid, topic: topic2, partitions });
|
|
1702
|
+
this.logger.log(
|
|
1703
|
+
`Offsets reset to ${position} for group "${gid}" on topic "${topic2}"`
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1363
1706
|
/**
|
|
1364
1707
|
* Query consumer group lag per partition.
|
|
1365
1708
|
* Lag = broker high-watermark − last committed offset.
|
|
@@ -1410,15 +1753,45 @@ var KafkaClient = class {
|
|
|
1410
1753
|
getClientId() {
|
|
1411
1754
|
return this.clientId;
|
|
1412
1755
|
}
|
|
1756
|
+
getMetrics(topic2) {
|
|
1757
|
+
if (topic2 !== void 0) {
|
|
1758
|
+
const m = this._topicMetrics.get(topic2);
|
|
1759
|
+
return m ? { ...m } : { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1760
|
+
}
|
|
1761
|
+
const agg = {
|
|
1762
|
+
processedCount: 0,
|
|
1763
|
+
retryCount: 0,
|
|
1764
|
+
dlqCount: 0,
|
|
1765
|
+
dedupCount: 0
|
|
1766
|
+
};
|
|
1767
|
+
for (const m of this._topicMetrics.values()) {
|
|
1768
|
+
agg.processedCount += m.processedCount;
|
|
1769
|
+
agg.retryCount += m.retryCount;
|
|
1770
|
+
agg.dlqCount += m.dlqCount;
|
|
1771
|
+
agg.dedupCount += m.dedupCount;
|
|
1772
|
+
}
|
|
1773
|
+
return agg;
|
|
1774
|
+
}
|
|
1775
|
+
resetMetrics(topic2) {
|
|
1776
|
+
if (topic2 !== void 0) {
|
|
1777
|
+
this._topicMetrics.delete(topic2);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
this._topicMetrics.clear();
|
|
1781
|
+
}
|
|
1413
1782
|
/** Gracefully disconnect producer, all consumers, and admin. */
|
|
1414
1783
|
async disconnect(drainTimeoutMs = 3e4) {
|
|
1415
1784
|
await this.waitForDrain(drainTimeoutMs);
|
|
1416
1785
|
const tasks = [this.producer.disconnect()];
|
|
1417
1786
|
if (this.txProducer) {
|
|
1418
1787
|
tasks.push(this.txProducer.disconnect());
|
|
1788
|
+
_activeTransactionalIds.delete(this.txId);
|
|
1419
1789
|
this.txProducer = void 0;
|
|
1420
1790
|
this.txProducerInitPromise = void 0;
|
|
1421
1791
|
}
|
|
1792
|
+
for (const txId of this.retryTxProducers.keys()) {
|
|
1793
|
+
_activeTransactionalIds.delete(txId);
|
|
1794
|
+
}
|
|
1422
1795
|
for (const p of this.retryTxProducers.values()) {
|
|
1423
1796
|
tasks.push(p.disconnect());
|
|
1424
1797
|
}
|
|
@@ -1514,6 +1887,38 @@ var KafkaClient = class {
|
|
|
1514
1887
|
}
|
|
1515
1888
|
}
|
|
1516
1889
|
}
|
|
1890
|
+
metricsFor(topic2) {
|
|
1891
|
+
let m = this._topicMetrics.get(topic2);
|
|
1892
|
+
if (!m) {
|
|
1893
|
+
m = { processedCount: 0, retryCount: 0, dlqCount: 0, dedupCount: 0 };
|
|
1894
|
+
this._topicMetrics.set(topic2, m);
|
|
1895
|
+
}
|
|
1896
|
+
return m;
|
|
1897
|
+
}
|
|
1898
|
+
notifyRetry(envelope, attempt, maxRetries) {
|
|
1899
|
+
this.metricsFor(envelope.topic).retryCount++;
|
|
1900
|
+
for (const inst of this.instrumentation) {
|
|
1901
|
+
inst.onRetry?.(envelope, attempt, maxRetries);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
notifyDlq(envelope, reason) {
|
|
1905
|
+
this.metricsFor(envelope.topic).dlqCount++;
|
|
1906
|
+
for (const inst of this.instrumentation) {
|
|
1907
|
+
inst.onDlq?.(envelope, reason);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
notifyDuplicate(envelope, strategy) {
|
|
1911
|
+
this.metricsFor(envelope.topic).dedupCount++;
|
|
1912
|
+
for (const inst of this.instrumentation) {
|
|
1913
|
+
inst.onDuplicate?.(envelope, strategy);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
notifyMessage(envelope) {
|
|
1917
|
+
this.metricsFor(envelope.topic).processedCount++;
|
|
1918
|
+
for (const inst of this.instrumentation) {
|
|
1919
|
+
inst.onMessage?.(envelope);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1517
1922
|
/**
|
|
1518
1923
|
* Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
|
|
1519
1924
|
* The handler itself is not cancelled — the warning is diagnostic only.
|
|
@@ -1603,6 +2008,11 @@ var KafkaClient = class {
|
|
|
1603
2008
|
* so Kafka can fence stale producers on restart without affecting other levels.
|
|
1604
2009
|
*/
|
|
1605
2010
|
async createRetryTxProducer(transactionalId) {
|
|
2011
|
+
if (_activeTransactionalIds.has(transactionalId)) {
|
|
2012
|
+
this.logger.warn(
|
|
2013
|
+
`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.`
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
1606
2016
|
const p = this.kafka.producer({
|
|
1607
2017
|
kafkaJS: {
|
|
1608
2018
|
acks: -1,
|
|
@@ -1612,6 +2022,7 @@ var KafkaClient = class {
|
|
|
1612
2022
|
}
|
|
1613
2023
|
});
|
|
1614
2024
|
await p.connect();
|
|
2025
|
+
_activeTransactionalIds.add(transactionalId);
|
|
1615
2026
|
this.retryTxProducers.set(transactionalId, p);
|
|
1616
2027
|
return p;
|
|
1617
2028
|
}
|
|
@@ -1732,7 +2143,11 @@ var KafkaClient = class {
|
|
|
1732
2143
|
logger: this.logger,
|
|
1733
2144
|
producer: this.producer,
|
|
1734
2145
|
instrumentation: this.instrumentation,
|
|
1735
|
-
onMessageLost: this.onMessageLost
|
|
2146
|
+
onMessageLost: this.onMessageLost,
|
|
2147
|
+
onRetry: this.notifyRetry.bind(this),
|
|
2148
|
+
onDlq: this.notifyDlq.bind(this),
|
|
2149
|
+
onDuplicate: this.notifyDuplicate.bind(this),
|
|
2150
|
+
onMessage: this.notifyMessage.bind(this)
|
|
1736
2151
|
};
|
|
1737
2152
|
}
|
|
1738
2153
|
get retryTopicDeps() {
|
|
@@ -1741,6 +2156,9 @@ var KafkaClient = class {
|
|
|
1741
2156
|
producer: this.producer,
|
|
1742
2157
|
instrumentation: this.instrumentation,
|
|
1743
2158
|
onMessageLost: this.onMessageLost,
|
|
2159
|
+
onRetry: this.notifyRetry.bind(this),
|
|
2160
|
+
onDlq: this.notifyDlq.bind(this),
|
|
2161
|
+
onMessage: this.notifyMessage.bind(this),
|
|
1744
2162
|
ensureTopic: (t) => this.ensureTopic(t),
|
|
1745
2163
|
getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
|
|
1746
2164
|
runningConsumers: this.runningConsumers,
|