@lucaapp/service-utils 5.40.0 → 5.42.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.
@@ -5,6 +5,7 @@ export type Reservation = {
5
5
  startsAt: Date;
6
6
  createdAt: Date;
7
7
  updatedAt: Date;
8
+ canceledByConsumerAt?: Date | null;
8
9
  tables?: Array<{
9
10
  id: string;
10
11
  internalNumber: number;
@@ -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
- eachMessage: async ({ message, partition }) => {
368
- if (this.isForeignEvent(message.headers)) {
369
- metrics_1.messageSkippedForeignEnvCounter
370
- .labels({ topic: topicName, groupId })
371
- .inc();
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
- try {
375
- metrics_1.messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
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
- else {
411
- metrics_1.messageConsumedErrorCounter
412
- .labels({ topic: topicName, groupId })
413
- .inc();
414
- throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
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,
@@ -30,6 +30,7 @@ type CustomTopicConfig = {
30
30
  topic: string;
31
31
  issuer: Service;
32
32
  secret?: string;
33
+ keyConcurrency?: number;
33
34
  };
34
35
  type CustomTopicPoisonContext = {
35
36
  error: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.40.0",
3
+ "version": "5.42.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [