@lucaapp/service-utils 5.17.0 → 5.19.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.
@@ -43,20 +43,23 @@ export type Payment = PaymentBase & {
43
43
  };
44
44
  export declare const PAYMENT_EVENT_VERSION = 2;
45
45
  export type PaymentEventVersion = typeof PAYMENT_EVENT_VERSION;
46
- export type PaymentEvent = PaymentBase & {
46
+ export type PaymentEvent = {
47
47
  version: PaymentEventVersion;
48
- checkoutId?: string;
49
- orderId?: string;
50
- surchargeAmount: number;
51
- currency: SupportedCurrencies;
52
- status: PaymentStatus;
48
+ uuid: string;
49
+ source: string;
53
50
  method: string;
54
51
  isCash: boolean;
55
- source: string;
56
- payoutId?: string | null;
52
+ status: PaymentStatus;
53
+ currency: SupportedCurrencies;
54
+ invoiceAmount: number;
55
+ tipAmount: number;
56
+ surchargeAmount: number;
57
+ locationId: string;
58
+ paymentVerifier: string;
59
+ checkoutId?: string;
60
+ orderId?: string;
61
+ statusCompleteAt?: Date | null;
57
62
  merchantDetails?: PaymentMerchantDetails;
58
- cardDetails?: PaymentCardDetails;
59
- lineItems?: PaymentEventLineItem[];
60
63
  };
61
64
  export declare const consumerPayAmount: (event: PaymentEvent) => number;
62
65
  export declare const operatorReceivableAmount: (event: PaymentEvent) => number;
@@ -70,21 +73,3 @@ export type PaymentMerchantDetails = {
70
73
  } | null;
71
74
  externalReference?: string | null;
72
75
  };
73
- export type PaymentCardDetails = {
74
- last4?: string | null;
75
- authCode?: string | null;
76
- consumerAlias?: string | null;
77
- terminalId?: string | null;
78
- consumerId?: string | null;
79
- };
80
- export type PaymentEventLineItem = {
81
- uuid?: string;
82
- posItemId?: string | null;
83
- name?: string;
84
- quantity: number;
85
- pricePerUnit: number;
86
- totalPrice: number;
87
- taxPercentage?: number | null;
88
- currency: SupportedCurrencies;
89
- subItems?: PaymentEventLineItem[];
90
- };
@@ -12,19 +12,23 @@ declare class KafkaClient {
12
12
  private readonly admin;
13
13
  private readonly producer;
14
14
  private readonly consumers;
15
+ private readonly topics;
15
16
  readonly serviceIdentity: ServiceIdentity;
16
17
  readonly encryptionEnabled: boolean;
17
18
  constructor(parentLogger: Logger, kafkaConfig: KafkaConfiguration, topicSecrets: Partial<Record<KafkaTopic, string>>, serviceIdentity: ServiceIdentity);
18
19
  connect: () => Promise<void>;
19
- private getTopic;
20
+ private topicPrefix;
21
+ private getTopicName;
22
+ private isForeignEvent;
20
23
  private getTopicSecret;
21
24
  private encryptValue;
22
25
  private decryptValue;
23
26
  private generateSignature;
24
27
  private verifySignature;
25
28
  private parseValue;
26
- private ensureTopics;
29
+ private sendEnsured;
27
30
  consume: <T extends KafkaTopic>(kafkaTopic: T, handler: EventPayloadHandler<T>, fromBeginning?: boolean) => Promise<Consumer>;
31
+ private runConsumer;
28
32
  produce: <T extends KafkaTopic>(kafkaTopic: T, key: string, value: KafkaEvent<T>) => Promise<void>;
29
33
  /**
30
34
  * Register a custom topic (not in KafkaTopic enum)
@@ -34,7 +38,7 @@ declare class KafkaClient {
34
38
  /**
35
39
  * Get full topic name for custom topic
36
40
  */
37
- private getCustomTopic;
41
+ private getCustomTopicName;
38
42
  /**
39
43
  * Produce message to custom topic
40
44
  */
@@ -43,10 +47,16 @@ declare class KafkaClient {
43
47
  * Consume messages from custom topic
44
48
  */
45
49
  consumeCustom: <T = unknown>(topicName: string, handler: GenericEventPayloadHandler<T>, fromBeginning?: boolean, onPoison?: boolean | CustomTopicPoisonHandler) => Promise<Consumer>;
50
+ private runCustomConsumer;
46
51
  private defaultPoisonHandler;
47
52
  private encryptCustomValue;
48
53
  private decryptCustomValue;
49
54
  private parseCustomValue;
55
+ deleteEmptyUnconsumedTopics: () => Promise<{
56
+ deleted: string[];
57
+ skippedActive: string[];
58
+ skippedNonEmpty: string[];
59
+ }>;
50
60
  shutdown: () => Promise<void>;
51
61
  }
52
62
  export { KafkaClient };
@@ -41,34 +41,9 @@ const serviceIdentity_1 = require("../serviceIdentity");
41
41
  const defaultPoisonHandler_1 = require("./defaultPoisonHandler");
42
42
  const events_1 = require("./events");
43
43
  const utils_1 = require("../../utils/utils");
44
- const metrics_1 = require("../metrics");
44
+ const topicLifecycle_1 = require("./topicLifecycle");
45
+ const metrics_1 = require("./metrics");
45
46
  const KEY_ALG = 'ES256';
46
- const messageProducedSizeCounter = new metrics_1.metricsClient.Histogram({
47
- name: 'kafka_message_produce_size_bytes',
48
- help: 'Size of messages produced',
49
- labelNames: ['topic'],
50
- buckets: [64, 128, 256, 512, 1024, 2048, 4096, 8182],
51
- });
52
- const messageProduceError = new metrics_1.metricsClient.Counter({
53
- name: 'kafka_message_produce_error_count',
54
- help: 'Total number of errors during message produce',
55
- labelNames: ['topic'],
56
- });
57
- const messageConsumedCounter = new metrics_1.metricsClient.Counter({
58
- name: 'kafka_message_consume_count',
59
- help: 'Total number of messages consumed',
60
- labelNames: ['topic', 'groupId'],
61
- });
62
- const messageConsumedErrorCounter = new metrics_1.metricsClient.Counter({
63
- name: 'kafka_message_consume_error_count',
64
- help: 'Total number of errors during message consume',
65
- labelNames: ['topic', 'groupId'],
66
- });
67
- const messageAcknowledgedCounter = new metrics_1.metricsClient.Counter({
68
- name: 'kafka_message_consume_ack_count',
69
- help: 'Total number of messages acknowledged',
70
- labelNames: ['topic', 'groupId'],
71
- });
72
47
  const getIssuer = (kafkaTopic) => {
73
48
  return events_1.MessageIssuer[kafkaTopic].valueOf();
74
49
  };
@@ -95,10 +70,18 @@ class KafkaClient {
95
70
  throw (0, utils_1.logAndGetError)(this.logger, 'Unable to connect kafkaClient', error);
96
71
  }
97
72
  };
98
- this.getTopic = async (kafkaTopic) => {
99
- const topic = `${this.environment}_${getIssuer(kafkaTopic)}_${kafkaTopic}`;
100
- await this.ensureTopics(topic);
101
- return topic;
73
+ this.topicPrefix = () => this.environment === serviceIdentity_1.Environment.PROD ||
74
+ this.environment === serviceIdentity_1.Environment.LOCAL
75
+ ? this.environment.valueOf()
76
+ : 'shared';
77
+ this.getTopicName = (kafkaTopic) => `${this.topicPrefix()}_${getIssuer(kafkaTopic)}_${kafkaTopic}`;
78
+ this.isForeignEvent = (headers) => {
79
+ if (this.environment === serviceIdentity_1.Environment.PROD ||
80
+ this.environment === serviceIdentity_1.Environment.LOCAL) {
81
+ return false;
82
+ }
83
+ const source = headerToString(headers?.sourceEnvironment);
84
+ return !!source && source !== this.environment.valueOf();
102
85
  };
103
86
  this.getTopicSecret = (topic) => {
104
87
  const topicSecret = this.topicSecrets[topic];
@@ -175,78 +158,90 @@ class KafkaClient {
175
158
  }
176
159
  return JSON.parse(value.toString());
177
160
  };
178
- this.ensureTopics = async (topic) => {
179
- const existingTopics = await this.admin.listTopics();
180
- if (existingTopics.includes(topic)) {
181
- this.logger.debug(`Topic=${topic} already exists. Not creating.`);
182
- return;
161
+ this.sendEnsured = async (topic, record) => {
162
+ await this.topics.ensure(topic);
163
+ try {
164
+ await this.producer.send(record);
165
+ }
166
+ catch (error) {
167
+ if (!(0, topicLifecycle_1.isUnknownTopicError)(error)) {
168
+ throw error;
169
+ }
170
+ this.logger.warn({ topic }, 'Topic missing on produce. Re-ensuring and retrying once.');
171
+ this.topics.uncache(topic);
172
+ await this.topics.ensure(topic);
173
+ await this.producer.send(record);
183
174
  }
184
- const numPartitions = this.environment == serviceIdentity_1.Environment.PROD ? 3 : 1;
185
- const replicationFactor = this.environment == serviceIdentity_1.Environment.LOCAL ? 1 : 3;
186
- await this.admin.createTopics({
187
- topics: [
188
- {
189
- topic,
190
- numPartitions,
191
- replicationFactor,
192
- },
193
- ],
194
- });
195
- this.logger.debug(`Topic=${topic} created.`);
196
175
  };
197
176
  this.consume = async (kafkaTopic, handler, fromBeginning = false) => {
198
- const topic = await this.getTopic(kafkaTopic);
177
+ const topic = this.getTopicName(kafkaTopic);
199
178
  const groupId = `${this.environment.valueOf()}_${kafkaTopic.valueOf()}_${this.serviceIdentity.identityName}`;
200
179
  try {
201
180
  const consumer = this.kafkaClient.consumer({
202
181
  groupId,
203
182
  sessionTimeout: 20000, // 20 seconds
204
183
  heartbeatInterval: 3000, // 3 seconds
184
+ allowAutoTopicCreation: false,
205
185
  });
206
186
  this.consumers.push(consumer);
207
187
  await consumer.connect();
208
- await consumer.subscribe({ topic, fromBeginning });
209
- await consumer.run({
210
- autoCommit: true,
211
- eachMessage: async ({ message }) => {
212
- try {
213
- messageConsumedCounter
214
- .labels({ topic: kafkaTopic.valueOf(), groupId })
215
- .inc();
216
- const decryptedValue = await this.decryptValue(kafkaTopic, message.value);
217
- await this.verifySignature(kafkaTopic, decryptedValue, message.headers);
218
- const value = this.parseValue(decryptedValue);
219
- this.logger.debug({
220
- key: message.key?.toString(),
221
- value,
222
- timestamp: message.timestamp,
223
- }, 'Record received');
224
- try {
225
- await handler({ ...message, value });
226
- }
227
- catch (error) {
228
- throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for groupId=${groupId}, topic=${topic}, message=${value}`, error);
229
- }
230
- messageAcknowledgedCounter
231
- .labels({ topic: kafkaTopic.valueOf(), groupId })
232
- .inc();
233
- }
234
- catch (error) {
235
- messageConsumedErrorCounter
236
- .labels({ topic: kafkaTopic.valueOf(), groupId })
237
- .inc();
238
- throw error;
239
- }
240
- },
241
- });
188
+ const start = () => this.runConsumer(consumer, topic, fromBeginning, kafkaTopic, handler, groupId);
189
+ if (await this.topics.exists(topic)) {
190
+ await start();
191
+ }
192
+ else {
193
+ void this.topics.runWhenExists(topic, groupId, start);
194
+ }
242
195
  return consumer;
243
196
  }
244
197
  catch (error) {
245
198
  throw (0, utils_1.logAndGetError)(this.logger, `Could not create consumer for groupId=${groupId}, topic=${topic}`, error);
246
199
  }
247
200
  };
201
+ this.runConsumer = async (consumer, topic, fromBeginning, kafkaTopic, handler, groupId) => {
202
+ await consumer.subscribe({ topic, fromBeginning });
203
+ await consumer.run({
204
+ autoCommit: true,
205
+ eachMessage: async ({ message }) => {
206
+ if (this.isForeignEvent(message.headers)) {
207
+ metrics_1.messageSkippedForeignEnvCounter
208
+ .labels({ topic: kafkaTopic.valueOf(), groupId })
209
+ .inc();
210
+ return;
211
+ }
212
+ try {
213
+ metrics_1.messageConsumedCounter
214
+ .labels({ topic: kafkaTopic.valueOf(), groupId })
215
+ .inc();
216
+ const decryptedValue = await this.decryptValue(kafkaTopic, message.value);
217
+ await this.verifySignature(kafkaTopic, decryptedValue, message.headers);
218
+ const value = this.parseValue(decryptedValue);
219
+ this.logger.debug({
220
+ key: message.key?.toString(),
221
+ value,
222
+ timestamp: message.timestamp,
223
+ }, 'Record received');
224
+ try {
225
+ await handler({ ...message, value });
226
+ }
227
+ catch (error) {
228
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for groupId=${groupId}, topic=${topic}, message=${value}`, error);
229
+ }
230
+ metrics_1.messageAcknowledgedCounter
231
+ .labels({ topic: kafkaTopic.valueOf(), groupId })
232
+ .inc();
233
+ }
234
+ catch (error) {
235
+ metrics_1.messageConsumedErrorCounter
236
+ .labels({ topic: kafkaTopic.valueOf(), groupId })
237
+ .inc();
238
+ throw error;
239
+ }
240
+ },
241
+ });
242
+ };
248
243
  this.produce = async (kafkaTopic, key, value) => {
249
- const topic = await this.getTopic(kafkaTopic);
244
+ const topic = this.getTopicName(kafkaTopic);
250
245
  const serializedValue = JSON.stringify(value);
251
246
  const encryptedValue = await this.encryptValue(kafkaTopic, serializedValue);
252
247
  const signature = await this.generateSignature(serializedValue);
@@ -260,18 +255,19 @@ class KafkaClient {
260
255
  headers: {
261
256
  signature,
262
257
  signatureIssuer: this.serviceIdentity.identityName,
258
+ sourceEnvironment: this.environment.valueOf(),
263
259
  },
264
260
  },
265
261
  ],
266
262
  };
267
- await this.producer.send(producerRecord);
263
+ await this.sendEnsured(topic, producerRecord);
268
264
  this.logger.debug(producerRecord, 'Record sent');
269
- messageProducedSizeCounter
265
+ metrics_1.messageProducedSizeCounter
270
266
  .labels({ topic })
271
267
  .observe(Buffer.byteLength(encryptedValue));
272
268
  }
273
269
  catch (error) {
274
- messageProduceError.labels({ topic }).inc();
270
+ metrics_1.messageProduceError.labels({ topic }).inc();
275
271
  throw (0, utils_1.logAndGetError)(this.logger, `Could not produce message for topic=${topic}`, error);
276
272
  }
277
273
  };
@@ -286,20 +282,18 @@ class KafkaClient {
286
282
  /**
287
283
  * Get full topic name for custom topic
288
284
  */
289
- this.getCustomTopic = async (topicName) => {
285
+ this.getCustomTopicName = (topicName) => {
290
286
  const config = this.customTopics.get(topicName);
291
287
  if (!config) {
292
288
  throw (0, utils_1.logAndGetError)(this.logger, `Custom topic ${topicName} not registered. Call registerCustomTopic() first.`);
293
289
  }
294
- const topic = `${this.environment}_${config.issuer}_${topicName}`;
295
- await this.ensureTopics(topic);
296
- return topic;
290
+ return `${this.topicPrefix()}_${config.issuer}_${topicName}`;
297
291
  };
298
292
  /**
299
293
  * Produce message to custom topic
300
294
  */
301
295
  this.produceCustom = async (topicName, key, value) => {
302
- const topic = await this.getCustomTopic(topicName);
296
+ const topic = this.getCustomTopicName(topicName);
303
297
  const serializedValue = JSON.stringify(value);
304
298
  // For custom topics, encryption is optional (skip if no secret provided)
305
299
  const config = this.customTopics.get(topicName);
@@ -314,18 +308,21 @@ class KafkaClient {
314
308
  {
315
309
  key,
316
310
  value: encryptedValue,
317
- headers: { signature },
311
+ headers: {
312
+ signature,
313
+ sourceEnvironment: this.environment.valueOf(),
314
+ },
318
315
  },
319
316
  ],
320
317
  };
321
- await this.producer.send(producerRecord);
318
+ await this.sendEnsured(topic, producerRecord);
322
319
  this.logger.debug(producerRecord, 'Custom topic record sent');
323
- messageProducedSizeCounter
320
+ metrics_1.messageProducedSizeCounter
324
321
  .labels({ topic })
325
322
  .observe(Buffer.byteLength(encryptedValue));
326
323
  }
327
324
  catch (error) {
328
- messageProduceError.labels({ topic }).inc();
325
+ metrics_1.messageProduceError.labels({ topic }).inc();
329
326
  throw (0, utils_1.logAndGetError)(this.logger, `Could not produce message for custom topic=${topicName}`, error);
330
327
  }
331
328
  };
@@ -336,71 +333,87 @@ class KafkaClient {
336
333
  const poisonHandler = onPoison === true
337
334
  ? this.defaultPoisonHandler(topicName)
338
335
  : onPoison || undefined;
339
- const topic = await this.getCustomTopic(topicName);
336
+ const topic = this.getCustomTopicName(topicName);
340
337
  const groupId = `${this.environment.valueOf()}_${topicName}_${this.serviceIdentity.identityName}`;
341
338
  try {
342
339
  const consumer = this.kafkaClient.consumer({
343
340
  groupId,
344
341
  sessionTimeout: 20000,
345
342
  heartbeatInterval: 3000,
343
+ allowAutoTopicCreation: false,
346
344
  });
347
345
  this.consumers.push(consumer);
348
346
  await consumer.connect();
349
- await consumer.subscribe({ topic, fromBeginning });
350
- await consumer.run({
351
- autoCommit: true,
352
- eachMessage: async ({ message, partition }) => {
353
- try {
354
- messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
355
- // Decrypt if secret was provided
356
- const config = this.customTopics.get(topicName);
357
- const decryptedValue = config.secret && this.encryptionEnabled
358
- ? await this.decryptCustomValue(config.secret, message.value)
359
- : message.value;
360
- const value = this.parseCustomValue(decryptedValue);
361
- this.logger.debug({
362
- key: message.key?.toString(),
363
- value,
364
- timestamp: message.timestamp,
365
- }, 'Custom topic record received');
366
- await handler({ ...message, value });
367
- messageAcknowledgedCounter
368
- .labels({ topic: topicName, groupId })
369
- .inc();
370
- }
371
- catch (error) {
372
- if (poisonHandler) {
373
- this.logger.warn(error, 'Poison message detected, forwarding to DLQ');
374
- await poisonHandler({
375
- error,
376
- decryptFailed: false,
377
- message,
378
- partition,
379
- primaryTopicFullName: topic,
380
- rawPayloadUtf8: message.value
381
- ? Buffer.from(message.value).toString('utf8')
382
- : '',
383
- });
384
- messageAcknowledgedCounter
385
- .labels({ topic: topicName, groupId })
386
- .inc();
387
- return;
388
- }
389
- else {
390
- messageConsumedErrorCounter
391
- .labels({ topic: topicName, groupId })
392
- .inc();
393
- throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
394
- }
395
- }
396
- },
397
- });
347
+ const start = () => this.runCustomConsumer(consumer, topic, fromBeginning, topicName, handler, groupId, poisonHandler);
348
+ if (await this.topics.exists(topic)) {
349
+ await start();
350
+ }
351
+ else {
352
+ void this.topics.runWhenExists(topic, groupId, start);
353
+ }
398
354
  return consumer;
399
355
  }
400
356
  catch (error) {
401
357
  throw (0, utils_1.logAndGetError)(this.logger, `Could not create consumer for custom topic=${topicName}`, error);
402
358
  }
403
359
  };
360
+ this.runCustomConsumer = async (consumer, topic, fromBeginning, topicName, handler, groupId, poisonHandler) => {
361
+ await consumer.subscribe({ topic, fromBeginning });
362
+ await consumer.run({
363
+ autoCommit: true,
364
+ eachMessage: async ({ message, partition }) => {
365
+ if (this.isForeignEvent(message.headers)) {
366
+ metrics_1.messageSkippedForeignEnvCounter
367
+ .labels({ topic: topicName, groupId })
368
+ .inc();
369
+ return;
370
+ }
371
+ try {
372
+ metrics_1.messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
373
+ // Decrypt if secret was provided
374
+ const config = this.customTopics.get(topicName);
375
+ const decryptedValue = config.secret && this.encryptionEnabled
376
+ ? await this.decryptCustomValue(config.secret, message.value)
377
+ : message.value;
378
+ const value = this.parseCustomValue(decryptedValue);
379
+ this.logger.debug({
380
+ key: message.key?.toString(),
381
+ value,
382
+ timestamp: message.timestamp,
383
+ }, 'Custom topic record received');
384
+ await handler({ ...message, value });
385
+ metrics_1.messageAcknowledgedCounter
386
+ .labels({ topic: topicName, groupId })
387
+ .inc();
388
+ }
389
+ catch (error) {
390
+ if (poisonHandler) {
391
+ this.logger.warn(error, 'Poison message detected, forwarding to DLQ');
392
+ await poisonHandler({
393
+ error,
394
+ decryptFailed: false,
395
+ message,
396
+ partition,
397
+ primaryTopicFullName: topic,
398
+ rawPayloadUtf8: message.value
399
+ ? Buffer.from(message.value).toString('utf8')
400
+ : '',
401
+ });
402
+ metrics_1.messageAcknowledgedCounter
403
+ .labels({ topic: topicName, groupId })
404
+ .inc();
405
+ return;
406
+ }
407
+ else {
408
+ metrics_1.messageConsumedErrorCounter
409
+ .labels({ topic: topicName, groupId })
410
+ .inc();
411
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
412
+ }
413
+ }
414
+ },
415
+ });
416
+ };
404
417
  this.defaultPoisonHandler = (topicName) => (0, defaultPoisonHandler_1.createDefaultPoisonHandler)(topicName, {
405
418
  logger: this.logger,
406
419
  produceCustom: (t, k, v) => this.produceCustom(t, k, v),
@@ -422,7 +435,9 @@ class KafkaClient {
422
435
  }
423
436
  return JSON.parse(value.toString());
424
437
  };
438
+ this.deleteEmptyUnconsumedTopics = () => this.topics.deleteEmptyUnconsumed();
425
439
  this.shutdown = async () => {
440
+ this.topics.stop();
426
441
  try {
427
442
  for (const consumer of this.consumers) {
428
443
  await consumer.disconnect();
@@ -465,8 +480,11 @@ class KafkaClient {
465
480
  }),
466
481
  });
467
482
  this.admin = this.kafkaClient.admin();
468
- this.producer = this.kafkaClient.producer();
483
+ this.producer = this.kafkaClient.producer({
484
+ allowAutoTopicCreation: false,
485
+ });
469
486
  this.consumers = [];
487
+ this.topics = new topicLifecycle_1.TopicLifecycle(this.admin, this.logger, this.environment);
470
488
  }
471
489
  catch (error) {
472
490
  throw (0, utils_1.logAndGetError)(this.logger, 'Unable to connect to Kafka', error);
@@ -0,0 +1,8 @@
1
+ import { metricsClient } from '../metrics';
2
+ declare const messageProducedSizeCounter: metricsClient.Histogram<"topic">;
3
+ declare const messageProduceError: metricsClient.Counter<"topic">;
4
+ declare const messageConsumedCounter: metricsClient.Counter<"topic" | "groupId">;
5
+ declare const messageConsumedErrorCounter: metricsClient.Counter<"topic" | "groupId">;
6
+ declare const messageAcknowledgedCounter: metricsClient.Counter<"topic" | "groupId">;
7
+ declare const messageSkippedForeignEnvCounter: metricsClient.Counter<"topic" | "groupId">;
8
+ export { messageProducedSizeCounter, messageProduceError, messageConsumedCounter, messageConsumedErrorCounter, messageAcknowledgedCounter, messageSkippedForeignEnvCounter, };
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.messageSkippedForeignEnvCounter = exports.messageAcknowledgedCounter = exports.messageConsumedErrorCounter = exports.messageConsumedCounter = exports.messageProduceError = exports.messageProducedSizeCounter = void 0;
4
+ const metrics_1 = require("../metrics");
5
+ const messageProducedSizeCounter = new metrics_1.metricsClient.Histogram({
6
+ name: 'kafka_message_produce_size_bytes',
7
+ help: 'Size of messages produced',
8
+ labelNames: ['topic'],
9
+ buckets: [64, 128, 256, 512, 1024, 2048, 4096, 8182],
10
+ });
11
+ exports.messageProducedSizeCounter = messageProducedSizeCounter;
12
+ const messageProduceError = new metrics_1.metricsClient.Counter({
13
+ name: 'kafka_message_produce_error_count',
14
+ help: 'Total number of errors during message produce',
15
+ labelNames: ['topic'],
16
+ });
17
+ exports.messageProduceError = messageProduceError;
18
+ const messageConsumedCounter = new metrics_1.metricsClient.Counter({
19
+ name: 'kafka_message_consume_count',
20
+ help: 'Total number of messages consumed',
21
+ labelNames: ['topic', 'groupId'],
22
+ });
23
+ exports.messageConsumedCounter = messageConsumedCounter;
24
+ const messageConsumedErrorCounter = new metrics_1.metricsClient.Counter({
25
+ name: 'kafka_message_consume_error_count',
26
+ help: 'Total number of errors during message consume',
27
+ labelNames: ['topic', 'groupId'],
28
+ });
29
+ exports.messageConsumedErrorCounter = messageConsumedErrorCounter;
30
+ const messageAcknowledgedCounter = new metrics_1.metricsClient.Counter({
31
+ name: 'kafka_message_consume_ack_count',
32
+ help: 'Total number of messages acknowledged',
33
+ labelNames: ['topic', 'groupId'],
34
+ });
35
+ exports.messageAcknowledgedCounter = messageAcknowledgedCounter;
36
+ const messageSkippedForeignEnvCounter = new metrics_1.metricsClient.Counter({
37
+ name: 'kafka_message_skipped_foreign_env_count',
38
+ help: 'Total number of messages skipped because they belong to another environment',
39
+ labelNames: ['topic', 'groupId'],
40
+ });
41
+ exports.messageSkippedForeignEnvCounter = messageSkippedForeignEnvCounter;
@@ -0,0 +1,23 @@
1
+ import { Admin } from 'kafkajs';
2
+ import { Logger } from 'pino';
3
+ import { Environment } from '../serviceIdentity';
4
+ declare const isUnknownTopicError: (error: unknown) => boolean;
5
+ declare class TopicLifecycle {
6
+ private readonly admin;
7
+ private readonly logger;
8
+ private readonly environment;
9
+ private readonly ensuredTopics;
10
+ private stopped;
11
+ constructor(admin: Admin, logger: Logger, environment: Environment);
12
+ stop: () => void;
13
+ exists: (topic: string) => Promise<boolean>;
14
+ uncache: (topic: string) => void;
15
+ ensure: (topic: string) => Promise<void>;
16
+ runWhenExists: (topic: string, groupId: string, start: () => Promise<void>) => Promise<void>;
17
+ deleteEmptyUnconsumed: () => Promise<{
18
+ deleted: string[];
19
+ skippedActive: string[];
20
+ skippedNonEmpty: string[];
21
+ }>;
22
+ }
23
+ export { TopicLifecycle, isUnknownTopicError };
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isUnknownTopicError = exports.TopicLifecycle = void 0;
4
+ const kafkajs_1 = require("kafkajs");
5
+ const serviceIdentity_1 = require("../serviceIdentity");
6
+ const utils_1 = require("../../utils/utils");
7
+ const TOPIC_WAIT_INTERVAL_MS = 30_000;
8
+ const TOPIC_DELETE_BATCH_SIZE = 20;
9
+ const sleep = (ms) => new Promise(resolve => {
10
+ const timer = setTimeout(resolve, ms);
11
+ timer.unref?.();
12
+ });
13
+ const isUnknownTopicError = (error) => {
14
+ if (!error || typeof error !== 'object') {
15
+ return false;
16
+ }
17
+ const err = error;
18
+ if (err.type === 'UNKNOWN_TOPIC_OR_PARTITION' || err.code === 3) {
19
+ return true;
20
+ }
21
+ if (err.originalError && isUnknownTopicError(err.originalError)) {
22
+ return true;
23
+ }
24
+ if (err.cause && isUnknownTopicError(err.cause)) {
25
+ return true;
26
+ }
27
+ return Array.isArray(err.errors) && err.errors.some(isUnknownTopicError);
28
+ };
29
+ exports.isUnknownTopicError = isUnknownTopicError;
30
+ class TopicLifecycle {
31
+ constructor(admin, logger, environment) {
32
+ this.admin = admin;
33
+ this.logger = logger;
34
+ this.environment = environment;
35
+ this.ensuredTopics = new Set();
36
+ this.stopped = false;
37
+ this.stop = () => {
38
+ this.stopped = true;
39
+ };
40
+ this.exists = async (topic) => {
41
+ const existingTopics = await this.admin.listTopics();
42
+ return existingTopics.includes(topic);
43
+ };
44
+ this.uncache = (topic) => {
45
+ this.ensuredTopics.delete(topic);
46
+ };
47
+ this.ensure = async (topic) => {
48
+ if (this.ensuredTopics.has(topic)) {
49
+ return;
50
+ }
51
+ if (!(await this.exists(topic))) {
52
+ const numPartitions = this.environment == serviceIdentity_1.Environment.PROD ? 3 : 1;
53
+ const replicationFactor = this.environment == serviceIdentity_1.Environment.LOCAL ? 1 : 3;
54
+ try {
55
+ await this.admin.createTopics({
56
+ topics: [
57
+ {
58
+ topic,
59
+ numPartitions,
60
+ replicationFactor,
61
+ },
62
+ ],
63
+ });
64
+ this.logger.info(`Topic=${topic} created.`);
65
+ }
66
+ catch (error) {
67
+ throw (0, utils_1.logAndGetError)(this.logger, `Unable to create topic=${topic} (broker partition quota exhausted?)`, error);
68
+ }
69
+ }
70
+ this.ensuredTopics.add(topic);
71
+ };
72
+ this.runWhenExists = async (topic, groupId, start) => {
73
+ this.logger.info({ topic, groupId }, 'Topic does not exist yet. Consumer waits for a producer to create it.');
74
+ try {
75
+ let attempt = 0;
76
+ while (!this.stopped) {
77
+ await sleep(TOPIC_WAIT_INTERVAL_MS);
78
+ const exists = await this.exists(topic).catch(() => false);
79
+ if (exists) {
80
+ await start();
81
+ this.logger.info({ topic, groupId, attempt }, 'Topic appeared. Consumer subscribed.');
82
+ return;
83
+ }
84
+ attempt += 1;
85
+ if (attempt % 20 === 0) {
86
+ this.logger.info({ topic, groupId, attempt }, 'Still waiting for topic before subscribing');
87
+ }
88
+ }
89
+ }
90
+ catch (error) {
91
+ this.logger.error({ err: error, topic, groupId }, 'Failed to subscribe consumer after topic appeared');
92
+ }
93
+ };
94
+ this.deleteEmptyUnconsumed = async () => {
95
+ if (this.environment === serviceIdentity_1.Environment.PROD) {
96
+ throw (0, utils_1.logAndGetError)(this.logger, 'Refusing to delete kafka topics in production');
97
+ }
98
+ const topics = (await this.admin.listTopics()).filter(topic => !topic.startsWith('__'));
99
+ const activeTopics = new Set();
100
+ const { groups } = await this.admin.listGroups();
101
+ if (groups.length > 0) {
102
+ const described = await this.admin.describeGroups(groups.map(group => group.groupId));
103
+ for (const group of described.groups) {
104
+ for (const member of group.members) {
105
+ try {
106
+ const { assignment } = kafkajs_1.AssignerProtocol.MemberAssignment.decode(member.memberAssignment) ?? { assignment: {} };
107
+ Object.keys(assignment).forEach(topic => activeTopics.add(topic));
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const deleted = [];
116
+ const skippedActive = [];
117
+ const skippedNonEmpty = [];
118
+ for (const topic of topics) {
119
+ if (activeTopics.has(topic)) {
120
+ skippedActive.push(topic);
121
+ continue;
122
+ }
123
+ const offsets = await this.admin.fetchTopicOffsets(topic);
124
+ const isEmpty = offsets.every(partition => partition.high === partition.low);
125
+ if (!isEmpty) {
126
+ skippedNonEmpty.push(topic);
127
+ continue;
128
+ }
129
+ deleted.push(topic);
130
+ }
131
+ for (let i = 0; i < deleted.length; i += TOPIC_DELETE_BATCH_SIZE) {
132
+ await this.admin.deleteTopics({
133
+ topics: deleted.slice(i, i + TOPIC_DELETE_BATCH_SIZE),
134
+ });
135
+ }
136
+ deleted.forEach(topic => this.ensuredTopics.delete(topic));
137
+ this.logger.info({
138
+ deletedCount: deleted.length,
139
+ skippedActiveCount: skippedActive.length,
140
+ skippedNonEmptyCount: skippedNonEmpty.length,
141
+ }, 'Deleted empty unconsumed kafka topics');
142
+ return { deleted, skippedActive, skippedNonEmpty };
143
+ };
144
+ }
145
+ }
146
+ exports.TopicLifecycle = TopicLifecycle;
@@ -206,11 +206,11 @@ export declare const successResponseSchema: z.ZodObject<{
206
206
  }, "strip", z.ZodTypeAny, {
207
207
  success: boolean;
208
208
  name?: string | undefined;
209
- jobId?: string | undefined;
210
209
  deleted?: string | undefined;
210
+ jobId?: string | undefined;
211
211
  }, {
212
212
  success: boolean;
213
213
  name?: string | undefined;
214
- jobId?: string | undefined;
215
214
  deleted?: string | undefined;
215
+ jobId?: string | undefined;
216
216
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.17.0",
3
+ "version": "5.19.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [