@jetit/publisher 5.6.1 → 6.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.
@@ -17,6 +17,9 @@ export declare class Streams {
17
17
  private subscriptions;
18
18
  private duplicateChecker;
19
19
  private circuitBreaker;
20
+ private readonly findHighestStreamIdScript;
21
+ private findHighestStreamIdScriptSHA;
22
+ private optimizationActiveUntil;
20
23
  private get redisPublisher();
21
24
  private get redisGroups();
22
25
  private DEFAULT_STREAMS_CONFIG;
@@ -35,6 +38,27 @@ export declare class Streams {
35
38
  * const streams = new Streams('POS');
36
39
  */
37
40
  constructor(serviceName: string, config?: Partial<IStreamsConfig>, redisConnectionId?: string);
41
+ /**
42
+ * Loads Lua scripts into Redis server cache
43
+ */
44
+ private loadScripts;
45
+ /**
46
+ * Executes a cached script, falling back to EVAL if needed
47
+ */
48
+ private executeScript;
49
+ /**
50
+ * Checks if optimization mode is active for a given event type.
51
+ * Cleans up expired entries during the check.
52
+ */
53
+ private isOptimizationActive;
54
+ /**
55
+ * Activates optimization mode for a given event type for a configured duration.
56
+ */
57
+ private activateOptimizationFor;
58
+ /**
59
+ * Deletes a specific message ID from multiple consumer group streams.
60
+ */
61
+ private deleteMessageFromStreams;
38
62
  private setupCircuitBreakerListeners;
39
63
  private runClear;
40
64
  /**
@@ -40,6 +40,75 @@ class Streams {
40
40
  this.redisConnectionId = redisConnectionId;
41
41
  this.eventsListened = [];
42
42
  this.subscriptions = new Map();
43
+ // Add these properties to store the script SHA and optimization state
44
+ this.findHighestStreamIdScript = `
45
+ -- KEYS[1]: event name
46
+ -- KEYS[2]: maximum streams to check
47
+ -- ARGV[1..n]: consumer group names
48
+
49
+ local eventName = KEYS[1]
50
+ local maxStreamsToCheck = tonumber(KEYS[2])
51
+ local highestTimestamp = 0
52
+ local highestSeq = 0
53
+
54
+ -- Calculate how many streams to check (all or max)
55
+ local streamsToCheck = math.min(#ARGV, maxStreamsToCheck)
56
+
57
+ -- If we have more streams than we want to check, use sampling
58
+ local streamIndices = {}
59
+ if streamsToCheck < #ARGV then
60
+ -- Deterministic sampling (every Nth stream)
61
+ local step = math.floor(#ARGV / streamsToCheck)
62
+ local offset = math.floor(step / 2) -- Start in the middle of each segment
63
+
64
+ for i=1, streamsToCheck do
65
+ local idx = offset + (i-1) * step
66
+ if idx <= #ARGV then
67
+ table.insert(streamIndices, idx)
68
+ end
69
+ end
70
+
71
+ -- Ensure we also check the last stream in case it's ahead
72
+ if #streamIndices == 0 or streamIndices[#streamIndices] ~= #ARGV then
73
+ if #ARGV > 0 then table.insert(streamIndices, #ARGV) end
74
+ end
75
+ else
76
+ -- Check all streams
77
+ for i=1, #ARGV do
78
+ streamIndices[i] = i
79
+ end
80
+ end
81
+
82
+ -- Check selected streams
83
+ for _, i in ipairs(streamIndices) do
84
+ local streamName = eventName .. ":" .. ARGV[i]
85
+ local latestEntries = redis.call('XREVRANGE', streamName, '+', '-', 'COUNT', 1)
86
+
87
+ if #latestEntries > 0 then
88
+ local id = latestEntries[1][1]
89
+ local dashPos = string.find(id, '-')
90
+
91
+ if dashPos then
92
+ local timestamp = tonumber(string.sub(id, 1, dashPos - 1))
93
+ local seq = tonumber(string.sub(id, dashPos + 1))
94
+
95
+ if timestamp > highestTimestamp then
96
+ highestTimestamp = timestamp
97
+ highestSeq = seq
98
+ elseif timestamp == highestTimestamp and seq > highestSeq then
99
+ highestSeq = seq
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ -- Add a safety buffer to the sequence number
106
+ highestSeq = highestSeq + 10
107
+
108
+ return { highestTimestamp, highestSeq }
109
+ `;
110
+ this.findHighestStreamIdScriptSHA = null;
111
+ this.optimizationActiveUntil = new Map(); // Tracks optimization state per event
43
112
  this.DEFAULT_STREAMS_CONFIG = {
44
113
  immediatePublishThreshold: 500,
45
114
  unprocessedMessageThreshold: 25,
@@ -56,19 +125,23 @@ class Streams {
56
125
  halfOpenStateMaxAttempts: 10,
57
126
  maxStoredEvents: 10000,
58
127
  },
128
+ // New defaults
129
+ optimizationDurationMs: 2 * 60 * 1000, // 2 minutes
130
+ optimizationThreshold: 20, // Enable optimization for >20 consumer groups
59
131
  };
60
132
  /** Initialise Config properties */
61
133
  this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
62
134
  this.instanceUniqueId = process.env['INSTANCE_ID'] ?? (0, id_1.generateID)('HEX', 'FE');
63
135
  this.instanceId = `${serviceName}:${this.instanceUniqueId}`;
64
136
  this.consumerGroupName = `cg-${serviceName}`;
65
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Instance ID: ${this.instanceId}`);
137
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Instance ID: ${this.instanceId} and with config: ${JSON.stringify(this.config)}`);
66
138
  const cleanUpInterval = this.config.cleanUpInterval ?? parseInt(process.env['CLEANUP_INTERVAL'] ?? `${this.config.cleanUpInterval}`, 10);
67
139
  this.cleanUpTimer = setInterval(() => {
68
140
  this.runClear(cleanUpInterval).catch((error) => {
69
- logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
141
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error during cleanup:', error);
70
142
  });
71
143
  }, cleanUpInterval);
144
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Clean Up process setup for ${cleanUpInterval} ms`);
72
145
  this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
73
146
  this.metricsCollector = new collector_1.MetricsCollector({
74
147
  redisClient: this.redisPublisher,
@@ -79,10 +152,124 @@ class Streams {
79
152
  this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
80
153
  if (this.config.circuitBreaker.enabled)
81
154
  this.setupCircuitBreakerListeners();
155
+ // Load scripts when Redis is available
156
+ this.loadScripts().catch((error) => {
157
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Failed to load Redis scripts:', error);
158
+ });
159
+ }
160
+ /**
161
+ * Loads Lua scripts into Redis server cache
162
+ */
163
+ async loadScripts() {
164
+ try {
165
+ // Wait until Redis connection is established (simple check)
166
+ if (!this._redisPublisher) {
167
+ await new Promise((resolve) => setTimeout(resolve, 100)); // Wait briefly
168
+ if (!this._redisPublisher) {
169
+ logger_1.PUBLISHER_LOGGER.warn('PUBLISHER: Redis connection not ready for script loading.');
170
+ return;
171
+ }
172
+ }
173
+ const redis = this.redisPublisher;
174
+ // Load the script and store its SHA
175
+ this.findHighestStreamIdScriptSHA = (await redis.script('LOAD', this.findHighestStreamIdScript));
176
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Successfully loaded stream ID script with SHA: ${this.findHighestStreamIdScriptSHA}`);
177
+ }
178
+ catch (error) {
179
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error loading Redis scripts:', error);
180
+ // We'll try again later if needed via executeScript fallback
181
+ }
182
+ }
183
+ /**
184
+ * Executes a cached script, falling back to EVAL if needed
185
+ */
186
+ async executeScript(script, scriptSHA, numKeys, ...args) {
187
+ try {
188
+ // If we have the SHA, try EVALSHA first
189
+ if (scriptSHA) {
190
+ try {
191
+ // Ensure redisPublisher is available
192
+ if (!this._redisPublisher)
193
+ throw new Error('Redis publisher connection not available');
194
+ return await this.redisPublisher.evalsha(scriptSHA, numKeys, ...args);
195
+ }
196
+ catch (error) {
197
+ // Add type 'any' to error
198
+ // If the script isn't found on the server, fall back to EVAL
199
+ if (error?.message && typeof error.message === 'string' && error.message.includes('NOSCRIPT')) {
200
+ logger_1.PUBLISHER_LOGGER.warn('PUBLISHER: Script not found on Redis server, reloading...');
201
+ if (!this._redisPublisher)
202
+ throw new Error('Redis publisher connection not available for script reload');
203
+ const newSHA = (await this.redisPublisher.script('LOAD', script)); // Cast result to string
204
+ // Update the stored SHA
205
+ if (script === this.findHighestStreamIdScript) {
206
+ this.findHighestStreamIdScriptSHA = newSHA;
207
+ }
208
+ // Retry with EVALSHA
209
+ return await this.redisPublisher.evalsha(newSHA, numKeys, ...args);
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+ // If we don't have the SHA yet, use EVAL directly
215
+ if (!this._redisPublisher)
216
+ throw new Error('Redis publisher connection not available for EVAL');
217
+ return await this.redisPublisher.eval(script, numKeys, ...args);
218
+ }
219
+ catch (error) {
220
+ // Add type 'any' to error
221
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error executing Redis script:', error);
222
+ throw error; // Re-throw the original error
223
+ }
224
+ }
225
+ /**
226
+ * Checks if optimization mode is active for a given event type.
227
+ * Cleans up expired entries during the check.
228
+ */
229
+ isOptimizationActive(eventName) {
230
+ const expirationTime = this.optimizationActiveUntil.get(eventName);
231
+ if (!expirationTime)
232
+ return false;
233
+ if (Date.now() > expirationTime) {
234
+ // Clean up expired entry during publish operation
235
+ this.optimizationActiveUntil.delete(eventName);
236
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Deactivated optimization for ${eventName}`);
237
+ return false;
238
+ }
239
+ return true;
240
+ }
241
+ /**
242
+ * Activates optimization mode for a given event type for a configured duration.
243
+ */
244
+ activateOptimizationFor(eventName) {
245
+ const durationMs = this.config.optimizationDurationMs ?? this.DEFAULT_STREAMS_CONFIG.optimizationDurationMs;
246
+ const expirationTime = Date.now() + durationMs;
247
+ this.optimizationActiveUntil.set(eventName, expirationTime);
248
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Activated optimization for ${eventName} until ${new Date(expirationTime).toISOString()}`);
249
+ }
250
+ /**
251
+ * Deletes a specific message ID from multiple consumer group streams.
252
+ */
253
+ async deleteMessageFromStreams(eventName, consumerGroups, messageId) {
254
+ const deletionPromises = consumerGroups.map(async (consumerGroup) => {
255
+ const streamName = `${eventName}:${consumerGroup}`;
256
+ try {
257
+ if (!this._redisPublisher)
258
+ throw new Error('Redis publisher connection not available for XDEL');
259
+ await this.redisPublisher.xdel(streamName, messageId);
260
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Deleted message ${messageId} from ${streamName} for rollback`);
261
+ }
262
+ catch (error) {
263
+ // Log error but continue trying to delete from other streams
264
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error deleting message ${messageId} from ${streamName}:`, error);
265
+ }
266
+ });
267
+ // Wait for all deletions to attempt completion
268
+ await Promise.allSettled(deletionPromises);
82
269
  }
83
270
  setupCircuitBreakerListeners() {
84
271
  this.circuitBreaker.on('stateChange', async (newState) => {
85
- logger_1.PUBLISHER_LOGGER.log(`Circuit breaker state changed to: ${circuit_breaker_1.CircuitState[newState]}`);
272
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Circuit breaker state changed to: ${circuit_breaker_1.CircuitState[newState]}`);
86
273
  if (newState === circuit_breaker_1.CircuitState.CLOSED) {
87
274
  await this.processStoredEvents();
88
275
  }
@@ -92,10 +279,10 @@ class Streams {
92
279
  logger_1.PUBLISHER_LOGGER.log('PUBLISHER: Running Clearance', this.eventsListened);
93
280
  const cleanupPromises = this.eventsListened.map((eventName) => this.cleanupAcknowledgedMessages(eventName, cleanUpInterval)
94
281
  .then(() => {
95
- logger_1.PUBLISHER_LOGGER.log(`Cleanup process for Acknowledged messages completed for ${eventName}`);
282
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Cleanup process for Acknowledged messages completed for ${eventName}`);
96
283
  })
97
284
  .catch((error) => {
98
- logger_1.PUBLISHER_LOGGER.error(`Error during cleanup for ${eventName}:`, error);
285
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during cleanup for ${eventName}:`, error);
99
286
  }));
100
287
  await Promise.all(cleanupPromises);
101
288
  }
@@ -133,32 +320,105 @@ class Streams {
133
320
  */
134
321
  if (this.config.circuitBreaker.enabled && !this.circuitBreaker.isAllowed()) {
135
322
  await this.circuitBreaker.storeEvent(data);
136
- logger_1.PUBLISHER_LOGGER.error('Circuit is open, event stored for later processing');
323
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Circuit is open, event stored for later processing');
137
324
  return 'CIRCUIT_BREAKER_FLOW';
138
325
  }
139
326
  try {
140
327
  tracker.startRedisOperation();
141
328
  const consumerGroups = await (0, utils_1.getAllConsumerGroups)(data.eventName, this.redisPublisher);
142
329
  tracker.endRedisOperation();
143
- let key = '*';
330
+ let key = '*'; // Default to auto-generated ID
331
+ const publishedGroups = []; // Track successfully published groups for rollback
144
332
  if (consumerGroups.length > 0) {
145
333
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
146
- for (const consumerGroup of consumerGroups) {
147
- // Publish the event to each consumer group's stream
148
- const streamName = `${data.eventName}:${consumerGroup}`;
334
+ // Check if optimization is already active or if threshold is met
335
+ const optimizationThreshold = this.config.optimizationThreshold ?? this.DEFAULT_STREAMS_CONFIG.optimizationThreshold;
336
+ const useOptimization = this.isOptimizationActive(data.eventName) || consumerGroups.length > optimizationThreshold;
337
+ // If optimization is needed, generate the optimized ID upfront
338
+ if (useOptimization) {
339
+ const maxStreamsToCheck = Math.max(5, Math.ceil(consumerGroups.length * 0.25));
149
340
  tracker.startRedisOperation();
150
- const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
151
- tracker.endRedisOperation();
152
- tracker.incrementEventCount();
153
- tracker.incrementMessageRate('publish', data.eventName);
154
- if (this.metricsCollector) {
155
- this.metricsCollector.addMetrics(tracker.getMetrics());
341
+ try {
342
+ const result = (await this.executeScript(this.findHighestStreamIdScript, this.findHighestStreamIdScriptSHA, 2, // Two keys
343
+ data.eventName, maxStreamsToCheck.toString(), ...consumerGroups));
344
+ const [highestTimestamp, highestSeq] = result;
345
+ const currentTime = Date.now();
346
+ if (currentTime > highestTimestamp) {
347
+ key = `${currentTime}-0`;
348
+ }
349
+ else {
350
+ key = `${highestTimestamp}-${highestSeq}`;
351
+ }
352
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Using optimized ID generation: ${key}`);
353
+ }
354
+ catch (scriptError) {
355
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in optimized ID generation script, falling back to auto-generated ID:', scriptError);
356
+ key = '*';
357
+ }
358
+ finally {
359
+ tracker.endRedisOperation();
156
360
  }
157
- if (key === '*')
158
- key = generatedKey ?? key;
159
361
  }
160
- await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
161
- await this.circuitBreaker.recordSuccess();
362
+ // Publish to each consumer group
363
+ try {
364
+ for (const consumerGroup of consumerGroups) {
365
+ const streamName = `${data.eventName}:${consumerGroup}`;
366
+ tracker.startRedisOperation();
367
+ try {
368
+ const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
369
+ tracker.endRedisOperation();
370
+ // If using auto-generated ID, capture it for the first stream
371
+ if (key === '*')
372
+ key = generatedKey ?? key;
373
+ // Track successful publish
374
+ publishedGroups.push(consumerGroup);
375
+ }
376
+ catch (error) {
377
+ tracker.endRedisOperation(); // Ensure tracker ends even on error
378
+ // Check if the error is the ID conflict error
379
+ if ((typeof error === 'string' && error.includes('equal or smaller than the target stream top item')) ||
380
+ (error?.message &&
381
+ typeof error.message === 'string' &&
382
+ error.message.includes('equal or smaller than the target stream top item'))) {
383
+ logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: ID conflict detected for ${streamName} (key: ${key}), switching to optimized mode for ${data.eventName} for duration: ${this.config.optimizationDurationMs ?? this.DEFAULT_STREAMS_CONFIG.optimizationDurationMs / 1000}. Consumers: ${consumerGroups.length}`);
384
+ // Delete already published messages if any (using the captured key)
385
+ if (publishedGroups.length > 0) {
386
+ await this.deleteMessageFromStreams(data.eventName, publishedGroups, key);
387
+ publishedGroups.length = 0;
388
+ }
389
+ // Activate optimization for this event type
390
+ this.activateOptimizationFor(data.eventName);
391
+ // Recursively call publish with same data (optimization will be active now)
392
+ // Important: Return the result of the recursive call
393
+ return this.publish(data, multicast);
394
+ }
395
+ else {
396
+ // For other XADD errors, rethrow to trigger the outer catch block
397
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Unhandled XADD error for ${streamName} (key: ${key}): ${error?.message}`);
398
+ throw error;
399
+ }
400
+ }
401
+ // Increment metrics only after successful publish
402
+ tracker.incrementEventCount();
403
+ tracker.incrementMessageRate('publish', data.eventName);
404
+ if (this.metricsCollector) {
405
+ this.metricsCollector.addMetrics(tracker.getMetrics());
406
+ }
407
+ } // End of for loop
408
+ // Notify subscribers only after all groups are successfully published (or retried)
409
+ await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
410
+ await this.circuitBreaker.recordSuccess();
411
+ }
412
+ catch (publishError) {
413
+ // If we failed during the loop (and it wasn't handled by the adaptive logic),
414
+ // try to clean up any messages already sent in this attempt.
415
+ if (publishedGroups.length > 0) {
416
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during multi-group publish for ${data.eventName}, rolling back published messages for key ${key}, publishedMessage: ${JSON.stringify(data)}`);
417
+ await this.deleteMessageFromStreams(data.eventName, publishedGroups, key);
418
+ }
419
+ // Rethrow the error that caused the failure
420
+ throw publishError;
421
+ }
162
422
  }
163
423
  else {
164
424
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
@@ -264,14 +524,14 @@ class Streams {
264
524
  // If both exist, this will be a no-op
265
525
  try {
266
526
  await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
267
- logger_1.PUBLISHER_LOGGER.log(`Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
527
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
268
528
  }
269
529
  catch (e) {
270
530
  // BUSYGROUP error means group already exists, which is fine
271
531
  if (!e.message.includes('BUSYGROUP')) {
272
532
  throw e;
273
533
  }
274
- logger_1.PUBLISHER_LOGGER.log(`Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
534
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
275
535
  }
276
536
  // Create consumer (idempotent operation)
277
537
  const createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
@@ -320,11 +580,11 @@ class Streams {
320
580
  await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
321
581
  }
322
582
  catch (error) {
323
- logger_1.PUBLISHER_LOGGER.error('Error in running recurring cleanup task:', error);
583
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in running recurring cleanup task:', error);
324
584
  }
325
585
  },
326
586
  error: (error) => {
327
- logger_1.PUBLISHER_LOGGER.error('Fatal error in cleanup timer:', error);
587
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Fatal error in cleanup timer:', error);
328
588
  },
329
589
  });
330
590
  // Create observable with proper cleanup
@@ -347,8 +607,8 @@ class Streams {
347
607
  // 3. Pending message reprocessing
348
608
  if (!multicast && messageId !== '0' && !processPending) {
349
609
  try {
350
- const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
351
- if (pendingDetails[2] === 0) {
610
+ const pendingDetails = (await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1));
611
+ if (pendingDetails && pendingDetails.length > 0 && pendingDetails[0] && pendingDetails[0][3] === 0) {
352
612
  logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Message ${messageId} already processed for ${streamName}`);
353
613
  return;
354
614
  }
@@ -371,12 +631,28 @@ class Streams {
371
631
  /**
372
632
  * Very very rare case of this occurring. Cases where the running service is super overloaded
373
633
  * that causes a messaged to be sent to processing with a delay, but eventually before being
374
- * processed gets picked up by another instance, leading to multiple publications
634
+ * processed gets picked up by another instance, leading to multiple publications. This makes
635
+ * sure that there is a check to ensure that the message belongs to this consumer before being
636
+ * processed
375
637
  */
376
638
  if (processPending) {
377
- const claimed = await redisClient.xclaim(streamName, this.consumerGroupName, this.instanceId, 20000, messageId, 'JUSTID');
378
- if (!claimed || claimed.length === 0) {
379
- return; // Message already claimed or acknowledged by another consumer, so don't repush to the subscriber
639
+ const pendingDetails = (await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1));
640
+ /** Message is not in PEL */
641
+ if (!pendingDetails || pendingDetails.length === 0)
642
+ return;
643
+ const [, instanceId, idleTime, deliveryCount] = pendingDetails[0][1];
644
+ /** Message is claimed by another consumer within the group, so do not process */
645
+ if (instanceId !== this.instanceId) {
646
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Process Pending message is claimed by another consumer : ${JSON.stringify({
647
+ instanceId,
648
+ idleTime,
649
+ deliveryCount,
650
+ messageId,
651
+ streamName,
652
+ originalInstanceId: this.instanceId,
653
+ consumerGroupName: this.consumerGroupName,
654
+ })}`);
655
+ return;
380
656
  }
381
657
  }
382
658
  const messages = await redisClient.xrange(streamName, messageId, messageId);
@@ -393,22 +669,30 @@ class Streams {
393
669
  else {
394
670
  const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
395
671
  if (messages?.length) {
396
- if (messageId === '0') {
397
- messageId = messages[0][1][0][0];
398
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${messageId}`);
672
+ const messageIdRead = messages[0][1][0][0];
673
+ if (messageIdRead !== messageId) {
674
+ if (messageId === '0') {
675
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${messageId}`);
676
+ }
677
+ else {
678
+ const [timestamp] = messageIdRead?.split('-').map(Number);
679
+ const [publishedTimestamp] = messageId?.split('-').map(Number);
680
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message queue processing is ${publishedTimestamp > timestamp ? 'ahead' : 'behind'}`);
681
+ }
399
682
  }
683
+ messageId = messageIdRead;
400
684
  try {
401
685
  eventData = JSON.parse(messages[0][1][0][1][1]);
402
686
  }
403
687
  catch (error) {
404
- logger_1.PUBLISHER_LOGGER.error(`JSON parsing failed for message: ${messages[0][1][0][1][1]}`);
688
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: JSON parsing failed for message: ${messages[0][1][0][1][1]}`);
405
689
  return;
406
690
  }
407
691
  }
408
692
  }
409
693
  }
410
694
  catch (error) {
411
- logger_1.PUBLISHER_LOGGER.error('Error retrieving or parsing event data:', error);
695
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error retrieving or parsing event data:', error);
412
696
  return;
413
697
  }
414
698
  finally {
@@ -444,7 +728,7 @@ class Streams {
444
728
  }
445
729
  catch (error) {
446
730
  // Log error but don't fail entire processing
447
- logger_1.PUBLISHER_LOGGER.error(`Error processing subscription ${subId}:`, error);
731
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing subscription ${subId}:`, error);
448
732
  }
449
733
  }));
450
734
  }
@@ -460,7 +744,7 @@ class Streams {
460
744
  tracker.setConsumerLag(this.consumerGroupName, currentTime - eventData.createdAt);
461
745
  }
462
746
  catch (error) {
463
- logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, error);
747
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Processing error for message ${messageId}:`, error);
464
748
  const dlqEvent = {
465
749
  ...eventData,
466
750
  failureReason: error.message,
@@ -588,6 +872,9 @@ class Streams {
588
872
  */
589
873
  async close() {
590
874
  try {
875
+ // Reset optimization state and script SHA
876
+ this.optimizationActiveUntil.clear();
877
+ this.findHighestStreamIdScriptSHA = null;
591
878
  if (this.cleanUpTimer) {
592
879
  clearInterval(this.cleanUpTimer);
593
880
  }
@@ -602,7 +889,7 @@ class Streams {
602
889
  }
603
890
  }
604
891
  catch (error) {
605
- logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
892
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error during cleanup:', error);
606
893
  }
607
894
  }
608
895
  async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
@@ -610,27 +897,34 @@ class Streams {
610
897
  const lastAckKey = `last_ack:${streamName}`;
611
898
  const oneHourAgo = Date.now() - interval;
612
899
  try {
613
- // Get consumer group info to check if consumers are active
614
- const groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
615
- // If no active consumers, leave stream as is
616
- if (!groupInfo || !groupInfo.some((group) => group.consumers > 0)) {
617
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No active consumers for ${streamName}, leaving stream as is`);
618
- return;
619
- }
900
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Started [cleanupAcknowledgedMessages] for ${streamName} and with cleanUpInterval set as ${interval}`);
620
901
  // Get last acknowledged message ID
621
902
  const lastAckId = await this.redisGroups.get(lastAckKey);
903
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: [cleanupAcknowledgedMessages] {lastAckId: ${lastAckId}, lastAckKey: ${lastAckKey}} `);
622
904
  if (!lastAckId) {
623
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No acknowledged messages for ${streamName}`);
905
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: [cleanupAcknowledgedMessages]No acknowledged messages for ${streamName}`);
624
906
  return;
625
907
  }
626
908
  // Extract timestamp from message ID
627
909
  const [timestamp] = lastAckId.split('-').map(Number);
628
910
  const cleanupThreshold = Math.min(timestamp, oneHourAgo);
911
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: [cleanupAcknowledgedMessages] {cleanUpThreshold: ${cleanupThreshold}} `);
912
+ // Get consumer group info to check if consumers are active
913
+ const groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName))?.map(([, name, , consumers]) => ({
914
+ name: name,
915
+ consumers: consumers,
916
+ }));
917
+ // If no active consumers, leave stream as is
918
+ if (groupInfo?.length === 0 || !groupInfo?.some((group) => group.consumers > 0)) {
919
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No active consumers for ${streamName}, leaving stream as is`);
920
+ return;
921
+ }
922
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: [cleanupAcknowledgedMessages] XTRIM to be called`);
629
923
  await this.redisGroups.xtrim(streamName, 'MINID', cleanupThreshold);
630
924
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Cleaned up messages before last acknowledged message ${timestamp} from ${streamName}`);
631
925
  }
632
926
  catch (error) {
633
- logger_1.PUBLISHER_LOGGER.error(`Error during cleanup for ${streamName}:`, error);
927
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during cleanup for ${streamName}:`, error);
634
928
  }
635
929
  }
636
930
  async getDiagnosticData(events) {
@@ -759,7 +1053,7 @@ class Streams {
759
1053
  ]);
760
1054
  }
761
1055
  catch (error) {
762
- logger_1.PUBLISHER_LOGGER.error(`Error acknowledging message ${messageId} for ${streamName}:`, error);
1056
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error acknowledging message ${messageId} for ${streamName}:`, error);
763
1057
  throw error;
764
1058
  }
765
1059
  }
@@ -54,6 +54,8 @@ export interface IStreamsConfig {
54
54
  halfOpenStateMaxAttempts: number;
55
55
  maxStoredEvents: number;
56
56
  };
57
+ optimizationDurationMs?: number;
58
+ optimizationThreshold?: number;
57
59
  }
58
60
  export type TEventFilter<T> = (event: EventData<T, string>) => boolean;
59
61
  export interface ISubscription<T, TName extends string = string> {
@@ -12,7 +12,7 @@ exports.decodeScheduledMessage = decodeScheduledMessage;
12
12
  const logger_1 = require("./logger");
13
13
  async function getAllConsumerGroups(eventName, redisConnection) {
14
14
  const consumerGroups = await redisConnection.smembers(`${eventName}`);
15
- return consumerGroups;
15
+ return consumerGroups.sort();
16
16
  }
17
17
  function* getSummaryOnStreamConsumerGroup(redisClient, consumerGroupName, streamName) {
18
18
  const [count, , , consumers] = (yield redisClient.xpending(streamName, consumerGroupName));