@lucaapp/service-utils 5.41.0 → 5.42.1
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.
|
@@ -48,6 +48,8 @@ declare class KafkaClient {
|
|
|
48
48
|
*/
|
|
49
49
|
consumeCustom: <T = unknown>(topicName: string, handler: GenericEventPayloadHandler<T>, fromBeginning?: boolean, onPoison?: boolean | CustomTopicPoisonHandler) => Promise<Consumer>;
|
|
50
50
|
private runCustomConsumer;
|
|
51
|
+
private processBatchByKey;
|
|
52
|
+
private processCustomMessage;
|
|
51
53
|
private defaultPoisonHandler;
|
|
52
54
|
private encryptCustomValue;
|
|
53
55
|
private decryptCustomValue;
|
|
@@ -45,6 +45,8 @@ const utils_1 = require("../../utils/utils");
|
|
|
45
45
|
const topicLifecycle_1 = require("./topicLifecycle");
|
|
46
46
|
const metrics_1 = require("./metrics");
|
|
47
47
|
const KEY_ALG = 'ES256';
|
|
48
|
+
// kafkajs commitOffsetsIfNecessary is a no-op without an interval or threshold.
|
|
49
|
+
const BATCH_AUTO_COMMIT_INTERVAL_MS = 5_000;
|
|
48
50
|
const getIssuer = (kafkaTopic) => {
|
|
49
51
|
return events_1.MessageIssuer[kafkaTopic].valueOf();
|
|
50
52
|
};
|
|
@@ -278,6 +280,11 @@ class KafkaClient {
|
|
|
278
280
|
* This allows services to define their own topics without modifying service-utils
|
|
279
281
|
*/
|
|
280
282
|
this.registerCustomTopic = (config) => {
|
|
283
|
+
const { keyConcurrency } = config;
|
|
284
|
+
if (keyConcurrency !== undefined &&
|
|
285
|
+
!(Number.isInteger(keyConcurrency) && keyConcurrency >= 1)) {
|
|
286
|
+
throw (0, utils_1.logAndGetError)(this.logger, `Invalid keyConcurrency=${keyConcurrency} for custom topic=${config.topic}. Expected an integer >= 1.`);
|
|
287
|
+
}
|
|
281
288
|
this.customTopics.set(config.topic, config);
|
|
282
289
|
this.logger.info({ topic: config.topic, issuer: config.issuer }, 'Registered custom Kafka topic');
|
|
283
290
|
};
|
|
@@ -362,60 +369,127 @@ class KafkaClient {
|
|
|
362
369
|
};
|
|
363
370
|
this.runCustomConsumer = async (consumer, topic, fromBeginning, topicName, handler, groupId, poisonHandler) => {
|
|
364
371
|
await consumer.subscribe({ topic, fromBeginning });
|
|
372
|
+
const processMessage = (message, partition) => this.processCustomMessage(message, partition, topic, topicName, handler, groupId, poisonHandler);
|
|
373
|
+
const keyConcurrency = this.customTopics.get(topicName)?.keyConcurrency;
|
|
374
|
+
if (!keyConcurrency) {
|
|
375
|
+
await consumer.run({
|
|
376
|
+
autoCommit: true,
|
|
377
|
+
eachMessage: ({ message, partition }) => processMessage(message, partition),
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
365
381
|
await consumer.run({
|
|
366
382
|
autoCommit: true,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
383
|
+
autoCommitInterval: BATCH_AUTO_COMMIT_INTERVAL_MS,
|
|
384
|
+
eachBatchAutoResolve: false,
|
|
385
|
+
eachBatch: payload => this.processBatchByKey(payload, keyConcurrency, processMessage),
|
|
386
|
+
});
|
|
387
|
+
};
|
|
388
|
+
// Same key runs in offset order, different keys run in parallel; only the contiguous done prefix gets resolved.
|
|
389
|
+
this.processBatchByKey = async ({ batch, resolveOffset, heartbeat, isRunning, isStale, commitOffsetsIfNecessary, }, keyConcurrency, processMessage) => {
|
|
390
|
+
const { messages, partition } = batch;
|
|
391
|
+
const groups = new Map();
|
|
392
|
+
messages.forEach((message, index) => {
|
|
393
|
+
const key = message.key?.length ? message.key.toString() : null;
|
|
394
|
+
const group = groups.get(key);
|
|
395
|
+
if (group) {
|
|
396
|
+
group.push(index);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
groups.set(key, [index]);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
const queue = [...groups.values()];
|
|
403
|
+
const done = new Array(messages.length).fill(false);
|
|
404
|
+
let watermark = 0;
|
|
405
|
+
let failed = false;
|
|
406
|
+
let firstError;
|
|
407
|
+
const canContinue = () => !failed && isRunning() && !isStale();
|
|
408
|
+
const advance = async () => {
|
|
409
|
+
const before = watermark;
|
|
410
|
+
while (watermark < messages.length && done[watermark]) {
|
|
411
|
+
watermark += 1;
|
|
412
|
+
}
|
|
413
|
+
if (watermark > before && !isStale()) {
|
|
414
|
+
resolveOffset(messages[watermark - 1].offset);
|
|
415
|
+
await commitOffsetsIfNecessary();
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
const worker = async () => {
|
|
419
|
+
while (canContinue()) {
|
|
420
|
+
const group = queue.shift();
|
|
421
|
+
if (!group) {
|
|
372
422
|
return;
|
|
373
423
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// Decrypt if secret was provided
|
|
377
|
-
const config = this.customTopics.get(topicName);
|
|
378
|
-
const decryptedValue = config.secret && this.encryptionEnabled
|
|
379
|
-
? await this.decryptCustomValue(config.secret, message.value)
|
|
380
|
-
: message.value;
|
|
381
|
-
const value = this.parseCustomValue(decryptedValue);
|
|
382
|
-
this.logger.debug({
|
|
383
|
-
key: message.key?.toString(),
|
|
384
|
-
value,
|
|
385
|
-
timestamp: message.timestamp,
|
|
386
|
-
}, 'Custom topic record received');
|
|
387
|
-
await handler({ ...message, value });
|
|
388
|
-
metrics_1.messageAcknowledgedCounter
|
|
389
|
-
.labels({ topic: topicName, groupId })
|
|
390
|
-
.inc();
|
|
391
|
-
}
|
|
392
|
-
catch (error) {
|
|
393
|
-
if (poisonHandler) {
|
|
394
|
-
this.logger.warn(error, 'Poison message detected, forwarding to DLQ');
|
|
395
|
-
await poisonHandler({
|
|
396
|
-
error,
|
|
397
|
-
decryptFailed: false,
|
|
398
|
-
message,
|
|
399
|
-
partition,
|
|
400
|
-
primaryTopicFullName: topic,
|
|
401
|
-
rawPayloadUtf8: message.value
|
|
402
|
-
? Buffer.from(message.value).toString('utf8')
|
|
403
|
-
: '',
|
|
404
|
-
});
|
|
405
|
-
metrics_1.messageAcknowledgedCounter
|
|
406
|
-
.labels({ topic: topicName, groupId })
|
|
407
|
-
.inc();
|
|
424
|
+
for (const index of group) {
|
|
425
|
+
if (!canContinue()) {
|
|
408
426
|
return;
|
|
409
427
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
428
|
+
try {
|
|
429
|
+
await processMessage(messages[index], partition);
|
|
430
|
+
done[index] = true;
|
|
431
|
+
await advance();
|
|
432
|
+
await heartbeat();
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (!failed) {
|
|
436
|
+
failed = true;
|
|
437
|
+
firstError = error;
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
415
440
|
}
|
|
416
441
|
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
await Promise.all(Array.from({ length: Math.min(keyConcurrency, queue.length) }, worker));
|
|
445
|
+
if (failed) {
|
|
446
|
+
throw firstError;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
this.processCustomMessage = async (message, partition, topic, topicName, handler, groupId, poisonHandler) => {
|
|
450
|
+
if (this.isForeignEvent(message.headers)) {
|
|
451
|
+
metrics_1.messageSkippedForeignEnvCounter
|
|
452
|
+
.labels({ topic: topicName, groupId })
|
|
453
|
+
.inc();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
metrics_1.messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
|
|
458
|
+
// Decrypt if secret was provided
|
|
459
|
+
const config = this.customTopics.get(topicName);
|
|
460
|
+
const decryptedValue = config.secret && this.encryptionEnabled
|
|
461
|
+
? await this.decryptCustomValue(config.secret, message.value)
|
|
462
|
+
: message.value;
|
|
463
|
+
const value = this.parseCustomValue(decryptedValue);
|
|
464
|
+
this.logger.debug({
|
|
465
|
+
key: message.key?.toString(),
|
|
466
|
+
value,
|
|
467
|
+
timestamp: message.timestamp,
|
|
468
|
+
}, 'Custom topic record received');
|
|
469
|
+
await handler({ ...message, value });
|
|
470
|
+
metrics_1.messageAcknowledgedCounter.labels({ topic: topicName, groupId }).inc();
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (poisonHandler) {
|
|
474
|
+
this.logger.warn(error, 'Poison message detected, forwarding to DLQ');
|
|
475
|
+
await poisonHandler({
|
|
476
|
+
error,
|
|
477
|
+
decryptFailed: false,
|
|
478
|
+
message,
|
|
479
|
+
partition,
|
|
480
|
+
primaryTopicFullName: topic,
|
|
481
|
+
rawPayloadUtf8: message.value
|
|
482
|
+
? Buffer.from(message.value).toString('utf8')
|
|
483
|
+
: '',
|
|
484
|
+
});
|
|
485
|
+
metrics_1.messageAcknowledgedCounter.labels({ topic: topicName, groupId }).inc();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
metrics_1.messageConsumedErrorCounter.labels({ topic: topicName, groupId }).inc();
|
|
490
|
+
throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
419
493
|
};
|
|
420
494
|
this.defaultPoisonHandler = (topicName) => (0, defaultPoisonHandler_1.createDefaultPoisonHandler)(topicName, {
|
|
421
495
|
logger: this.logger,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lucaapp/service-utils",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.42.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"picomatch": "2.3.2",
|
|
112
112
|
"node-forge": "1.4.0",
|
|
113
113
|
"uuid": "^14.0.0",
|
|
114
|
-
"follow-redirects": "^1.16.
|
|
114
|
+
"follow-redirects": "^1.16.1",
|
|
115
115
|
"axios": "1.20.0",
|
|
116
116
|
"path-to-regexp": "0.1.13",
|
|
117
117
|
"ip-address": "10.7.2",
|