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