@jetit/publisher 4.1.1 → 5.0.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.
@@ -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,144 @@ 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: 5000,
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
+ if (this.metricsCollector) {
153
+ this.metricsCollector.addMetrics(tracker.getMetrics());
154
+ }
76
155
  if (key === '*')
77
156
  key = generatedKey ?? key;
78
157
  }
79
158
  await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
159
+ await this.circuitBreaker.recordSuccess();
160
+ }
161
+ else {
162
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
80
163
  }
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');
164
+ const metrics = tracker.getMetrics();
165
+ this.logPerformance(`PTIME;${key};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
166
+ return key;
167
+ }
168
+ catch (error) {
169
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
170
+ tracker.incrementErrorCount('publish');
171
+ if (this.metricsCollector) {
172
+ this.metricsCollector.addMetrics(tracker.getMetrics());
84
173
  }
174
+ await this.circuitBreaker.recordFailure();
175
+ throw new Error('Publisher Error');
85
176
  }
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
177
  }
93
178
  async scheduledPublish(scheduledTime, eventData, uniquePerInstance = false, repeatInterval = 0, multicast = false) {
94
179
  const currentTime = new Date();
@@ -99,7 +184,7 @@ class Streams {
99
184
  if (scheduledTime < currentTime) {
100
185
  throw new Error('PUBLISHER: Cannot schedule an event in the past');
101
186
  }
102
- else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= 500) {
187
+ else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= this.config.immediatePublishThreshold) {
103
188
  await this.publish(eventData, multicast);
104
189
  }
105
190
  else {
@@ -107,7 +192,7 @@ class Streams {
107
192
  if (uniquePerInstance === true) {
108
193
  const existingJob = await this.redisPublisher.zscore('se', key);
109
194
  if (existingJob) {
110
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${eventData}' already exists. Skipping.`);
195
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${JSON.stringify(eventData)}' already exists. Skipping.`);
111
196
  return;
112
197
  }
113
198
  }
@@ -141,17 +226,27 @@ class Streams {
141
226
  * PUBLISHER_LOGGER.log('New order created:', event.data);
142
227
  * });
143
228
  */
144
- listen(eventName, maxRetries = 5, initialDelay = 1000) {
145
- return this.listenInternals(eventName).pipe((0, rxjs_1.retry)({
146
- count: maxRetries,
229
+ listen(eventName, listenerOptions) {
230
+ const options = {
231
+ maxRetries: this.config.maxRetries,
232
+ initialDelay: this.config.initialRetryDelay,
233
+ filterKeepAlive: this.config.filterKeepAlive,
234
+ publishOnceGuarantee: false,
235
+ ...listenerOptions,
236
+ };
237
+ const subscriptionId = (0, id_1.generateID)('HEX');
238
+ return this.listenInternals(eventName, subscriptionId, options.eventFilter, options.filterKeepAlive, options.publishOnceGuarantee).pipe((0, rxjs_1.retry)({
239
+ count: options.maxRetries,
147
240
  delay: (error, retryAttempt) => {
148
- const delay = initialDelay * Math.pow(2, retryAttempt);
241
+ const delay = options.initialDelay * Math.pow(2, retryAttempt);
149
242
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
150
243
  return (0, rxjs_1.timer)(delay);
151
244
  },
152
245
  }), (0, rxjs_1.catchError)((error) => {
153
- logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${maxRetries} retries: ${error.message}`);
246
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${options.maxRetries} retries: ${error.message}`);
154
247
  return (0, rxjs_1.throwError)(() => new Error(error.message));
248
+ }), (0, rxjs_1.finalize)(() => {
249
+ this.removeSubscription(eventName, subscriptionId);
155
250
  }));
156
251
  }
157
252
  async createConsumerAndRegister(eventName) {
@@ -159,41 +254,82 @@ class Streams {
159
254
  const key = `instance:${this.instanceId}:subscribedEvents`;
160
255
  const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
161
256
  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) => {
257
+ try {
258
+ // Check if the consumer group already exists
259
+ const groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
260
+ let groupExists = false;
261
+ for (const group of groupInfo) {
262
+ if (group[1] === this.consumerGroupName) {
263
+ groupExists = true;
264
+ break;
265
+ }
266
+ }
267
+ if (!groupExists) {
268
+ await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
269
+ logger_1.PUBLISHER_LOGGER.log(`Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
270
+ }
271
+ else {
272
+ logger_1.PUBLISHER_LOGGER.log(`Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
273
+ }
274
+ }
275
+ catch (e) {
168
276
  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));
277
+ }
278
+ // Check if the consumer already exists in the group
279
+ const consumers = (await this.redisGroups.xinfo('CONSUMERS', streamName, this.consumerGroupName));
280
+ let consumerExists = false;
281
+ for (const consumer of consumers) {
282
+ if (consumer[1] === this.instanceId) {
283
+ consumerExists = true;
284
+ break;
285
+ }
286
+ }
287
+ let createConsumerStatus;
288
+ if (!consumerExists) {
289
+ createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
290
+ }
291
+ else {
292
+ createConsumerStatus = 0; // Consumer already exists
293
+ logger_1.PUBLISHER_LOGGER.log(`Consumer already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName, instanceId: this.instanceId })}`);
294
+ }
171
295
  await this.redisGroups.sadd(key, eventName);
172
296
  const addToCGSet = await this.redisGroups.sadd(`${eventName}`, this.consumerGroupName);
173
297
  const addToFlushSet = await this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName);
174
298
  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
299
  return createConsumerStatus === 0 || createConsumerStatus === 1;
176
300
  }
177
- listenInternals(eventName) {
301
+ listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false) {
302
+ if (!this.subscriptions.has(eventName)) {
303
+ this.subscriptions.set(eventName, new Map());
304
+ }
178
305
  const bs = new rxjs_1.BehaviorSubject(null);
306
+ const subscription = {
307
+ subject: bs,
308
+ filter: eventFilter,
309
+ lastMatchTime: Date.now(),
310
+ keepAlive: filterKeepAlive,
311
+ };
312
+ this.subscriptions.get(eventName).set(subscriptionId, subscription);
179
313
  const timer = (0, rxjs_1.interval)(10000).subscribe(async () => {
180
314
  /** Clear earlier unprocessed messages. Runs every 10 seconds */
181
- await processMessage(this.redisGroups, '0', false);
315
+ await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
182
316
  });
317
+ let lastMatchTime = Date.now();
183
318
  const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
184
319
  /** Cleanup timer */
185
320
  timer.unsubscribe();
186
321
  }));
187
322
  const streamName = `${eventName}:${this.consumerGroupName}`;
188
- const processMessage = async (redisClient, messageId, multicast = false, processPending = false) => {
323
+ const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
189
324
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName}`);
190
325
  try {
191
326
  try {
192
327
  /**
193
328
  * Check if the message is already acquired by another client and is pending.
329
+ * This check should only happen if the message is not a multicast message
194
330
  */
195
331
  const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
196
- if (pendingDetails[2] === 0 && multicast === false) {
332
+ if (pendingDetails[2] === 0 && !multicast) {
197
333
  logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: MACK ${messageId} for ${streamName}`);
198
334
  return;
199
335
  }
@@ -210,10 +346,16 @@ class Streams {
210
346
  * are usually behind in the stream so XREADGROUP will not read them and hence
211
347
  * they need to be read using XRANGE.
212
348
  */
213
- if (multicast === true || processPending) {
349
+ tracker.startRedisOperation();
350
+ if (multicast || processPending) {
214
351
  const messages = await redisClient.xrange(streamName, messageId, messageId);
215
352
  if (messages && messages.length) {
216
- eventData = JSON.parse(messages[0][1][1]);
353
+ try {
354
+ eventData = JSON.parse(messages[0][1][1]);
355
+ }
356
+ catch (e) {
357
+ console.error(`JSON parsing failed for the following message ${messages[0][1][1]} in the publisher.`);
358
+ }
217
359
  }
218
360
  }
219
361
  else {
@@ -222,28 +364,91 @@ class Streams {
222
364
  eventData = JSON.parse(messages[0][1][0][1][1]);
223
365
  }
224
366
  }
367
+ tracker.endRedisOperation();
368
+ tracker.startProcessing();
225
369
  if (eventData) {
226
- bs.next(eventData);
227
- await redisClient.xack(streamName, this.consumerGroupName, messageId);
228
- await redisClient.zadd(`ack:${streamName}`, Date.now().toString(), messageId);
370
+ if (publishOnceGuarantee) {
371
+ if (await this.duplicateChecker.isDuplicate(eventData, this.consumerGroupName)) {
372
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Duplicate event detected, skipping processing for consumer group ${this.consumerGroupName}`);
373
+ tracker.incrementDuplicateEvent();
374
+ await redisClient.xack(streamName, this.consumerGroupName, messageId);
375
+ return;
376
+ }
377
+ }
378
+ try {
379
+ const subscriptions = this.subscriptions.get(eventName);
380
+ if (subscriptions) {
381
+ const subscriptionEntries = Array.from(subscriptions.entries());
382
+ for (let i = 0; i < subscriptionEntries.length; i++) {
383
+ const [subId, sub] = subscriptionEntries[i];
384
+ if (!sub.filter || sub.filter(eventData)) {
385
+ sub.subject.next(eventData);
386
+ sub.lastMatchTime = Date.now();
387
+ }
388
+ else if (Date.now() - sub.lastMatchTime > sub.keepAlive) {
389
+ /**
390
+ * Reset the lastMatch time every day by default. For now only
391
+ * log the data. Should add functionality to remove the filter
392
+ * if its not used at all to gain minor improvements in
393
+ * performace
394
+ */
395
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
396
+ sub.lastMatchTime = Date.now();
397
+ }
398
+ }
399
+ }
400
+ await redisClient.xack(streamName, this.consumerGroupName, messageId);
401
+ await redisClient.zadd(`ack:${streamName}`, Date.now().toString(), messageId);
402
+ }
403
+ catch (processingError) {
404
+ logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, processingError);
405
+ const dlqEvent = {
406
+ ...eventData,
407
+ failureReason: processingError.message,
408
+ retryCount: (eventData.retryCount || 0) + 1,
409
+ originalStream: streamName,
410
+ consumerGroupName: this.consumerGroupName,
411
+ timestamp: Date.now(),
412
+ };
413
+ await this.dlq.addToDLQ(dlqEvent);
414
+ }
229
415
  }
230
416
  else {
231
417
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message ${messageId} not found for ${streamName}`);
232
418
  }
419
+ tracker.endProcessing();
233
420
  /** Process Unprocessed Message if this is a main tree, otherwise limit to processing 100 messages that are unacknowledged */
234
421
  if (!processPending) {
235
422
  const unprocessedMessageIds = await (0, utils_1.getUnacknowledgedMessages)(redisClient, this.consumerGroupName, streamName, this.instanceId);
236
- if (unprocessedMessageIds.countOnThisConsumer && unprocessedMessageIds.countOnThisConsumer > 25) {
423
+ if (unprocessedMessageIds.countOnThisConsumer &&
424
+ unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
237
425
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
238
426
  }
239
427
  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);
428
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${id}`);
429
+ await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
242
430
  }
243
431
  }
244
432
  }
245
433
  catch (e) {
246
434
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing message ${messageId} for ${streamName}`, e);
435
+ if (!multicast) {
436
+ const dlqEvent = {
437
+ eventId: messageId,
438
+ eventName: streamName.split(':')[0],
439
+ data: {},
440
+ failureReason: e.message,
441
+ retryCount: 1,
442
+ originalStream: streamName,
443
+ timestamp: Date.now(),
444
+ consumerGroupName: this.consumerGroupName,
445
+ };
446
+ await this.dlq.addToDLQ(dlqEvent);
447
+ tracker.incrementErrorCount('subscribe');
448
+ if (this.metricsCollector) {
449
+ this.metricsCollector.addMetrics(tracker.getMetrics());
450
+ }
451
+ }
247
452
  }
248
453
  };
249
454
  /** Register the consumer and setup the Observable */
@@ -256,7 +461,7 @@ class Streams {
256
461
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Redis Subscription connection initiated for ${eventName}`);
257
462
  });
258
463
  eventStreamClient.on('message', async (channel, data) => {
259
- const subscribeStartTime = process.hrtime();
464
+ const tracker = new tracker_1.MetricsTracker();
260
465
  let messageIdRead, multicastRead;
261
466
  try {
262
467
  const { messageId, multicast } = JSON.parse(data);
@@ -268,10 +473,12 @@ class Streams {
268
473
  multicastRead = false;
269
474
  }
270
475
  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}`);
476
+ await processMessage(this.redisGroups, messageIdRead, tracker, multicastRead);
477
+ const metrics = tracker.getMetrics();
478
+ logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
479
+ if (this.metricsCollector) {
480
+ this.metricsCollector.addMetrics(tracker.getMetrics());
481
+ }
275
482
  });
276
483
  })
277
484
  .catch((e) => {
@@ -300,27 +507,34 @@ class Streams {
300
507
  * }
301
508
  */
302
509
  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();
510
+ try {
511
+ if (this.cleanUpTimer) {
512
+ clearInterval(this.cleanUpTimer);
513
+ }
514
+ if (this._redisPublisher) {
515
+ await this._redisPublisher.quit();
516
+ }
517
+ for (const eventName of this.eventsListened) {
518
+ await registry_1.RedisRegistry.getConnection(`sub-${eventName}`).quit();
519
+ }
520
+ if (this._redisGroups) {
521
+ await this._redisGroups.quit();
522
+ }
311
523
  }
312
- if (this.redisGroups) {
313
- await this.redisGroups.quit();
524
+ catch (error) {
525
+ logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
314
526
  }
315
527
  }
316
- async cleanupAcknowledgedMessages(eventName, interval = 60 * 60 * 1000) {
528
+ async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
317
529
  const streamName = `${eventName}:${this.consumerGroupName}`;
318
530
  const cleanupThreshold = Date.now() - interval;
319
531
  const acknowledgedMessages = await this.redisGroups.zrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
320
532
  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);
533
+ // Remove acknowledged messages from the stream in batches
534
+ const batchSize = 100;
535
+ for (let i = 0; i < acknowledgedMessages.length; i += batchSize) {
536
+ const batch = acknowledgedMessages.slice(i, i + batchSize);
537
+ await this.redisGroups.xdel(streamName, ...batch);
324
538
  }
325
539
  // Remove acknowledged messages from the Sorted Set
326
540
  await this.redisGroups.zremrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
@@ -342,5 +556,99 @@ class Streams {
342
556
  const returnData = await await Promise.all(tempPromises);
343
557
  return { status: 'SUCCESS', data: returnData, message: 'We recommend not running this in times of heavy load' };
344
558
  }
559
+ logPerformance(message) {
560
+ if (this.config.performanceLogger) {
561
+ this.config.performanceLogger(message);
562
+ }
563
+ else {
564
+ logger_1.PERFORMANCE_LOGGER.log(message);
565
+ }
566
+ }
567
+ /**
568
+ * @description
569
+ * This method is use to retry an event that has ended in the dead letter queue,
570
+ * which happens after the first retry.
571
+ */
572
+ async retryFromDLQ(eventId) {
573
+ return this.dlq.retryFromDLQ(eventId);
574
+ }
575
+ /**
576
+ * @description
577
+ * This returns the number of items and the rate at which events are added
578
+ * to the queue. The queue is global and hence remains as is
579
+ */
580
+ async getDLQStats() {
581
+ return this.dlq.getDLQStats();
582
+ }
583
+ removeSubscription(eventName, subscriptionId) {
584
+ const eventSubscriptions = this.subscriptions.get(eventName);
585
+ if (eventSubscriptions) {
586
+ eventSubscriptions.delete(subscriptionId);
587
+ if (eventSubscriptions.size === 0) {
588
+ this.subscriptions.delete(eventName);
589
+ }
590
+ }
591
+ }
592
+ /**
593
+ * @description
594
+ * This is a simple helper utility that can be used externally to create alerts based
595
+ * on thresholds that can be provided into the function. It returns true/false for each
596
+ * key that is provided. Not all keys are required
597
+ */
598
+ async checkThresholds(thresholds) {
599
+ if (!this.metricsCollector) {
600
+ throw new Error('Metrics collection is not enabled');
601
+ }
602
+ const latestMetrics = await this.metricsCollector.getLatestMetrics();
603
+ if (!latestMetrics) {
604
+ throw new Error('No metrics available');
605
+ }
606
+ const alerts = {};
607
+ for (const [key, threshold] of Object.entries(thresholds)) {
608
+ if (key in latestMetrics) {
609
+ alerts[key] = latestMetrics[key] > threshold;
610
+ }
611
+ }
612
+ return alerts;
613
+ }
614
+ /**
615
+ * @description
616
+ * This will return you the stats of the publisher for the last 6 hours after cleaning
617
+ */
618
+ async getMetrics(startTime, endTime) {
619
+ return this.metricsCollector.getMetrics(startTime, endTime);
620
+ }
621
+ /**
622
+ * @description
623
+ * This will return you the latest stats of the publisher
624
+ */
625
+ async getLatestMetrics() {
626
+ return this.metricsCollector.getLatestMetrics();
627
+ }
628
+ /**
629
+ * @description
630
+ * This returns the status of the performance control setup. This includes
631
+ * the circuit breaker
632
+ */
633
+ async getPerformanceControlStatus() {
634
+ const circuitBreakerState = this.circuitBreaker.getState();
635
+ return {
636
+ circuitBreakerState,
637
+ };
638
+ }
639
+ /**
640
+ * @description
641
+ * This is a manual control to process stored events in case the
642
+ * circuit is OPEN
643
+ */
644
+ async processStoredEvents() {
645
+ if (this.circuitBreaker.getState() === circuit_breaker_1.CircuitState.CLOSED) {
646
+ const storedEvents = await this.circuitBreaker.getStoredEvents();
647
+ for (const event of storedEvents) {
648
+ await this.publish(event);
649
+ }
650
+ await this.circuitBreaker.clearStoredEvents();
651
+ }
652
+ }
345
653
  }
346
654
  exports.Streams = Streams;