@jetit/publisher 4.1.1 → 5.1.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.
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Streams = void 0;
4
4
  const id_1 = require("@jetit/id");
5
5
  const rxjs_1 = require("rxjs");
6
+ const collector_1 = require("../monitoring/collector");
7
+ const tracker_1 = require("../monitoring/tracker");
8
+ const circuit_breaker_1 = require("../performance/circuit_breaker");
9
+ const dlq_1 = require("./dlq");
10
+ const duplication_1 = require("./duplication");
6
11
  const logger_1 = require("./logger");
7
12
  const registry_1 = require("./registry");
8
13
  const utils_1 = require("./utils");
9
- function publisherErrorHandler(error) {
10
- logger_1.PUBLISHER_LOGGER.error('PUBLISHER UNHANDLED ERROR: ', JSON.stringify(error));
11
- }
12
14
  class Streams {
13
15
  get redisPublisher() {
14
16
  if (!this._redisPublisher)
@@ -34,61 +36,145 @@ class Streams {
34
36
  * // Create a new Streams instance for the "POS" service
35
37
  * const streams = new Streams('POS');
36
38
  */
37
- constructor(serviceName) {
39
+ constructor(serviceName, config = {}) {
38
40
  this.eventsListened = [];
41
+ this.subscriptions = new Map();
42
+ this.DEFAULT_STREAMS_CONFIG = {
43
+ immediatePublishThreshold: 500,
44
+ unprocessedMessageThreshold: 25,
45
+ acknowledgedMessageCleanupInterval: 60 * 60 * 1000, // 1 hour
46
+ cleanUpInterval: 1000 * 60 * 60,
47
+ dlqEventThreshold: 2000,
48
+ filterKeepAlive: 24 * 60 * 60 * 1000,
49
+ duplicationCheckWindow: 84600,
50
+ circuitBreaker: {
51
+ enabled: true,
52
+ errorThreshold: 50,
53
+ errorThresholdPercentage: 50,
54
+ openStateDuration: 30000,
55
+ halfOpenStateMaxAttempts: 10,
56
+ maxStoredEvents: 10000,
57
+ },
58
+ };
59
+ /** Initialise Config properties */
60
+ this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
39
61
  this.instanceUniqueId = process.env['INSTANCE_ID'] ?? (0, id_1.generateID)('HEX', 'FE');
40
62
  this.instanceId = `${serviceName}:${this.instanceUniqueId}`;
41
63
  this.consumerGroupName = `cg-${serviceName}`;
42
64
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Instance ID: ${this.instanceId}`);
43
- const cleanUpInterval = parseInt(process.env['CLEANUP_INTERVAL'] || `${1000 * 60 * 60}`, 10) ?? 1000 * 60 * 60;
65
+ const cleanUpInterval = this.config.cleanUpInterval ?? parseInt(process.env['CLEANUP_INTERVAL'] ?? `${this.config.cleanUpInterval}`, 10);
44
66
  this.cleanUpTimer = setInterval(() => {
45
- this.runClear(cleanUpInterval);
67
+ this.runClear(cleanUpInterval).catch((error) => {
68
+ logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
69
+ });
46
70
  }, cleanUpInterval);
71
+ this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
72
+ this.metricsCollector = new collector_1.MetricsCollector({
73
+ redisClient: this.redisPublisher,
74
+ collectionInterval: 60000,
75
+ retentionPeriod: 6 * 60 * 60 * 1000,
76
+ }, this.dlq);
77
+ this.duplicateChecker = new duplication_1.ContentBasedDeduplication(this.redisPublisher, this.config.duplicationCheckWindow);
78
+ this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
79
+ if (this.config.circuitBreaker.enabled)
80
+ this.setupCircuitBreakerListeners();
81
+ }
82
+ setupCircuitBreakerListeners() {
83
+ this.circuitBreaker.on('stateChange', async (newState) => {
84
+ logger_1.PUBLISHER_LOGGER.log(`Circuit breaker state changed to: ${circuit_breaker_1.CircuitState[newState]}`);
85
+ if (newState === circuit_breaker_1.CircuitState.CLOSED) {
86
+ await this.processStoredEvents();
87
+ }
88
+ });
47
89
  }
48
90
  async runClear(cleanUpInterval) {
49
91
  logger_1.PUBLISHER_LOGGER.log('PUBLISHER: Running Clearance', this.eventsListened);
50
- for (const eventName of this.eventsListened) {
51
- process.nextTick(async () => {
52
- /** This removes the messages from the stream after they have been processed according to cleanup interval */
53
- await this.cleanupAcknowledgedMessages(eventName, cleanUpInterval).catch(publisherErrorHandler);
54
- logger_1.PUBLISHER_LOGGER.log(`Cleanup process for Acknowledged messages completed for ${eventName}`);
55
- });
56
- }
92
+ const cleanupPromises = this.eventsListened.map((eventName) => this.cleanupAcknowledgedMessages(eventName, cleanUpInterval)
93
+ .then(() => {
94
+ logger_1.PUBLISHER_LOGGER.log(`Cleanup process for Acknowledged messages completed for ${eventName}`);
95
+ })
96
+ .catch((error) => {
97
+ logger_1.PUBLISHER_LOGGER.error(`Error during cleanup for ${eventName}:`, error);
98
+ }));
99
+ await Promise.all(cleanupPromises);
57
100
  }
58
101
  async publish(data, multicast = false) {
59
- const publishStartTime = process.hrtime();
102
+ const tracker = new tracker_1.MetricsTracker();
60
103
  if (data.eventId)
61
104
  data.republishEvent = data.eventId;
62
105
  data.eventId = (0, id_1.generateID)('HEX', 'FF');
63
106
  if (!data.createdAt)
64
107
  data.createdAt = Date.now();
65
- const consumerGroups = await (0, utils_1.getAllConsumerGroups)(data.eventName, this.redisPublisher);
66
- let key = '*';
67
- if (consumerGroups.length > 0) {
68
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
69
- try {
108
+ /**
109
+ * This is a simplified description of the circuit breaker code
110
+ * 1. If the circuit breaker is enabled, then we check if the circuit is closed and messages are allowed to pass
111
+ * 2. If the circuit is open, then the event is stored and an error is logged notifying that the system has recorded
112
+ * a large number of publisher failures (controlled by errorThreshold and errorThresholdPercentage). This emits an
113
+ * event and the time of change of state is recorded. The circuit remains open for 30 seconds, allowing the system to
114
+ * correct itself as much as possible. All events during this time end up in the buffer
115
+ * 3. If the 30s is expired, the system resets to a half open state where new events are allowed to go through and a
116
+ * success state is recorded.
117
+ * 4. In the half open state if there are 10 attempts that succeed, then the circuit goes to closed and all pending
118
+ * messages are published.
119
+ *
120
+ * Here is a pictorial representation
121
+ *
122
+ * [Failure threshold met]
123
+ * CLOSED --------------------> OPEN
124
+ * ^ |
125
+ * | |
126
+ * | [Max success | [Open duration elapsed]
127
+ * | in half-open] |
128
+ * | v
129
+ * -------------------- HALF-OPEN
130
+ *
131
+ * These properties are configurable
132
+ */
133
+ if (this.config.circuitBreaker.enabled && !this.circuitBreaker.isAllowed()) {
134
+ await this.circuitBreaker.storeEvent(data);
135
+ logger_1.PUBLISHER_LOGGER.error('Circuit is open, event stored for later processing');
136
+ return 'CIRCUIT_BREAKER_FLOW';
137
+ }
138
+ try {
139
+ tracker.startRedisOperation();
140
+ const consumerGroups = await (0, utils_1.getAllConsumerGroups)(data.eventName, this.redisPublisher);
141
+ tracker.endRedisOperation();
142
+ let key = '*';
143
+ if (consumerGroups.length > 0) {
144
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
70
145
  for (const consumerGroup of consumerGroups) {
71
146
  // Publish the event to each consumer group's stream
72
147
  const streamName = `${data.eventName}:${consumerGroup}`;
73
- const generatedKey = await this.redisPublisher
74
- .xadd(streamName, key, 'data', JSON.stringify(data))
75
- .catch((e) => logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')} failed with data ${JSON.stringify(data)}, ${e} `));
148
+ tracker.startRedisOperation();
149
+ const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
150
+ tracker.endRedisOperation();
151
+ tracker.incrementEventCount();
152
+ tracker.incrementMessageRate('publish', data.eventName);
153
+ if (this.metricsCollector) {
154
+ this.metricsCollector.addMetrics(tracker.getMetrics());
155
+ }
76
156
  if (key === '*')
77
157
  key = generatedKey ?? key;
78
158
  }
79
159
  await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
160
+ await this.circuitBreaker.recordSuccess();
161
+ }
162
+ else {
163
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
80
164
  }
81
- catch (error) {
82
- logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
83
- throw new Error('Publisher Error');
165
+ const metrics = tracker.getMetrics();
166
+ this.logPerformance(`PTIME;${key};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
167
+ return key;
168
+ }
169
+ catch (error) {
170
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
171
+ tracker.incrementErrorCount('publish');
172
+ if (this.metricsCollector) {
173
+ this.metricsCollector.addMetrics(tracker.getMetrics());
84
174
  }
175
+ await this.circuitBreaker.recordFailure();
176
+ throw new Error('Publisher Error');
85
177
  }
86
- else
87
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
88
- const publishEndTime = process.hrtime(publishStartTime);
89
- const elapsedTime = publishEndTime[0] * 1000 + publishEndTime[1] / 1000000;
90
- logger_1.PERFORMANCE_LOGGER.log(`PTIME;${key};${data.eventName};${Date.now()};${elapsedTime}`);
91
- return key;
92
178
  }
93
179
  async scheduledPublish(scheduledTime, eventData, uniquePerInstance = false, repeatInterval = 0, multicast = false) {
94
180
  const currentTime = new Date();
@@ -99,7 +185,7 @@ class Streams {
99
185
  if (scheduledTime < currentTime) {
100
186
  throw new Error('PUBLISHER: Cannot schedule an event in the past');
101
187
  }
102
- else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= 500) {
188
+ else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= this.config.immediatePublishThreshold) {
103
189
  await this.publish(eventData, multicast);
104
190
  }
105
191
  else {
@@ -107,7 +193,7 @@ class Streams {
107
193
  if (uniquePerInstance === true) {
108
194
  const existingJob = await this.redisPublisher.zscore('se', key);
109
195
  if (existingJob) {
110
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${eventData}' already exists. Skipping.`);
196
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${JSON.stringify(eventData)}' already exists. Skipping.`);
111
197
  return;
112
198
  }
113
199
  }
@@ -141,17 +227,27 @@ class Streams {
141
227
  * PUBLISHER_LOGGER.log('New order created:', event.data);
142
228
  * });
143
229
  */
144
- listen(eventName, maxRetries = 5, initialDelay = 1000) {
145
- return this.listenInternals(eventName).pipe((0, rxjs_1.retry)({
146
- count: maxRetries,
230
+ listen(eventName, listenerOptions) {
231
+ const options = {
232
+ maxRetries: this.config.maxRetries,
233
+ initialDelay: this.config.initialRetryDelay,
234
+ filterKeepAlive: this.config.filterKeepAlive,
235
+ publishOnceGuarantee: false,
236
+ ...listenerOptions,
237
+ };
238
+ const subscriptionId = (0, id_1.generateID)('HEX');
239
+ return this.listenInternals(eventName, subscriptionId, options.eventFilter, options.filterKeepAlive, options.publishOnceGuarantee).pipe((0, rxjs_1.retry)({
240
+ count: options.maxRetries,
147
241
  delay: (error, retryAttempt) => {
148
- const delay = initialDelay * Math.pow(2, retryAttempt);
242
+ const delay = options.initialDelay * Math.pow(2, retryAttempt);
149
243
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
150
244
  return (0, rxjs_1.timer)(delay);
151
245
  },
152
246
  }), (0, rxjs_1.catchError)((error) => {
153
- logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${maxRetries} retries: ${error.message}`);
247
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${options.maxRetries} retries: ${error.message}`);
154
248
  return (0, rxjs_1.throwError)(() => new Error(error.message));
249
+ }), (0, rxjs_1.finalize)(() => {
250
+ this.removeSubscription(eventName, subscriptionId);
155
251
  }));
156
252
  }
157
253
  async createConsumerAndRegister(eventName) {
@@ -159,41 +255,94 @@ class Streams {
159
255
  const key = `instance:${this.instanceId}:subscribedEvents`;
160
256
  const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
161
257
  this.eventsListened.push(eventName);
162
- await this.redisGroups
163
- .xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM')
164
- .then(() => {
165
- logger_1.PUBLISHER_LOGGER.log(`Group created created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
166
- })
167
- .catch((e) => {
258
+ try {
259
+ // Check if the consumer group already exists
260
+ let groupInfo = [];
261
+ try {
262
+ groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
263
+ }
264
+ catch (e) {
265
+ // Do nothing
266
+ }
267
+ let groupExists = false;
268
+ for (const group of groupInfo) {
269
+ if (group[1] === this.consumerGroupName) {
270
+ groupExists = true;
271
+ break;
272
+ }
273
+ }
274
+ if (!groupExists) {
275
+ await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
276
+ logger_1.PUBLISHER_LOGGER.log(`Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
277
+ }
278
+ else {
279
+ logger_1.PUBLISHER_LOGGER.log(`Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
280
+ }
281
+ }
282
+ catch (e) {
168
283
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Group creation failed with error ${e.message} for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
169
- });
170
- const createConsumerStatus = (await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId));
284
+ }
285
+ // Check if the consumer already exists in the group
286
+ let consumers = [];
287
+ try {
288
+ consumers = (await this.redisGroups.xinfo('CONSUMERS', streamName, this.consumerGroupName));
289
+ }
290
+ catch (e) {
291
+ // Do nothing
292
+ }
293
+ let consumerExists = false;
294
+ for (const consumer of consumers) {
295
+ if (consumer[1] === this.instanceId) {
296
+ consumerExists = true;
297
+ break;
298
+ }
299
+ }
300
+ let createConsumerStatus;
301
+ if (!consumerExists) {
302
+ createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
303
+ }
304
+ else {
305
+ createConsumerStatus = 0; // Consumer already exists
306
+ logger_1.PUBLISHER_LOGGER.log(`Consumer already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName, instanceId: this.instanceId })}`);
307
+ }
171
308
  await this.redisGroups.sadd(key, eventName);
172
309
  const addToCGSet = await this.redisGroups.sadd(`${eventName}`, this.consumerGroupName);
173
310
  const addToFlushSet = await this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName);
174
311
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Consumer Registered and created with ${this.instanceId} under ${this.consumerGroupName} with ${createConsumerStatus} consumers and with the following status ${JSON.stringify({ addToCGSet, addToFlushSet })}`);
175
312
  return createConsumerStatus === 0 || createConsumerStatus === 1;
176
313
  }
177
- listenInternals(eventName) {
314
+ listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false) {
315
+ if (!this.subscriptions.has(eventName)) {
316
+ this.subscriptions.set(eventName, new Map());
317
+ }
178
318
  const bs = new rxjs_1.BehaviorSubject(null);
319
+ const subscription = {
320
+ subject: bs,
321
+ filter: eventFilter,
322
+ lastMatchTime: Date.now(),
323
+ keepAlive: filterKeepAlive,
324
+ };
325
+ this.subscriptions.get(eventName).set(subscriptionId, subscription);
179
326
  const timer = (0, rxjs_1.interval)(10000).subscribe(async () => {
180
327
  /** Clear earlier unprocessed messages. Runs every 10 seconds */
181
- await processMessage(this.redisGroups, '0', false);
328
+ await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
182
329
  });
330
+ let lastMatchTime = Date.now();
183
331
  const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
184
332
  /** Cleanup timer */
185
333
  timer.unsubscribe();
186
334
  }));
187
335
  const streamName = `${eventName}:${this.consumerGroupName}`;
188
- const processMessage = async (redisClient, messageId, multicast = false, processPending = false) => {
336
+ const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
189
337
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName}`);
190
338
  try {
191
339
  try {
192
340
  /**
193
341
  * Check if the message is already acquired by another client and is pending.
342
+ * This check should only happen if the message is not a multicast message
194
343
  */
195
344
  const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
196
- if (pendingDetails[2] === 0 && multicast === false) {
345
+ if (pendingDetails[2] === 0 && !multicast) {
197
346
  logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: MACK ${messageId} for ${streamName}`);
198
347
  return;
199
348
  }
@@ -210,40 +359,118 @@ class Streams {
210
359
  * are usually behind in the stream so XREADGROUP will not read them and hence
211
360
  * they need to be read using XRANGE.
212
361
  */
213
- if (multicast === true || processPending) {
362
+ tracker.startRedisOperation();
363
+ if (multicast || processPending) {
214
364
  const messages = await redisClient.xrange(streamName, messageId, messageId);
215
365
  if (messages && messages.length) {
216
- eventData = JSON.parse(messages[0][1][1]);
366
+ try {
367
+ eventData = JSON.parse(messages[0][1][1]);
368
+ }
369
+ catch (e) {
370
+ console.error(`JSON parsing failed for the following message ${messages[0][1][1]} in the publisher.`);
371
+ }
217
372
  }
218
373
  }
219
374
  else {
220
375
  const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
221
376
  if (messages && messages.length) {
377
+ if (messageId === '0') {
378
+ messageId = messages[0][1][0][0];
379
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${messageId}`);
380
+ }
222
381
  eventData = JSON.parse(messages[0][1][0][1][1]);
223
382
  }
224
383
  }
384
+ tracker.endRedisOperation();
385
+ tracker.startProcessing();
225
386
  if (eventData) {
226
- bs.next(eventData);
227
- await redisClient.xack(streamName, this.consumerGroupName, messageId);
228
- await redisClient.zadd(`ack:${streamName}`, Date.now().toString(), messageId);
387
+ if (publishOnceGuarantee) {
388
+ if (await this.duplicateChecker.isDuplicate(eventData, this.consumerGroupName)) {
389
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Duplicate event detected, skipping processing for consumer group ${this.consumerGroupName}`);
390
+ tracker.incrementDuplicateEvent();
391
+ await redisClient.xack(streamName, this.consumerGroupName, messageId);
392
+ return;
393
+ }
394
+ }
395
+ try {
396
+ const subscriptions = this.subscriptions.get(eventName);
397
+ if (subscriptions) {
398
+ const subscriptionEntries = Array.from(subscriptions.entries());
399
+ for (let i = 0; i < subscriptionEntries.length; i++) {
400
+ const [subId, sub] = subscriptionEntries[i];
401
+ if (!sub.filter || sub.filter(eventData)) {
402
+ sub.subject.next(eventData);
403
+ sub.lastMatchTime = Date.now();
404
+ }
405
+ else if (Date.now() - sub.lastMatchTime > sub.keepAlive) {
406
+ /**
407
+ * Reset the lastMatch time every day by default. For now only
408
+ * log the data. Should add functionality to remove the filter
409
+ * if its not used at all to gain minor improvements in
410
+ * performace
411
+ */
412
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
413
+ sub.lastMatchTime = Date.now();
414
+ }
415
+ }
416
+ }
417
+ await redisClient.xack(streamName, this.consumerGroupName, messageId);
418
+ await redisClient.zadd(`ack:${streamName}`, Date.now().toString(), messageId);
419
+ }
420
+ catch (processingError) {
421
+ logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, processingError);
422
+ const dlqEvent = {
423
+ ...eventData,
424
+ failureReason: processingError.message,
425
+ retryCount: (eventData.retryCount || 0) + 1,
426
+ originalStream: streamName,
427
+ consumerGroupName: this.consumerGroupName,
428
+ timestamp: Date.now(),
429
+ };
430
+ await this.dlq.addToDLQ(dlqEvent);
431
+ }
432
+ tracker.incrementMessageRate('subscribe', eventData.eventName);
433
+ const processingTime = Date.now() - eventData.createdAt;
434
+ tracker.addProcessingTime(processingTime);
435
+ const lag = Date.now() - eventData.createdAt;
436
+ tracker.setConsumerLag(this.consumerGroupName, lag);
229
437
  }
230
438
  else {
231
439
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message ${messageId} not found for ${streamName}`);
232
440
  }
441
+ tracker.endProcessing();
233
442
  /** Process Unprocessed Message if this is a main tree, otherwise limit to processing 100 messages that are unacknowledged */
234
443
  if (!processPending) {
235
444
  const unprocessedMessageIds = await (0, utils_1.getUnacknowledgedMessages)(redisClient, this.consumerGroupName, streamName, this.instanceId);
236
- if (unprocessedMessageIds.countOnThisConsumer && unprocessedMessageIds.countOnThisConsumer > 25) {
445
+ if (unprocessedMessageIds.countOnThisConsumer &&
446
+ unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
237
447
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
238
448
  }
239
449
  for (const id of unprocessedMessageIds.messageIds) {
240
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reporcessing unprocessed message with id: ${id}`);
241
- await processMessage(redisClient, id, multicast, true);
450
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${id}`);
451
+ await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
242
452
  }
243
453
  }
244
454
  }
245
455
  catch (e) {
246
456
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing message ${messageId} for ${streamName}`, e);
457
+ if (!multicast) {
458
+ const dlqEvent = {
459
+ eventId: messageId,
460
+ eventName: streamName.split(':')[0],
461
+ data: {},
462
+ failureReason: e.message,
463
+ retryCount: 1,
464
+ originalStream: streamName,
465
+ timestamp: Date.now(),
466
+ consumerGroupName: this.consumerGroupName,
467
+ };
468
+ await this.dlq.addToDLQ(dlqEvent);
469
+ tracker.incrementErrorCount('subscribe');
470
+ if (this.metricsCollector) {
471
+ this.metricsCollector.addMetrics(tracker.getMetrics());
472
+ }
473
+ }
247
474
  }
248
475
  };
249
476
  /** Register the consumer and setup the Observable */
@@ -256,7 +483,7 @@ class Streams {
256
483
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Redis Subscription connection initiated for ${eventName}`);
257
484
  });
258
485
  eventStreamClient.on('message', async (channel, data) => {
259
- const subscribeStartTime = process.hrtime();
486
+ const tracker = new tracker_1.MetricsTracker();
260
487
  let messageIdRead, multicastRead;
261
488
  try {
262
489
  const { messageId, multicast } = JSON.parse(data);
@@ -268,10 +495,12 @@ class Streams {
268
495
  multicastRead = false;
269
496
  }
270
497
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream Notification Received for event ${eventName} with message ID ${messageIdRead}`);
271
- await processMessage(this.redisGroups, messageIdRead, multicastRead);
272
- const subscribendTime = process.hrtime(subscribeStartTime);
273
- const elapsedTime = subscribendTime[0] * 1000 + subscribendTime[1] / 1000000;
274
- logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${data.eventName};${Date.now()};${elapsedTime}`);
498
+ await processMessage(this.redisGroups, messageIdRead, tracker, multicastRead);
499
+ const metrics = tracker.getMetrics();
500
+ logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
501
+ if (this.metricsCollector) {
502
+ this.metricsCollector.addMetrics(tracker.getMetrics());
503
+ }
275
504
  });
276
505
  })
277
506
  .catch((e) => {
@@ -300,27 +529,34 @@ class Streams {
300
529
  * }
301
530
  */
302
531
  async close() {
303
- if (this.cleanUpTimer) {
304
- clearInterval(this.cleanUpTimer);
305
- }
306
- if (this.redisPublisher) {
307
- await this.redisPublisher.quit();
308
- }
309
- for (const eventName of this.eventsListened) {
310
- registry_1.RedisRegistry.getConnection(`sub-${eventName}`).quit();
532
+ try {
533
+ if (this.cleanUpTimer) {
534
+ clearInterval(this.cleanUpTimer);
535
+ }
536
+ if (this._redisPublisher) {
537
+ await this._redisPublisher.quit();
538
+ }
539
+ for (const eventName of this.eventsListened) {
540
+ await registry_1.RedisRegistry.getConnection(`sub-${eventName}`).quit();
541
+ }
542
+ if (this._redisGroups) {
543
+ await this._redisGroups.quit();
544
+ }
311
545
  }
312
- if (this.redisGroups) {
313
- await this.redisGroups.quit();
546
+ catch (error) {
547
+ logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
314
548
  }
315
549
  }
316
- async cleanupAcknowledgedMessages(eventName, interval = 60 * 60 * 1000) {
550
+ async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
317
551
  const streamName = `${eventName}:${this.consumerGroupName}`;
318
552
  const cleanupThreshold = Date.now() - interval;
319
553
  const acknowledgedMessages = await this.redisGroups.zrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
320
554
  if (acknowledgedMessages && acknowledgedMessages.length > 0) {
321
- // Remove acknowledged messages from the stream
322
- for (const messageId of acknowledgedMessages) {
323
- await this.redisGroups.xdel(streamName, messageId);
555
+ // Remove acknowledged messages from the stream in batches
556
+ const batchSize = 100;
557
+ for (let i = 0; i < acknowledgedMessages.length; i += batchSize) {
558
+ const batch = acknowledgedMessages.slice(i, i + batchSize);
559
+ await this.redisGroups.xdel(streamName, ...batch);
324
560
  }
325
561
  // Remove acknowledged messages from the Sorted Set
326
562
  await this.redisGroups.zremrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
@@ -342,5 +578,99 @@ class Streams {
342
578
  const returnData = await await Promise.all(tempPromises);
343
579
  return { status: 'SUCCESS', data: returnData, message: 'We recommend not running this in times of heavy load' };
344
580
  }
581
+ logPerformance(message) {
582
+ if (this.config.performanceLogger) {
583
+ this.config.performanceLogger(message);
584
+ }
585
+ else {
586
+ logger_1.PERFORMANCE_LOGGER.log(message);
587
+ }
588
+ }
589
+ /**
590
+ * @description
591
+ * This method is use to retry an event that has ended in the dead letter queue,
592
+ * which happens after the first retry.
593
+ */
594
+ async retryFromDLQ(eventId) {
595
+ return this.dlq.retryFromDLQ(eventId);
596
+ }
597
+ /**
598
+ * @description
599
+ * This returns the number of items and the rate at which events are added
600
+ * to the queue. The queue is global and hence remains as is
601
+ */
602
+ async getDLQStats() {
603
+ return this.dlq.getDLQStats();
604
+ }
605
+ removeSubscription(eventName, subscriptionId) {
606
+ const eventSubscriptions = this.subscriptions.get(eventName);
607
+ if (eventSubscriptions) {
608
+ eventSubscriptions.delete(subscriptionId);
609
+ if (eventSubscriptions.size === 0) {
610
+ this.subscriptions.delete(eventName);
611
+ }
612
+ }
613
+ }
614
+ /**
615
+ * @description
616
+ * This is a simple helper utility that can be used externally to create alerts based
617
+ * on thresholds that can be provided into the function. It returns true/false for each
618
+ * key that is provided. Not all keys are required
619
+ */
620
+ async checkThresholds(thresholds) {
621
+ if (!this.metricsCollector) {
622
+ throw new Error('Metrics collection is not enabled');
623
+ }
624
+ const latestMetrics = await this.metricsCollector.getLatestMetrics();
625
+ if (!latestMetrics) {
626
+ throw new Error('No metrics available');
627
+ }
628
+ const alerts = {};
629
+ for (const [key, threshold] of Object.entries(thresholds)) {
630
+ if (key in latestMetrics) {
631
+ alerts[key] = latestMetrics[key] > threshold;
632
+ }
633
+ }
634
+ return alerts;
635
+ }
636
+ /**
637
+ * @description
638
+ * This will return you the stats of the publisher for the last 6 hours after cleaning
639
+ */
640
+ async getMetrics(startTime, endTime) {
641
+ return this.metricsCollector.getMetrics(startTime, endTime);
642
+ }
643
+ /**
644
+ * @description
645
+ * This will return you the latest stats of the publisher
646
+ */
647
+ async getLatestMetrics() {
648
+ return this.metricsCollector.getLatestMetrics();
649
+ }
650
+ /**
651
+ * @description
652
+ * This returns the status of the performance control setup. This includes
653
+ * the circuit breaker
654
+ */
655
+ async getPerformanceControlStatus() {
656
+ const circuitBreakerState = this.circuitBreaker.getState();
657
+ return {
658
+ circuitBreakerState,
659
+ };
660
+ }
661
+ /**
662
+ * @description
663
+ * This is a manual control to process stored events in case the
664
+ * circuit is OPEN
665
+ */
666
+ async processStoredEvents() {
667
+ if (this.circuitBreaker.getState() === circuit_breaker_1.CircuitState.CLOSED) {
668
+ const storedEvents = await this.circuitBreaker.getStoredEvents();
669
+ for (const event of storedEvents) {
670
+ await this.publish(event);
671
+ }
672
+ await this.circuitBreaker.clearStoredEvents();
673
+ }
674
+ }
345
675
  }
346
676
  exports.Streams = Streams;