@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
  };
@@ -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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taphealth/kafka",
3
- "version": "1.6.53",
3
+ "version": "2.0.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",