@jetit/publisher 5.6.3 → 6.0.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.
- package/README.md +68 -0
- package/package.json +7 -5
- package/src/lib/publisher.d.ts +1 -0
- package/src/lib/publisher.js +3 -1
- package/src/lib/redis/scheduler.js +1 -1
- package/src/lib/redis/streams-lite.d.ts +187 -0
- package/src/lib/redis/streams-lite.js +734 -0
- package/src/lib/redis/streams.d.ts +24 -0
- package/src/lib/redis/streams.js +347 -40
- package/src/lib/redis/types.d.ts +3 -0
- package/src/lib/redis/utils.js +1 -1
|
@@ -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
|
/**
|
package/src/lib/redis/streams.js
CHANGED
|
@@ -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,10 @@ 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
|
|
131
|
+
maxPendingTasks: 1000,
|
|
59
132
|
};
|
|
60
133
|
/** Initialise Config properties */
|
|
61
134
|
this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
|
|
@@ -80,6 +153,120 @@ class Streams {
|
|
|
80
153
|
this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
|
|
81
154
|
if (this.config.circuitBreaker.enabled)
|
|
82
155
|
this.setupCircuitBreakerListeners();
|
|
156
|
+
// Load scripts when Redis is available
|
|
157
|
+
this.loadScripts().catch((error) => {
|
|
158
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Failed to load Redis scripts:', error);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Loads Lua scripts into Redis server cache
|
|
163
|
+
*/
|
|
164
|
+
async loadScripts() {
|
|
165
|
+
try {
|
|
166
|
+
// Wait until Redis connection is established (simple check)
|
|
167
|
+
if (!this._redisPublisher) {
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait briefly
|
|
169
|
+
if (!this._redisPublisher) {
|
|
170
|
+
logger_1.PUBLISHER_LOGGER.warn('PUBLISHER: Redis connection not ready for script loading.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const redis = this.redisPublisher;
|
|
175
|
+
// Load the script and store its SHA
|
|
176
|
+
this.findHighestStreamIdScriptSHA = (await redis.script('LOAD', this.findHighestStreamIdScript));
|
|
177
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Successfully loaded stream ID script with SHA: ${this.findHighestStreamIdScriptSHA}`);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error loading Redis scripts:', error);
|
|
181
|
+
// We'll try again later if needed via executeScript fallback
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Executes a cached script, falling back to EVAL if needed
|
|
186
|
+
*/
|
|
187
|
+
async executeScript(script, scriptSHA, numKeys, ...args) {
|
|
188
|
+
try {
|
|
189
|
+
// If we have the SHA, try EVALSHA first
|
|
190
|
+
if (scriptSHA) {
|
|
191
|
+
try {
|
|
192
|
+
// Ensure redisPublisher is available
|
|
193
|
+
if (!this._redisPublisher)
|
|
194
|
+
throw new Error('Redis publisher connection not available');
|
|
195
|
+
return await this.redisPublisher.evalsha(scriptSHA, numKeys, ...args);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// Add type 'any' to error
|
|
199
|
+
// If the script isn't found on the server, fall back to EVAL
|
|
200
|
+
if (error?.message && typeof error.message === 'string' && error.message.includes('NOSCRIPT')) {
|
|
201
|
+
logger_1.PUBLISHER_LOGGER.warn('PUBLISHER: Script not found on Redis server, reloading...');
|
|
202
|
+
if (!this._redisPublisher)
|
|
203
|
+
throw new Error('Redis publisher connection not available for script reload');
|
|
204
|
+
const newSHA = (await this.redisPublisher.script('LOAD', script)); // Cast result to string
|
|
205
|
+
// Update the stored SHA
|
|
206
|
+
if (script === this.findHighestStreamIdScript) {
|
|
207
|
+
this.findHighestStreamIdScriptSHA = newSHA;
|
|
208
|
+
}
|
|
209
|
+
// Retry with EVALSHA
|
|
210
|
+
return await this.redisPublisher.evalsha(newSHA, numKeys, ...args);
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// If we don't have the SHA yet, use EVAL directly
|
|
216
|
+
if (!this._redisPublisher)
|
|
217
|
+
throw new Error('Redis publisher connection not available for EVAL');
|
|
218
|
+
return await this.redisPublisher.eval(script, numKeys, ...args);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
// Add type 'any' to error
|
|
222
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error executing Redis script:', error);
|
|
223
|
+
throw error; // Re-throw the original error
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Checks if optimization mode is active for a given event type.
|
|
228
|
+
* Cleans up expired entries during the check.
|
|
229
|
+
*/
|
|
230
|
+
isOptimizationActive(eventName) {
|
|
231
|
+
const expirationTime = this.optimizationActiveUntil.get(eventName);
|
|
232
|
+
if (!expirationTime)
|
|
233
|
+
return false;
|
|
234
|
+
if (Date.now() > expirationTime) {
|
|
235
|
+
// Clean up expired entry during publish operation
|
|
236
|
+
this.optimizationActiveUntil.delete(eventName);
|
|
237
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Deactivated optimization for ${eventName}`);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Activates optimization mode for a given event type for a configured duration.
|
|
244
|
+
*/
|
|
245
|
+
activateOptimizationFor(eventName) {
|
|
246
|
+
const durationMs = this.config.optimizationDurationMs ?? this.DEFAULT_STREAMS_CONFIG.optimizationDurationMs;
|
|
247
|
+
const expirationTime = Date.now() + durationMs;
|
|
248
|
+
this.optimizationActiveUntil.set(eventName, expirationTime);
|
|
249
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Activated optimization for ${eventName} until ${new Date(expirationTime).toISOString()}`);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Deletes a specific message ID from multiple consumer group streams.
|
|
253
|
+
*/
|
|
254
|
+
async deleteMessageFromStreams(eventName, consumerGroups, messageId) {
|
|
255
|
+
const deletionPromises = consumerGroups.map(async (consumerGroup) => {
|
|
256
|
+
const streamName = `${eventName}:${consumerGroup}`;
|
|
257
|
+
try {
|
|
258
|
+
if (!this._redisPublisher)
|
|
259
|
+
throw new Error('Redis publisher connection not available for XDEL');
|
|
260
|
+
await this.redisPublisher.xdel(streamName, messageId);
|
|
261
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Deleted message ${messageId} from ${streamName} for rollback`);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// Log error but continue trying to delete from other streams
|
|
265
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error deleting message ${messageId} from ${streamName}:`, error);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
// Wait for all deletions to attempt completion
|
|
269
|
+
await Promise.allSettled(deletionPromises);
|
|
83
270
|
}
|
|
84
271
|
setupCircuitBreakerListeners() {
|
|
85
272
|
this.circuitBreaker.on('stateChange', async (newState) => {
|
|
@@ -141,25 +328,98 @@ class Streams {
|
|
|
141
328
|
tracker.startRedisOperation();
|
|
142
329
|
const consumerGroups = await (0, utils_1.getAllConsumerGroups)(data.eventName, this.redisPublisher);
|
|
143
330
|
tracker.endRedisOperation();
|
|
144
|
-
let key = '*';
|
|
331
|
+
let key = '*'; // Default to auto-generated ID
|
|
332
|
+
const publishedGroups = []; // Track successfully published groups for rollback
|
|
145
333
|
if (consumerGroups.length > 0) {
|
|
146
334
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
335
|
+
// Check if optimization is already active or if threshold is met
|
|
336
|
+
const optimizationThreshold = this.config.optimizationThreshold ?? this.DEFAULT_STREAMS_CONFIG.optimizationThreshold;
|
|
337
|
+
const useOptimization = this.isOptimizationActive(data.eventName) || consumerGroups.length > optimizationThreshold;
|
|
338
|
+
// If optimization is needed, generate the optimized ID upfront
|
|
339
|
+
if (useOptimization) {
|
|
340
|
+
const maxStreamsToCheck = Math.max(5, Math.ceil(consumerGroups.length * 0.25));
|
|
150
341
|
tracker.startRedisOperation();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
342
|
+
try {
|
|
343
|
+
const result = (await this.executeScript(this.findHighestStreamIdScript, this.findHighestStreamIdScriptSHA, 2, // Two keys
|
|
344
|
+
data.eventName, maxStreamsToCheck.toString(), ...consumerGroups));
|
|
345
|
+
const [highestTimestamp, highestSeq] = result;
|
|
346
|
+
const currentTime = Date.now();
|
|
347
|
+
if (currentTime > highestTimestamp) {
|
|
348
|
+
key = `${currentTime}-0`;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
key = `${highestTimestamp}-${highestSeq}`;
|
|
352
|
+
}
|
|
353
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Using optimized ID generation: ${key}`);
|
|
354
|
+
}
|
|
355
|
+
catch (scriptError) {
|
|
356
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in optimized ID generation script, falling back to auto-generated ID:', scriptError);
|
|
357
|
+
key = '*';
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
tracker.endRedisOperation();
|
|
157
361
|
}
|
|
158
|
-
if (key === '*')
|
|
159
|
-
key = generatedKey ?? key;
|
|
160
362
|
}
|
|
161
|
-
|
|
162
|
-
|
|
363
|
+
// Publish to each consumer group
|
|
364
|
+
try {
|
|
365
|
+
for (const consumerGroup of consumerGroups) {
|
|
366
|
+
const streamName = `${data.eventName}:${consumerGroup}`;
|
|
367
|
+
tracker.startRedisOperation();
|
|
368
|
+
try {
|
|
369
|
+
const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
|
|
370
|
+
tracker.endRedisOperation();
|
|
371
|
+
// If using auto-generated ID, capture it for the first stream
|
|
372
|
+
if (key === '*')
|
|
373
|
+
key = generatedKey ?? key;
|
|
374
|
+
// Track successful publish
|
|
375
|
+
publishedGroups.push(consumerGroup);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
tracker.endRedisOperation(); // Ensure tracker ends even on error
|
|
379
|
+
// Check if the error is the ID conflict error
|
|
380
|
+
if ((typeof error === 'string' && error.includes('equal or smaller than the target stream top item')) ||
|
|
381
|
+
(error?.message &&
|
|
382
|
+
typeof error.message === 'string' &&
|
|
383
|
+
error.message.includes('equal or smaller than the target stream top item'))) {
|
|
384
|
+
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}`);
|
|
385
|
+
// Delete already published messages if any (using the captured key)
|
|
386
|
+
if (publishedGroups.length > 0) {
|
|
387
|
+
await this.deleteMessageFromStreams(data.eventName, publishedGroups, key);
|
|
388
|
+
publishedGroups.length = 0;
|
|
389
|
+
}
|
|
390
|
+
// Activate optimization for this event type
|
|
391
|
+
this.activateOptimizationFor(data.eventName);
|
|
392
|
+
// Recursively call publish with same data (optimization will be active now)
|
|
393
|
+
// Important: Return the result of the recursive call
|
|
394
|
+
return this.publish(data, multicast);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
// For other XADD errors, rethrow to trigger the outer catch block
|
|
398
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Unhandled XADD error for ${streamName} (key: ${key}): ${error?.message}`);
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Increment metrics only after successful publish
|
|
403
|
+
tracker.incrementEventCount();
|
|
404
|
+
tracker.incrementMessageRate('publish', data.eventName);
|
|
405
|
+
if (this.metricsCollector) {
|
|
406
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
407
|
+
}
|
|
408
|
+
} // End of for loop
|
|
409
|
+
// Notify subscribers only after all groups are successfully published (or retried)
|
|
410
|
+
await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
|
|
411
|
+
await this.circuitBreaker.recordSuccess();
|
|
412
|
+
}
|
|
413
|
+
catch (publishError) {
|
|
414
|
+
// If we failed during the loop (and it wasn't handled by the adaptive logic),
|
|
415
|
+
// try to clean up any messages already sent in this attempt.
|
|
416
|
+
if (publishedGroups.length > 0) {
|
|
417
|
+
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)}`);
|
|
418
|
+
await this.deleteMessageFromStreams(data.eventName, publishedGroups, key);
|
|
419
|
+
}
|
|
420
|
+
// Rethrow the error that caused the failure
|
|
421
|
+
throw publishError;
|
|
422
|
+
}
|
|
163
423
|
}
|
|
164
424
|
else {
|
|
165
425
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
|
|
@@ -314,26 +574,19 @@ class Streams {
|
|
|
314
574
|
if (!isNewSubscription) {
|
|
315
575
|
return bs.asObservable().pipe((0, rxjs_1.skip)(1));
|
|
316
576
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
|
|
322
|
-
}
|
|
323
|
-
catch (error) {
|
|
324
|
-
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in running recurring cleanup task:', error);
|
|
325
|
-
}
|
|
326
|
-
},
|
|
327
|
-
error: (error) => {
|
|
328
|
-
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Fatal error in cleanup timer:', error);
|
|
329
|
-
},
|
|
330
|
-
});
|
|
577
|
+
// 10-second cleanup timer disabled — was re-enabled in v6.0.0 but causes
|
|
578
|
+
// double publishes and triggers unbounded pending retry spawning.
|
|
579
|
+
// Pending retries are driven by Pub/Sub notifications only.
|
|
580
|
+
const timer = { unsubscribe: () => { } };
|
|
331
581
|
// Create observable with proper cleanup
|
|
332
582
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
333
583
|
timer.unsubscribe();
|
|
334
584
|
// Clean up subscription on completion
|
|
335
585
|
this.removeSubscription(eventName, subscriptionId);
|
|
336
586
|
}));
|
|
587
|
+
let activePendingTasks = 0;
|
|
588
|
+
let lastCapLogTime = 0;
|
|
589
|
+
const maxPendingTasks = this.config.maxPendingTasks ?? 1000;
|
|
337
590
|
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
338
591
|
// Skip processing if subscription was removed. This is needed because the processing is independent of the subscription
|
|
339
592
|
if (!this.subscriptions.has(eventName)) {
|
|
@@ -348,8 +601,8 @@ class Streams {
|
|
|
348
601
|
// 3. Pending message reprocessing
|
|
349
602
|
if (!multicast && messageId !== '0' && !processPending) {
|
|
350
603
|
try {
|
|
351
|
-
const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
|
|
352
|
-
if (pendingDetails[
|
|
604
|
+
const pendingDetails = (await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1));
|
|
605
|
+
if (pendingDetails && pendingDetails.length > 0 && pendingDetails[0] && pendingDetails[0][3] === 0) {
|
|
353
606
|
logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Message ${messageId} already processed for ${streamName}`);
|
|
354
607
|
return;
|
|
355
608
|
}
|
|
@@ -372,12 +625,28 @@ class Streams {
|
|
|
372
625
|
/**
|
|
373
626
|
* Very very rare case of this occurring. Cases where the running service is super overloaded
|
|
374
627
|
* 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
|
|
628
|
+
* processed gets picked up by another instance, leading to multiple publications. This makes
|
|
629
|
+
* sure that there is a check to ensure that the message belongs to this consumer before being
|
|
630
|
+
* processed
|
|
376
631
|
*/
|
|
377
632
|
if (processPending) {
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
633
|
+
const pendingDetails = (await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1));
|
|
634
|
+
/** Message is not in PEL */
|
|
635
|
+
if (!pendingDetails || pendingDetails.length === 0)
|
|
636
|
+
return;
|
|
637
|
+
const [, instanceId, idleTime, deliveryCount] = pendingDetails[0][1];
|
|
638
|
+
/** Message is claimed by another consumer within the group, so do not process */
|
|
639
|
+
if (instanceId !== this.instanceId) {
|
|
640
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Process Pending message is claimed by another consumer : ${JSON.stringify({
|
|
641
|
+
instanceId,
|
|
642
|
+
idleTime,
|
|
643
|
+
deliveryCount,
|
|
644
|
+
messageId,
|
|
645
|
+
streamName,
|
|
646
|
+
originalInstanceId: this.instanceId,
|
|
647
|
+
consumerGroupName: this.consumerGroupName,
|
|
648
|
+
})}`);
|
|
649
|
+
return;
|
|
381
650
|
}
|
|
382
651
|
}
|
|
383
652
|
const messages = await redisClient.xrange(streamName, messageId, messageId);
|
|
@@ -511,12 +780,47 @@ class Streams {
|
|
|
511
780
|
unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
|
|
512
781
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
|
|
513
782
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
783
|
+
if (maxPendingTasks > 0) {
|
|
784
|
+
const available = maxPendingTasks - activePendingTasks;
|
|
785
|
+
if (available <= 0) {
|
|
786
|
+
const now = Date.now();
|
|
787
|
+
if (now - lastCapLogTime > 30000) {
|
|
788
|
+
logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Pending task cap reached (${activePendingTasks}/${maxPendingTasks}), skipping batch for ${streamName}`);
|
|
789
|
+
lastCapLogTime = now;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
const batch = unprocessedMessageIds.messageIds.slice(0, available);
|
|
794
|
+
batch.forEach((id, index) => {
|
|
795
|
+
activePendingTasks++;
|
|
796
|
+
void (async () => {
|
|
797
|
+
try {
|
|
798
|
+
await new Promise((resolve) => setTimeout(resolve, index * getDelay(unprocessedMessageIds.countOnThisConsumer ?? 1)));
|
|
799
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in pending retry task for ${id}`, err);
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
activePendingTasks--;
|
|
806
|
+
}
|
|
807
|
+
})();
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
unprocessedMessageIds.messageIds.forEach((id, index) => {
|
|
813
|
+
void (async () => {
|
|
814
|
+
try {
|
|
815
|
+
await new Promise((resolve) => setTimeout(resolve, index * getDelay(unprocessedMessageIds.countOnThisConsumer ?? 1)));
|
|
816
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in pending retry task for ${id}`, err);
|
|
820
|
+
}
|
|
821
|
+
})();
|
|
822
|
+
});
|
|
823
|
+
}
|
|
520
824
|
}
|
|
521
825
|
}
|
|
522
826
|
catch (e) {
|
|
@@ -597,6 +901,9 @@ class Streams {
|
|
|
597
901
|
*/
|
|
598
902
|
async close() {
|
|
599
903
|
try {
|
|
904
|
+
// Reset optimization state and script SHA
|
|
905
|
+
this.optimizationActiveUntil.clear();
|
|
906
|
+
this.findHighestStreamIdScriptSHA = null;
|
|
600
907
|
if (this.cleanUpTimer) {
|
|
601
908
|
clearInterval(this.cleanUpTimer);
|
|
602
909
|
}
|
package/src/lib/redis/types.d.ts
CHANGED
|
@@ -54,6 +54,9 @@ export interface IStreamsConfig {
|
|
|
54
54
|
halfOpenStateMaxAttempts: number;
|
|
55
55
|
maxStoredEvents: number;
|
|
56
56
|
};
|
|
57
|
+
optimizationDurationMs?: number;
|
|
58
|
+
optimizationThreshold?: number;
|
|
59
|
+
maxPendingTasks?: number;
|
|
57
60
|
}
|
|
58
61
|
export type TEventFilter<T> = (event: EventData<T, string>) => boolean;
|
|
59
62
|
export interface ISubscription<T, TName extends string = string> {
|
package/src/lib/redis/utils.js
CHANGED
|
@@ -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));
|