@taphealth/kafka 1.6.53 → 2.0.0
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.
|
@@ -12,6 +12,8 @@ class ConsumerStub {
|
|
|
12
12
|
this.connectCalls = 0;
|
|
13
13
|
this.subscribeCalls = 0;
|
|
14
14
|
this.disconnectCalls = 0;
|
|
15
|
+
this.commitCalls = [];
|
|
16
|
+
this.commitShouldThrow = false;
|
|
15
17
|
}
|
|
16
18
|
async connect() {
|
|
17
19
|
this.connectCalls += 1;
|
|
@@ -25,6 +27,12 @@ class ConsumerStub {
|
|
|
25
27
|
autoCommit: config.autoCommit,
|
|
26
28
|
};
|
|
27
29
|
}
|
|
30
|
+
async commitOffsets(offsets) {
|
|
31
|
+
if (this.commitShouldThrow) {
|
|
32
|
+
throw new Error("commit failed (rebalance)");
|
|
33
|
+
}
|
|
34
|
+
this.commitCalls.push(...offsets);
|
|
35
|
+
}
|
|
28
36
|
async disconnect() {
|
|
29
37
|
this.disconnectCalls += 1;
|
|
30
38
|
}
|
|
@@ -86,6 +94,9 @@ const run = async () => {
|
|
|
86
94
|
heartbeat: async () => undefined,
|
|
87
95
|
});
|
|
88
96
|
assert(consumer.received.length === 1, "valid message should reach handler");
|
|
97
|
+
assert(kafka.consumerInstance.commitCalls.length === 1 &&
|
|
98
|
+
kafka.consumerInstance.commitCalls[0].offset === "2" &&
|
|
99
|
+
kafka.consumerInstance.commitCalls[0].partition === 0, "autoCommit:false success path should manually commit next offset (message.offset + 1)");
|
|
89
100
|
await kafka.consumerInstance.eachMessage({
|
|
90
101
|
message: {
|
|
91
102
|
value: { toString: () => "not-json" },
|
|
@@ -95,7 +106,10 @@ const run = async () => {
|
|
|
95
106
|
heartbeat: async () => undefined,
|
|
96
107
|
});
|
|
97
108
|
assert(consumer.received.length === 1, "poison message should be skipped");
|
|
109
|
+
assert(kafka.consumerInstance.commitCalls.length === 2 &&
|
|
110
|
+
kafka.consumerInstance.commitCalls[1].offset === "3", "autoCommit:false poison path should manually commit to advance past skipped message");
|
|
98
111
|
consumer.shouldThrow = true;
|
|
112
|
+
const commitsBeforeThrow = kafka.consumerInstance.commitCalls.length;
|
|
99
113
|
let handlerThrew = false;
|
|
100
114
|
try {
|
|
101
115
|
await kafka.consumerInstance.eachMessage({
|
|
@@ -111,6 +125,7 @@ const run = async () => {
|
|
|
111
125
|
handlerThrew = true;
|
|
112
126
|
}
|
|
113
127
|
assert(handlerThrew, "handler errors should be rethrown");
|
|
128
|
+
assert(kafka.consumerInstance.commitCalls.length === commitsBeforeThrow, "autoCommit:false handler-error path with no DLQ should NOT commit (kafkajs will redeliver)");
|
|
114
129
|
consumer.shouldThrow = false;
|
|
115
130
|
consumer.shouldRejectAsync = true;
|
|
116
131
|
let asyncHandlerThrew = false;
|
|
@@ -128,6 +143,7 @@ const run = async () => {
|
|
|
128
143
|
asyncHandlerThrew = true;
|
|
129
144
|
}
|
|
130
145
|
assert(asyncHandlerThrew, "async handler rejections should be rethrown");
|
|
146
|
+
assert(kafka.consumerInstance.commitCalls.length === commitsBeforeThrow, "autoCommit:false async-rejection path with no DLQ should NOT commit");
|
|
131
147
|
consumer.shouldRejectAsync = false;
|
|
132
148
|
consumer.remainingFailures = 2;
|
|
133
149
|
const deadLetterCalls = [];
|
|
@@ -195,6 +211,73 @@ const run = async () => {
|
|
|
195
211
|
deadLetterHookThrowSurfaces = true;
|
|
196
212
|
}
|
|
197
213
|
assert(deadLetterHookThrowSurfaces, "consumer should rethrow when dead-letter hook itself throws");
|
|
214
|
+
// ─── autoCommit:false interaction with retry + DLQ + commit failures ──
|
|
215
|
+
const manualCommitConsumer = new TestConsumer(kafka);
|
|
216
|
+
// Reset commit log so subsequent assertions are independent.
|
|
217
|
+
kafka.consumerInstance.commitCalls = [];
|
|
218
|
+
manualCommitConsumer.remainingFailures = 2;
|
|
219
|
+
await manualCommitConsumer.consume({
|
|
220
|
+
autoCommit: false,
|
|
221
|
+
retry: { maxRetries: 3, backoffMs: () => 0 },
|
|
222
|
+
onDeadLetter: async () => undefined,
|
|
223
|
+
});
|
|
224
|
+
await kafka.consumerInstance.eachMessage({
|
|
225
|
+
message: { value: { toString: () => '{"id":"retry-then-commit"}' }, offset: "10" },
|
|
226
|
+
partition: 0,
|
|
227
|
+
heartbeat: async () => undefined,
|
|
228
|
+
});
|
|
229
|
+
assert(kafka.consumerInstance.commitCalls.length === 1 &&
|
|
230
|
+
kafka.consumerInstance.commitCalls[0].offset === "11", "autoCommit:false should commit ONCE after retry-then-success (one commit per message, not per attempt)");
|
|
231
|
+
// autoCommit:false + retry-exhaustion + DLQ-handled → must commit so the
|
|
232
|
+
// failed message does not get redelivered forever.
|
|
233
|
+
kafka.consumerInstance.commitCalls = [];
|
|
234
|
+
manualCommitConsumer.remainingFailures = 5;
|
|
235
|
+
let dlqHandled = false;
|
|
236
|
+
await manualCommitConsumer.consume({
|
|
237
|
+
autoCommit: false,
|
|
238
|
+
retry: { maxRetries: 1, backoffMs: () => 0 },
|
|
239
|
+
onDeadLetter: async () => {
|
|
240
|
+
dlqHandled = true;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
await kafka.consumerInstance.eachMessage({
|
|
244
|
+
message: { value: { toString: () => '{"id":"dlq-then-commit"}' }, offset: "20" },
|
|
245
|
+
partition: 0,
|
|
246
|
+
heartbeat: async () => undefined,
|
|
247
|
+
});
|
|
248
|
+
assert(dlqHandled, "DLQ hook should fire on retry exhaustion");
|
|
249
|
+
assert(kafka.consumerInstance.commitCalls.length === 1 &&
|
|
250
|
+
kafka.consumerInstance.commitCalls[0].offset === "21", "autoCommit:false should commit after a DLQ-handled message so it is not redelivered");
|
|
251
|
+
// commitOffsets failure (rebalance, broker down) MUST be swallowed on the
|
|
252
|
+
// happy path — at-least-once is preserved by kafkajs replay on the next
|
|
253
|
+
// session, but propagating would force redelivery + perma-failure loop.
|
|
254
|
+
kafka.consumerInstance.commitCalls = [];
|
|
255
|
+
kafka.consumerInstance.commitShouldThrow = true;
|
|
256
|
+
manualCommitConsumer.remainingFailures = 0; // ensure handler succeeds cleanly
|
|
257
|
+
let commitFailureSurfaced = false;
|
|
258
|
+
try {
|
|
259
|
+
await kafka.consumerInstance.eachMessage({
|
|
260
|
+
message: { value: { toString: () => '{"id":"commit-fails-but-handler-ok"}' }, offset: "30" },
|
|
261
|
+
partition: 0,
|
|
262
|
+
heartbeat: async () => undefined,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (_err) {
|
|
266
|
+
commitFailureSurfaced = true;
|
|
267
|
+
}
|
|
268
|
+
assert(!commitFailureSurfaced, "commitOffsets failure must NOT propagate out of eachMessage (would cause kafkajs to redeliver and never commit)");
|
|
269
|
+
kafka.consumerInstance.commitShouldThrow = false;
|
|
270
|
+
// autoCommit:true (or undefined) → kafkajs handles commits, package must NOT
|
|
271
|
+
// call commitOffsets manually (would double-commit).
|
|
272
|
+
kafka.consumerInstance.commitCalls = [];
|
|
273
|
+
const autoCommitConsumer = new TestConsumer(kafka);
|
|
274
|
+
await autoCommitConsumer.consume({ autoCommit: true });
|
|
275
|
+
await kafka.consumerInstance.eachMessage({
|
|
276
|
+
message: { value: { toString: () => '{"id":"auto-commit-no-manual"}' }, offset: "40" },
|
|
277
|
+
partition: 0,
|
|
278
|
+
heartbeat: async () => undefined,
|
|
279
|
+
});
|
|
280
|
+
assert(kafka.consumerInstance.commitCalls.length === 0, "autoCommit:true must NOT trigger manual commitOffsets (kafkajs owns commit cadence)");
|
|
198
281
|
await consumer.disconnect();
|
|
199
282
|
assert(kafka.consumerInstance.disconnectCalls === 1, "disconnect should use the running consumer instance");
|
|
200
283
|
};
|
package/dist/consumer.d.ts
CHANGED
|
@@ -32,10 +32,19 @@ export declare abstract class KafkaConsumer<T extends Event> {
|
|
|
32
32
|
* - Handler errors are rethrown so kafkajs retries message processing.
|
|
33
33
|
*/
|
|
34
34
|
private executeWithRetry;
|
|
35
|
+
/**
|
|
36
|
+
* Commit the offset for a single processed message. Used when callers opt
|
|
37
|
+
* into manual commits via `autoCommit: false`. Failures are logged and
|
|
38
|
+
* swallowed: at-least-once delivery is preserved because kafkajs will
|
|
39
|
+
* resume from the last successfully committed offset on the next session.
|
|
40
|
+
*/
|
|
41
|
+
private commitMessageOffset;
|
|
35
42
|
/**
|
|
36
43
|
* Error handling behavior:
|
|
37
44
|
* - Poison parse errors are skipped so offsets can move forward.
|
|
38
45
|
* - Handler errors are rethrown so kafkajs retries message processing.
|
|
46
|
+
* - With `autoCommit: false`, offsets are manually committed after a
|
|
47
|
+
* message is fully handled (success, poison-skip, or DLQ-handled).
|
|
39
48
|
*/
|
|
40
49
|
consume(options?: ConsumeOptions<T["data"]>): Promise<void>;
|
|
41
50
|
parseMessage(msg: Message): T["data"];
|
package/dist/consumer.js
CHANGED
|
@@ -53,13 +53,42 @@ class KafkaConsumer {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Commit the offset for a single processed message. Used when callers opt
|
|
58
|
+
* into manual commits via `autoCommit: false`. Failures are logged and
|
|
59
|
+
* swallowed: at-least-once delivery is preserved because kafkajs will
|
|
60
|
+
* resume from the last successfully committed offset on the next session.
|
|
61
|
+
*/
|
|
62
|
+
async commitMessageOffset(consumer, partition, offset) {
|
|
63
|
+
try {
|
|
64
|
+
const nextOffset = (BigInt(offset) + BigInt(1)).toString();
|
|
65
|
+
await consumer.commitOffsets([
|
|
66
|
+
{
|
|
67
|
+
topic: this.topic,
|
|
68
|
+
partition,
|
|
69
|
+
offset: nextOffset,
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
this.logger.error("Failed to commit kafka offset", {
|
|
75
|
+
topic: this.topic,
|
|
76
|
+
partition,
|
|
77
|
+
offset,
|
|
78
|
+
error: toError(err),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
56
82
|
/**
|
|
57
83
|
* Error handling behavior:
|
|
58
84
|
* - Poison parse errors are skipped so offsets can move forward.
|
|
59
85
|
* - Handler errors are rethrown so kafkajs retries message processing.
|
|
86
|
+
* - With `autoCommit: false`, offsets are manually committed after a
|
|
87
|
+
* message is fully handled (success, poison-skip, or DLQ-handled).
|
|
60
88
|
*/
|
|
61
89
|
async consume(options) {
|
|
62
90
|
const consumer = this.getConsumer();
|
|
91
|
+
const manualCommit = (options === null || options === void 0 ? void 0 : options.autoCommit) === false;
|
|
63
92
|
await consumer.connect();
|
|
64
93
|
await consumer.subscribe({ topics: [this.topic], fromBeginning: true });
|
|
65
94
|
const dlqEnabled = process.env.KAFKA_CONSUMER_DLQ_ENABLED === "true";
|
|
@@ -101,6 +130,9 @@ class KafkaConsumer {
|
|
|
101
130
|
offset: message.offset,
|
|
102
131
|
error,
|
|
103
132
|
});
|
|
133
|
+
if (manualCommit) {
|
|
134
|
+
await this.commitMessageOffset(consumer, partition, message.offset);
|
|
135
|
+
}
|
|
104
136
|
span.end();
|
|
105
137
|
return;
|
|
106
138
|
}
|
|
@@ -120,6 +152,9 @@ class KafkaConsumer {
|
|
|
120
152
|
if (options === null || options === void 0 ? void 0 : options.onDeadLetter) {
|
|
121
153
|
try {
|
|
122
154
|
await options.onDeadLetter(data, message, error);
|
|
155
|
+
if (manualCommit) {
|
|
156
|
+
await this.commitMessageOffset(consumer, partition, message.offset);
|
|
157
|
+
}
|
|
123
158
|
span.end();
|
|
124
159
|
return;
|
|
125
160
|
}
|
|
@@ -135,6 +170,9 @@ class KafkaConsumer {
|
|
|
135
170
|
span.end();
|
|
136
171
|
throw error;
|
|
137
172
|
}
|
|
173
|
+
if (manualCommit) {
|
|
174
|
+
await this.commitMessageOffset(consumer, partition, message.offset);
|
|
175
|
+
}
|
|
138
176
|
span.end();
|
|
139
177
|
});
|
|
140
178
|
},
|