@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.
- package/README.md +66 -0
- package/package.json +7 -4
- package/src/lib/monitoring/adapters/prom.js +8 -4
- package/src/lib/monitoring/tracker.js +1 -1
- 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 +342 -48
- package/src/lib/redis/types.d.ts +2 -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,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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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[
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/redis/types.d.ts
CHANGED
|
@@ -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> {
|
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));
|