@lucaapp/service-utils 5.17.0 → 5.18.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.
- package/dist/lib/kafka/kafkaClient.d.ts +13 -3
- package/dist/lib/kafka/kafkaClient.js +165 -147
- package/dist/lib/kafka/metrics.d.ts +8 -0
- package/dist/lib/kafka/metrics.js +41 -0
- package/dist/lib/kafka/topicLifecycle.d.ts +23 -0
- package/dist/lib/kafka/topicLifecycle.js +146 -0
- package/dist/lib/pgBoss/controller/routes.schema.d.ts +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
this.
|
|
182
|
-
|
|
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 =
|
|
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
|
-
|
|
209
|
-
await
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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: {
|
|
311
|
+
headers: {
|
|
312
|
+
signature,
|
|
313
|
+
sourceEnvironment: this.environment.valueOf(),
|
|
314
|
+
},
|
|
318
315
|
},
|
|
319
316
|
],
|
|
320
317
|
};
|
|
321
|
-
await this.
|
|
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 =
|
|
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
|
-
|
|
350
|
-
await
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
}>;
|