@jetit/publisher 5.6.3 → 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,6 +125,9 @@ 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 };
@@ -80,6 +152,120 @@ class Streams {
80
152
  this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
81
153
  if (this.config.circuitBreaker.enabled)
82
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);
83
269
  }
84
270
  setupCircuitBreakerListeners() {
85
271
  this.circuitBreaker.on('stateChange', async (newState) => {
@@ -141,25 +327,98 @@ class Streams {
141
327
  tracker.startRedisOperation();
142
328
  const consumerGroups = await (0, utils_1.getAllConsumerGroups)(data.eventName, this.redisPublisher);
143
329
  tracker.endRedisOperation();
144
- let key = '*';
330
+ let key = '*'; // Default to auto-generated ID
331
+ const publishedGroups = []; // Track successfully published groups for rollback
145
332
  if (consumerGroups.length > 0) {
146
333
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
147
- for (const consumerGroup of consumerGroups) {
148
- // Publish the event to each consumer group's stream
149
- 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));
150
340
  tracker.startRedisOperation();
151
- const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
152
- tracker.endRedisOperation();
153
- tracker.incrementEventCount();
154
- tracker.incrementMessageRate('publish', data.eventName);
155
- if (this.metricsCollector) {
156
- 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();
157
360
  }
158
- if (key === '*')
159
- key = generatedKey ?? key;
160
361
  }
161
- await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
162
- 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
+ }
163
422
  }
164
423
  else {
165
424
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
@@ -348,8 +607,8 @@ class Streams {
348
607
  // 3. Pending message reprocessing
349
608
  if (!multicast && messageId !== '0' && !processPending) {
350
609
  try {
351
- const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
352
- 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) {
353
612
  logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Message ${messageId} already processed for ${streamName}`);
354
613
  return;
355
614
  }
@@ -372,12 +631,28 @@ class Streams {
372
631
  /**
373
632
  * Very very rare case of this occurring. Cases where the running service is super overloaded
374
633
  * that causes a messaged to be sent to processing with a delay, but eventually before being
375
- * 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
376
637
  */
377
638
  if (processPending) {
378
- const claimed = await redisClient.xclaim(streamName, this.consumerGroupName, this.instanceId, 10000, messageId, 'JUSTID');
379
- if (!claimed || claimed.length === 0) {
380
- 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;
381
656
  }
382
657
  }
383
658
  const messages = await redisClient.xrange(streamName, messageId, messageId);
@@ -597,6 +872,9 @@ class Streams {
597
872
  */
598
873
  async close() {
599
874
  try {
875
+ // Reset optimization state and script SHA
876
+ this.optimizationActiveUntil.clear();
877
+ this.findHighestStreamIdScriptSHA = null;
600
878
  if (this.cleanUpTimer) {
601
879
  clearInterval(this.cleanUpTimer);
602
880
  }
@@ -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));