@drarzter/kafka-client 0.2.1 → 0.2.2
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/dist/index.d.mts +27 -5
- package/dist/index.d.ts +27 -5
- package/dist/index.js +235 -292
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +235 -292
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -86,6 +86,10 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
// src/client/kafka.client.ts
|
|
89
|
+
var ACKS_ALL = -1;
|
|
90
|
+
function toError(error) {
|
|
91
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
92
|
+
}
|
|
89
93
|
var KafkaClient = class {
|
|
90
94
|
kafka;
|
|
91
95
|
producer;
|
|
@@ -118,71 +122,17 @@ var KafkaClient = class {
|
|
|
118
122
|
});
|
|
119
123
|
this.admin = this.kafka.admin();
|
|
120
124
|
}
|
|
121
|
-
getOrCreateConsumer(groupId) {
|
|
122
|
-
const gid = groupId || this.defaultGroupId;
|
|
123
|
-
if (!this.consumers.has(gid)) {
|
|
124
|
-
this.consumers.set(gid, this.kafka.consumer({ groupId: gid }));
|
|
125
|
-
}
|
|
126
|
-
return this.consumers.get(gid);
|
|
127
|
-
}
|
|
128
|
-
resolveTopicName(topicOrDescriptor) {
|
|
129
|
-
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
130
|
-
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
131
|
-
return topicOrDescriptor.__topic;
|
|
132
|
-
}
|
|
133
|
-
return String(topicOrDescriptor);
|
|
134
|
-
}
|
|
135
|
-
async ensureTopic(topic2) {
|
|
136
|
-
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
137
|
-
if (!this.isAdminConnected) {
|
|
138
|
-
await this.admin.connect();
|
|
139
|
-
this.isAdminConnected = true;
|
|
140
|
-
}
|
|
141
|
-
await this.admin.createTopics({
|
|
142
|
-
topics: [{ topic: topic2, numPartitions: 1 }]
|
|
143
|
-
});
|
|
144
|
-
this.ensuredTopics.add(topic2);
|
|
145
|
-
}
|
|
146
|
-
validateMessage(topicOrDesc, message) {
|
|
147
|
-
if (topicOrDesc?.__schema) {
|
|
148
|
-
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
149
|
-
this.schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
150
|
-
return topicOrDesc.__schema.parse(message);
|
|
151
|
-
}
|
|
152
|
-
if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
153
|
-
const schema = this.schemaRegistry.get(topicOrDesc);
|
|
154
|
-
if (schema) return schema.parse(message);
|
|
155
|
-
}
|
|
156
|
-
return message;
|
|
157
|
-
}
|
|
158
125
|
async sendMessage(topicOrDesc, message, options = {}) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
await this.
|
|
163
|
-
|
|
164
|
-
messages: [
|
|
165
|
-
{
|
|
166
|
-
value: JSON.stringify(validated),
|
|
167
|
-
key: options.key ?? null,
|
|
168
|
-
headers: options.headers
|
|
169
|
-
}
|
|
170
|
-
],
|
|
171
|
-
acks: -1
|
|
172
|
-
});
|
|
126
|
+
const payload = this.buildSendPayload(topicOrDesc, [
|
|
127
|
+
{ value: message, key: options.key, headers: options.headers }
|
|
128
|
+
]);
|
|
129
|
+
await this.ensureTopic(payload.topic);
|
|
130
|
+
await this.producer.send(payload);
|
|
173
131
|
}
|
|
174
132
|
async sendBatch(topicOrDesc, messages) {
|
|
175
|
-
const
|
|
176
|
-
await this.ensureTopic(
|
|
177
|
-
await this.producer.send(
|
|
178
|
-
topic: topic2,
|
|
179
|
-
messages: messages.map((m) => ({
|
|
180
|
-
value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
|
|
181
|
-
key: m.key ?? null,
|
|
182
|
-
headers: m.headers
|
|
183
|
-
})),
|
|
184
|
-
acks: -1
|
|
185
|
-
});
|
|
133
|
+
const payload = this.buildSendPayload(topicOrDesc, messages);
|
|
134
|
+
await this.ensureTopic(payload.topic);
|
|
135
|
+
await this.producer.send(payload);
|
|
186
136
|
}
|
|
187
137
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
188
138
|
async transaction(fn) {
|
|
@@ -190,33 +140,16 @@ var KafkaClient = class {
|
|
|
190
140
|
try {
|
|
191
141
|
const ctx = {
|
|
192
142
|
send: async (topicOrDesc, message, options = {}) => {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
await
|
|
197
|
-
|
|
198
|
-
messages: [
|
|
199
|
-
{
|
|
200
|
-
value: JSON.stringify(validated),
|
|
201
|
-
key: options.key ?? null,
|
|
202
|
-
headers: options.headers
|
|
203
|
-
}
|
|
204
|
-
],
|
|
205
|
-
acks: -1
|
|
206
|
-
});
|
|
143
|
+
const payload = this.buildSendPayload(topicOrDesc, [
|
|
144
|
+
{ value: message, key: options.key, headers: options.headers }
|
|
145
|
+
]);
|
|
146
|
+
await this.ensureTopic(payload.topic);
|
|
147
|
+
await tx.send(payload);
|
|
207
148
|
},
|
|
208
149
|
sendBatch: async (topicOrDesc, messages) => {
|
|
209
|
-
const
|
|
210
|
-
await this.ensureTopic(
|
|
211
|
-
await tx.send(
|
|
212
|
-
topic: topic2,
|
|
213
|
-
messages: messages.map((m) => ({
|
|
214
|
-
value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
|
|
215
|
-
key: m.key ?? null,
|
|
216
|
-
headers: m.headers
|
|
217
|
-
})),
|
|
218
|
-
acks: -1
|
|
219
|
-
});
|
|
150
|
+
const payload = this.buildSendPayload(topicOrDesc, messages);
|
|
151
|
+
await this.ensureTopic(payload.topic);
|
|
152
|
+
await tx.send(payload);
|
|
220
153
|
}
|
|
221
154
|
};
|
|
222
155
|
await fn(ctx);
|
|
@@ -226,6 +159,7 @@ var KafkaClient = class {
|
|
|
226
159
|
throw error;
|
|
227
160
|
}
|
|
228
161
|
}
|
|
162
|
+
// ── Producer lifecycle ───────────────────────────────────────────
|
|
229
163
|
/** Connect the idempotent producer. Called automatically by `KafkaModule.register()`. */
|
|
230
164
|
async connectProducer() {
|
|
231
165
|
await this.producer.connect();
|
|
@@ -236,112 +170,38 @@ var KafkaClient = class {
|
|
|
236
170
|
this.logger.log("Producer disconnected");
|
|
237
171
|
}
|
|
238
172
|
async startConsumer(topics, handleMessage, options = {}) {
|
|
239
|
-
const {
|
|
240
|
-
groupId: optGroupId,
|
|
241
|
-
fromBeginning = false,
|
|
242
|
-
autoCommit = true,
|
|
243
|
-
retry,
|
|
244
|
-
dlq = false,
|
|
245
|
-
interceptors = [],
|
|
246
|
-
schemas: optionSchemas
|
|
247
|
-
} = options;
|
|
248
|
-
const gid = optGroupId || this.defaultGroupId;
|
|
249
|
-
const existingMode = this.runningConsumers.get(gid);
|
|
250
|
-
if (existingMode === "eachBatch") {
|
|
251
|
-
throw new Error(
|
|
252
|
-
`Cannot use eachMessage on consumer group "${gid}" \u2014 it is already running with eachBatch. Use a different groupId for this consumer.`
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
const consumer = this.getOrCreateConsumer(optGroupId);
|
|
256
|
-
const schemaMap = this.buildSchemaMap(topics, optionSchemas);
|
|
257
|
-
const topicNames = topics.map(
|
|
258
|
-
(t) => this.resolveTopicName(t)
|
|
259
|
-
);
|
|
260
|
-
await consumer.connect();
|
|
261
|
-
await this.subscribeWithRetry(consumer, topicNames, fromBeginning, options.subscribeRetry);
|
|
262
|
-
this.logger.log(`Consumer subscribed to topics: ${topicNames.join(", ")}`);
|
|
173
|
+
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
|
|
263
174
|
await consumer.run({
|
|
264
|
-
autoCommit,
|
|
175
|
+
autoCommit: options.autoCommit ?? true,
|
|
265
176
|
eachMessage: async ({ topic: topic2, message }) => {
|
|
266
177
|
if (!message.value) {
|
|
267
178
|
this.logger.warn(`Received empty message from topic ${topic2}`);
|
|
268
179
|
return;
|
|
269
180
|
}
|
|
270
181
|
const raw = message.value.toString();
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
287
|
-
const validationError = new KafkaValidationError(
|
|
288
|
-
topic2,
|
|
289
|
-
parsedMessage,
|
|
290
|
-
{ cause: err }
|
|
291
|
-
);
|
|
292
|
-
this.logger.error(
|
|
293
|
-
`Schema validation failed for topic ${topic2}:`,
|
|
294
|
-
err.message
|
|
295
|
-
);
|
|
296
|
-
if (dlq) await this.sendToDlq(topic2, raw);
|
|
297
|
-
for (const interceptor of interceptors) {
|
|
298
|
-
await interceptor.onError?.(
|
|
299
|
-
parsedMessage,
|
|
300
|
-
topic2,
|
|
301
|
-
validationError
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
await this.processMessage(parsedMessage, raw, topic2, handleMessage, {
|
|
308
|
-
retry,
|
|
309
|
-
dlq,
|
|
310
|
-
interceptors
|
|
311
|
-
});
|
|
182
|
+
const parsed = this.parseJsonMessage(raw, topic2);
|
|
183
|
+
if (parsed === null) return;
|
|
184
|
+
const validated = await this.validateWithSchema(
|
|
185
|
+
parsed,
|
|
186
|
+
raw,
|
|
187
|
+
topic2,
|
|
188
|
+
schemaMap,
|
|
189
|
+
interceptors,
|
|
190
|
+
dlq
|
|
191
|
+
);
|
|
192
|
+
if (validated === null) return;
|
|
193
|
+
await this.executeWithRetry(
|
|
194
|
+
() => handleMessage(validated, topic2),
|
|
195
|
+
{ topic: topic2, messages: validated, rawMessages: [raw], interceptors, dlq, retry }
|
|
196
|
+
);
|
|
312
197
|
}
|
|
313
198
|
});
|
|
314
199
|
this.runningConsumers.set(gid, "eachMessage");
|
|
315
200
|
}
|
|
316
201
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
317
|
-
const {
|
|
318
|
-
groupId: optGroupId,
|
|
319
|
-
fromBeginning = false,
|
|
320
|
-
autoCommit = true,
|
|
321
|
-
retry,
|
|
322
|
-
dlq = false,
|
|
323
|
-
interceptors = [],
|
|
324
|
-
schemas: optionSchemas
|
|
325
|
-
} = options;
|
|
326
|
-
const gid = optGroupId || this.defaultGroupId;
|
|
327
|
-
const existingMode = this.runningConsumers.get(gid);
|
|
328
|
-
if (existingMode === "eachMessage") {
|
|
329
|
-
throw new Error(
|
|
330
|
-
`Cannot use eachBatch on consumer group "${gid}" \u2014 it is already running with eachMessage. Use a different groupId for this consumer.`
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
const consumer = this.getOrCreateConsumer(optGroupId);
|
|
334
|
-
const schemaMap = this.buildSchemaMap(topics, optionSchemas);
|
|
335
|
-
const topicNames = topics.map(
|
|
336
|
-
(t) => this.resolveTopicName(t)
|
|
337
|
-
);
|
|
338
|
-
await consumer.connect();
|
|
339
|
-
await this.subscribeWithRetry(consumer, topicNames, fromBeginning, options.subscribeRetry);
|
|
340
|
-
this.logger.log(
|
|
341
|
-
`Batch consumer subscribed to topics: ${topicNames.join(", ")}`
|
|
342
|
-
);
|
|
202
|
+
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
|
|
343
203
|
await consumer.run({
|
|
344
|
-
autoCommit,
|
|
204
|
+
autoCommit: options.autoCommit ?? true,
|
|
345
205
|
eachBatch: async ({
|
|
346
206
|
batch,
|
|
347
207
|
heartbeat,
|
|
@@ -349,6 +209,7 @@ var KafkaClient = class {
|
|
|
349
209
|
commitOffsetsIfNecessary
|
|
350
210
|
}) => {
|
|
351
211
|
const validMessages = [];
|
|
212
|
+
const rawMessages = [];
|
|
352
213
|
for (const message of batch.messages) {
|
|
353
214
|
if (!message.value) {
|
|
354
215
|
this.logger.warn(
|
|
@@ -357,43 +218,19 @@ var KafkaClient = class {
|
|
|
357
218
|
continue;
|
|
358
219
|
}
|
|
359
220
|
const raw = message.value.toString();
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
parsedMessage = schema.parse(parsedMessage);
|
|
374
|
-
} catch (error) {
|
|
375
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
376
|
-
const validationError = new KafkaValidationError(
|
|
377
|
-
batch.topic,
|
|
378
|
-
parsedMessage,
|
|
379
|
-
{ cause: err }
|
|
380
|
-
);
|
|
381
|
-
this.logger.error(
|
|
382
|
-
`Schema validation failed for topic ${batch.topic}:`,
|
|
383
|
-
err.message
|
|
384
|
-
);
|
|
385
|
-
if (dlq) await this.sendToDlq(batch.topic, raw);
|
|
386
|
-
for (const interceptor of interceptors) {
|
|
387
|
-
await interceptor.onError?.(
|
|
388
|
-
parsedMessage,
|
|
389
|
-
batch.topic,
|
|
390
|
-
validationError
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
validMessages.push(parsedMessage);
|
|
221
|
+
const parsed = this.parseJsonMessage(raw, batch.topic);
|
|
222
|
+
if (parsed === null) continue;
|
|
223
|
+
const validated = await this.validateWithSchema(
|
|
224
|
+
parsed,
|
|
225
|
+
raw,
|
|
226
|
+
batch.topic,
|
|
227
|
+
schemaMap,
|
|
228
|
+
interceptors,
|
|
229
|
+
dlq
|
|
230
|
+
);
|
|
231
|
+
if (validated === null) continue;
|
|
232
|
+
validMessages.push(validated);
|
|
233
|
+
rawMessages.push(raw);
|
|
397
234
|
}
|
|
398
235
|
if (validMessages.length === 0) return;
|
|
399
236
|
const meta = {
|
|
@@ -403,69 +240,23 @@ var KafkaClient = class {
|
|
|
403
240
|
resolveOffset,
|
|
404
241
|
commitOffsetsIfNecessary
|
|
405
242
|
};
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
for (const interceptor of interceptors) {
|
|
417
|
-
for (const msg of validMessages) {
|
|
418
|
-
await interceptor.after?.(msg, batch.topic);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return;
|
|
422
|
-
} catch (error) {
|
|
423
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
424
|
-
const isLastAttempt = attempt === maxAttempts;
|
|
425
|
-
if (isLastAttempt && maxAttempts > 1) {
|
|
426
|
-
const exhaustedError = new KafkaRetryExhaustedError(
|
|
427
|
-
batch.topic,
|
|
428
|
-
validMessages,
|
|
429
|
-
maxAttempts,
|
|
430
|
-
{ cause: err }
|
|
431
|
-
);
|
|
432
|
-
for (const interceptor of interceptors) {
|
|
433
|
-
await interceptor.onError?.(
|
|
434
|
-
validMessages,
|
|
435
|
-
batch.topic,
|
|
436
|
-
exhaustedError
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
} else {
|
|
440
|
-
for (const interceptor of interceptors) {
|
|
441
|
-
await interceptor.onError?.(
|
|
442
|
-
validMessages,
|
|
443
|
-
batch.topic,
|
|
444
|
-
err
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
this.logger.error(
|
|
449
|
-
`Error processing batch from topic ${batch.topic} (attempt ${attempt}/${maxAttempts}):`,
|
|
450
|
-
err.stack
|
|
451
|
-
);
|
|
452
|
-
if (isLastAttempt) {
|
|
453
|
-
if (dlq) {
|
|
454
|
-
for (const msg of batch.messages) {
|
|
455
|
-
if (msg.value) {
|
|
456
|
-
await this.sendToDlq(batch.topic, msg.value.toString());
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
} else {
|
|
461
|
-
await this.sleep(backoffMs * attempt);
|
|
462
|
-
}
|
|
243
|
+
await this.executeWithRetry(
|
|
244
|
+
() => handleBatch(validMessages, batch.topic, meta),
|
|
245
|
+
{
|
|
246
|
+
topic: batch.topic,
|
|
247
|
+
messages: validMessages,
|
|
248
|
+
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
249
|
+
interceptors,
|
|
250
|
+
dlq,
|
|
251
|
+
retry,
|
|
252
|
+
isBatch: true
|
|
463
253
|
}
|
|
464
|
-
|
|
254
|
+
);
|
|
465
255
|
}
|
|
466
256
|
});
|
|
467
257
|
this.runningConsumers.set(gid, "eachBatch");
|
|
468
258
|
}
|
|
259
|
+
// ── Consumer lifecycle ───────────────────────────────────────────
|
|
469
260
|
async stopConsumer() {
|
|
470
261
|
const tasks = [];
|
|
471
262
|
for (const consumer of this.consumers.values()) {
|
|
@@ -503,7 +294,97 @@ var KafkaClient = class {
|
|
|
503
294
|
this.runningConsumers.clear();
|
|
504
295
|
this.logger.log("All connections closed");
|
|
505
296
|
}
|
|
506
|
-
//
|
|
297
|
+
// ── Private helpers ──────────────────────────────────────────────
|
|
298
|
+
getOrCreateConsumer(groupId) {
|
|
299
|
+
const gid = groupId || this.defaultGroupId;
|
|
300
|
+
if (!this.consumers.has(gid)) {
|
|
301
|
+
this.consumers.set(gid, this.kafka.consumer({ groupId: gid }));
|
|
302
|
+
}
|
|
303
|
+
return this.consumers.get(gid);
|
|
304
|
+
}
|
|
305
|
+
resolveTopicName(topicOrDescriptor) {
|
|
306
|
+
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
307
|
+
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
308
|
+
return topicOrDescriptor.__topic;
|
|
309
|
+
}
|
|
310
|
+
return String(topicOrDescriptor);
|
|
311
|
+
}
|
|
312
|
+
async ensureTopic(topic2) {
|
|
313
|
+
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
314
|
+
if (!this.isAdminConnected) {
|
|
315
|
+
await this.admin.connect();
|
|
316
|
+
this.isAdminConnected = true;
|
|
317
|
+
}
|
|
318
|
+
await this.admin.createTopics({
|
|
319
|
+
topics: [{ topic: topic2, numPartitions: 1 }]
|
|
320
|
+
});
|
|
321
|
+
this.ensuredTopics.add(topic2);
|
|
322
|
+
}
|
|
323
|
+
/** Register schema from descriptor into global registry (side-effect). */
|
|
324
|
+
registerSchema(topicOrDesc) {
|
|
325
|
+
if (topicOrDesc?.__schema) {
|
|
326
|
+
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
327
|
+
this.schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Validate message against schema. Pure — no side-effects on registry. */
|
|
331
|
+
validateMessage(topicOrDesc, message) {
|
|
332
|
+
if (topicOrDesc?.__schema) {
|
|
333
|
+
return topicOrDesc.__schema.parse(message);
|
|
334
|
+
}
|
|
335
|
+
if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
336
|
+
const schema = this.schemaRegistry.get(topicOrDesc);
|
|
337
|
+
if (schema) return schema.parse(message);
|
|
338
|
+
}
|
|
339
|
+
return message;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Build a kafkajs-ready send payload.
|
|
343
|
+
* Handles: topic resolution, schema registration, validation, JSON serialization.
|
|
344
|
+
*/
|
|
345
|
+
buildSendPayload(topicOrDesc, messages) {
|
|
346
|
+
this.registerSchema(topicOrDesc);
|
|
347
|
+
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
348
|
+
return {
|
|
349
|
+
topic: topic2,
|
|
350
|
+
messages: messages.map((m) => ({
|
|
351
|
+
value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
|
|
352
|
+
key: m.key ?? null,
|
|
353
|
+
headers: m.headers
|
|
354
|
+
})),
|
|
355
|
+
acks: ACKS_ALL
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/** Shared consumer setup: groupId check, schema map, connect, subscribe. */
|
|
359
|
+
async setupConsumer(topics, mode, options) {
|
|
360
|
+
const {
|
|
361
|
+
groupId: optGroupId,
|
|
362
|
+
fromBeginning = false,
|
|
363
|
+
retry,
|
|
364
|
+
dlq = false,
|
|
365
|
+
interceptors = [],
|
|
366
|
+
schemas: optionSchemas
|
|
367
|
+
} = options;
|
|
368
|
+
const gid = optGroupId || this.defaultGroupId;
|
|
369
|
+
const existingMode = this.runningConsumers.get(gid);
|
|
370
|
+
const oppositeMode = mode === "eachMessage" ? "eachBatch" : "eachMessage";
|
|
371
|
+
if (existingMode === oppositeMode) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const consumer = this.getOrCreateConsumer(optGroupId);
|
|
377
|
+
const schemaMap = this.buildSchemaMap(topics, optionSchemas);
|
|
378
|
+
const topicNames = topics.map(
|
|
379
|
+
(t) => this.resolveTopicName(t)
|
|
380
|
+
);
|
|
381
|
+
await consumer.connect();
|
|
382
|
+
await this.subscribeWithRetry(consumer, topicNames, fromBeginning, options.subscribeRetry);
|
|
383
|
+
this.logger.log(
|
|
384
|
+
`${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
|
|
385
|
+
);
|
|
386
|
+
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
|
|
387
|
+
}
|
|
507
388
|
buildSchemaMap(topics, optionSchemas) {
|
|
508
389
|
const schemaMap = /* @__PURE__ */ new Map();
|
|
509
390
|
for (const t of topics) {
|
|
@@ -521,44 +402,106 @@ var KafkaClient = class {
|
|
|
521
402
|
}
|
|
522
403
|
return schemaMap;
|
|
523
404
|
}
|
|
524
|
-
|
|
525
|
-
|
|
405
|
+
/** Parse raw message as JSON. Returns null on failure (logs error). */
|
|
406
|
+
parseJsonMessage(raw, topic2) {
|
|
407
|
+
try {
|
|
408
|
+
return JSON.parse(raw);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.logger.error(
|
|
411
|
+
`Failed to parse message from topic ${topic2}:`,
|
|
412
|
+
toError(error).stack
|
|
413
|
+
);
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Validate a parsed message against the schema map.
|
|
419
|
+
* On failure: logs error, sends to DLQ if enabled, calls interceptor.onError.
|
|
420
|
+
* Returns validated message or null.
|
|
421
|
+
*/
|
|
422
|
+
async validateWithSchema(message, raw, topic2, schemaMap, interceptors, dlq) {
|
|
423
|
+
const schema = schemaMap.get(topic2);
|
|
424
|
+
if (!schema) return message;
|
|
425
|
+
try {
|
|
426
|
+
return schema.parse(message);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const err = toError(error);
|
|
429
|
+
const validationError = new KafkaValidationError(topic2, message, {
|
|
430
|
+
cause: err
|
|
431
|
+
});
|
|
432
|
+
this.logger.error(
|
|
433
|
+
`Schema validation failed for topic ${topic2}:`,
|
|
434
|
+
err.message
|
|
435
|
+
);
|
|
436
|
+
if (dlq) await this.sendToDlq(topic2, raw);
|
|
437
|
+
for (const interceptor of interceptors) {
|
|
438
|
+
await interceptor.onError?.(message, topic2, validationError);
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Execute a handler with retry, interceptors, and DLQ support.
|
|
445
|
+
* Used by both single-message and batch consumers.
|
|
446
|
+
*/
|
|
447
|
+
async executeWithRetry(fn, ctx) {
|
|
448
|
+
const { topic: topic2, messages, rawMessages, interceptors, dlq, retry, isBatch } = ctx;
|
|
526
449
|
const maxAttempts = retry ? retry.maxRetries + 1 : 1;
|
|
527
450
|
const backoffMs = retry?.backoffMs ?? 1e3;
|
|
528
451
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
529
452
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
453
|
+
if (isBatch) {
|
|
454
|
+
for (const interceptor of interceptors) {
|
|
455
|
+
for (const msg of messages) {
|
|
456
|
+
await interceptor.before?.(msg, topic2);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
for (const interceptor of interceptors) {
|
|
461
|
+
await interceptor.before?.(messages, topic2);
|
|
462
|
+
}
|
|
532
463
|
}
|
|
533
|
-
await
|
|
534
|
-
|
|
535
|
-
|
|
464
|
+
await fn();
|
|
465
|
+
if (isBatch) {
|
|
466
|
+
for (const interceptor of interceptors) {
|
|
467
|
+
for (const msg of messages) {
|
|
468
|
+
await interceptor.after?.(msg, topic2);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
for (const interceptor of interceptors) {
|
|
473
|
+
await interceptor.after?.(messages, topic2);
|
|
474
|
+
}
|
|
536
475
|
}
|
|
537
476
|
return;
|
|
538
477
|
} catch (error) {
|
|
539
|
-
const err =
|
|
478
|
+
const err = toError(error);
|
|
540
479
|
const isLastAttempt = attempt === maxAttempts;
|
|
541
480
|
if (isLastAttempt && maxAttempts > 1) {
|
|
542
481
|
const exhaustedError = new KafkaRetryExhaustedError(
|
|
543
482
|
topic2,
|
|
544
|
-
|
|
483
|
+
messages,
|
|
545
484
|
maxAttempts,
|
|
546
485
|
{ cause: err }
|
|
547
486
|
);
|
|
548
487
|
for (const interceptor of interceptors) {
|
|
549
|
-
await interceptor.onError?.(
|
|
488
|
+
await interceptor.onError?.(messages, topic2, exhaustedError);
|
|
550
489
|
}
|
|
551
490
|
} else {
|
|
552
491
|
for (const interceptor of interceptors) {
|
|
553
|
-
await interceptor.onError?.(
|
|
492
|
+
await interceptor.onError?.(messages, topic2, err);
|
|
554
493
|
}
|
|
555
494
|
}
|
|
556
495
|
this.logger.error(
|
|
557
|
-
`Error processing message from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
496
|
+
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
558
497
|
err.stack
|
|
559
498
|
);
|
|
560
499
|
if (isLastAttempt) {
|
|
561
|
-
if (dlq)
|
|
500
|
+
if (dlq) {
|
|
501
|
+
for (const raw of rawMessages) {
|
|
502
|
+
await this.sendToDlq(topic2, raw);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
562
505
|
} else {
|
|
563
506
|
await this.sleep(backoffMs * attempt);
|
|
564
507
|
}
|
|
@@ -571,13 +514,13 @@ var KafkaClient = class {
|
|
|
571
514
|
await this.producer.send({
|
|
572
515
|
topic: dlqTopic,
|
|
573
516
|
messages: [{ value: rawMessage }],
|
|
574
|
-
acks:
|
|
517
|
+
acks: ACKS_ALL
|
|
575
518
|
});
|
|
576
519
|
this.logger.warn(`Message sent to DLQ: ${dlqTopic}`);
|
|
577
520
|
} catch (error) {
|
|
578
521
|
this.logger.error(
|
|
579
522
|
`Failed to send message to DLQ ${dlqTopic}:`,
|
|
580
|
-
error
|
|
523
|
+
toError(error).stack
|
|
581
524
|
);
|
|
582
525
|
}
|
|
583
526
|
}
|
|
@@ -590,7 +533,7 @@ var KafkaClient = class {
|
|
|
590
533
|
return;
|
|
591
534
|
} catch (error) {
|
|
592
535
|
if (attempt === maxAttempts) throw error;
|
|
593
|
-
const msg = error
|
|
536
|
+
const msg = toError(error).message;
|
|
594
537
|
this.logger.warn(
|
|
595
538
|
`Failed to subscribe to [${topics.join(", ")}] (attempt ${attempt}/${maxAttempts}): ${msg}. Retrying in ${backoffMs}ms...`
|
|
596
539
|
);
|