@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.
@@ -0,0 +1,734 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StreamsLite = void 0;
4
+ const id_1 = require("@jetit/id");
5
+ const rxjs_1 = require("rxjs");
6
+ const collector_1 = require("../monitoring/collector");
7
+ const tracker_1 = require("../monitoring/tracker");
8
+ const circuit_breaker_1 = require("../performance/circuit_breaker");
9
+ const dlq_1 = require("./dlq");
10
+ const duplication_1 = require("./duplication");
11
+ const logger_1 = require("./logger");
12
+ const registry_1 = require("./registry");
13
+ const utils_1 = require("./utils");
14
+ class StreamsLite {
15
+ // Removed Lua script and related properties (findHighestStreamIdScript, findHighestStreamIdScriptSHA, optimizationActiveUntil)
16
+ get redisPublisher() {
17
+ if (!this._redisPublisher)
18
+ this._redisPublisher = registry_1.RedisRegistry.getConnection(this.redisConnectionId, 'publish');
19
+ return this._redisPublisher;
20
+ }
21
+ get redisGroups() {
22
+ if (!this._redisGroups)
23
+ this._redisGroups = registry_1.RedisRegistry.getConnection(this.redisConnectionId, 'groups');
24
+ return this._redisGroups;
25
+ }
26
+ /**
27
+ * Creates a new Streams instance for a given service.
28
+ *
29
+ * The constructor initializes the Redis connections for publishers, subscribers, and consumer groups.
30
+ * It also sets up an interval timer for clearing expired messages from Redis and another interval timer
31
+ * for processing scheduled events at regular intervals.
32
+ *
33
+ * @param serviceName - A unique name for the service that will be using this Streams instance.
34
+ *
35
+ * @example
36
+ *
37
+ * // Create a new Streams instance for the "POS" service
38
+ * const streams = new Streams('POS');
39
+ */
40
+ constructor(serviceName, config = {}, redisConnectionId = 'default') {
41
+ this.redisConnectionId = redisConnectionId;
42
+ this.eventsListened = [];
43
+ this.subscriptions = new Map();
44
+ this.DEFAULT_STREAMS_CONFIG = {
45
+ immediatePublishThreshold: 500,
46
+ unprocessedMessageThreshold: 25,
47
+ acknowledgedMessageCleanupInterval: 60 * 60 * 1000, // 1 hour
48
+ cleanUpInterval: 1000 * 60 * 60,
49
+ dlqEventThreshold: 2000,
50
+ filterKeepAlive: 24 * 60 * 60 * 1000,
51
+ duplicationCheckWindow: 84600,
52
+ circuitBreaker: {
53
+ enabled: true,
54
+ errorThreshold: 50,
55
+ errorThresholdPercentage: 50,
56
+ openStateDuration: 30000,
57
+ halfOpenStateMaxAttempts: 10,
58
+ maxStoredEvents: 10000,
59
+ },
60
+ // New defaults
61
+ optimizationDurationMs: 2 * 60 * 1000, // 2 minutes
62
+ optimizationThreshold: 20, // Enable optimization for >20 consumer groups
63
+ };
64
+ /** Initialise Config properties */
65
+ this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
66
+ this.instanceUniqueId = process.env['INSTANCE_ID'] ?? (0, id_1.generateID)('HEX', 'FE');
67
+ this.instanceId = `${serviceName}:${this.instanceUniqueId}`;
68
+ this.consumerGroupName = `cg-${serviceName}`;
69
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Instance ID: ${this.instanceId} and with config: ${JSON.stringify(this.config)}`);
70
+ const cleanUpInterval = this.config.cleanUpInterval ?? parseInt(process.env['CLEANUP_INTERVAL'] ?? `${this.config.cleanUpInterval}`, 10);
71
+ this.cleanUpTimer = setInterval(() => {
72
+ this.runClear(cleanUpInterval).catch((error) => {
73
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error during cleanup:', error);
74
+ });
75
+ }, cleanUpInterval);
76
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Clean Up process setup for ${cleanUpInterval} ms`);
77
+ this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
78
+ this.metricsCollector = new collector_1.MetricsCollector({
79
+ redisClient: this.redisPublisher,
80
+ collectionInterval: 60000,
81
+ retentionPeriod: 6 * 60 * 60 * 1000,
82
+ }, this.dlq);
83
+ this.duplicateChecker = new duplication_1.ContentBasedDeduplication(this.redisPublisher, this.config.duplicationCheckWindow);
84
+ this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
85
+ if (this.config.circuitBreaker.enabled)
86
+ this.setupCircuitBreakerListeners();
87
+ }
88
+ setupCircuitBreakerListeners() {
89
+ this.circuitBreaker.on('stateChange', async (newState) => {
90
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Circuit breaker state changed to: ${circuit_breaker_1.CircuitState[newState]}`);
91
+ if (newState === circuit_breaker_1.CircuitState.CLOSED) {
92
+ await this.processStoredEvents();
93
+ }
94
+ });
95
+ }
96
+ async runClear(cleanUpInterval) {
97
+ logger_1.PUBLISHER_LOGGER.log('PUBLISHER: Running Clearance', this.eventsListened);
98
+ const cleanupPromises = this.eventsListened.map((eventName) => this.cleanupAcknowledgedMessages(eventName, cleanUpInterval)
99
+ .then(() => {
100
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Cleanup process for Acknowledged messages completed for ${eventName}`);
101
+ })
102
+ .catch((error) => {
103
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during cleanup for ${eventName}:`, error);
104
+ }));
105
+ await Promise.all(cleanupPromises);
106
+ }
107
+ async publish(data, multicast = false) {
108
+ const tracker = new tracker_1.MetricsTracker();
109
+ if (data.eventId)
110
+ data.republishEvent = data.eventId;
111
+ data.eventId = (0, id_1.generateID)('HEX', 'FF');
112
+ if (!data.createdAt)
113
+ data.createdAt = Date.now();
114
+ /**
115
+ * This is a simplified description of the circuit breaker code
116
+ * 1. If the circuit breaker is enabled, then we check if the circuit is closed and messages are allowed to pass
117
+ * 2. If the circuit is open, then the event is stored and an error is logged notifying that the system has recorded
118
+ * a large number of publisher failures (controlled by errorThreshold and errorThresholdPercentage). This emits an
119
+ * event and the time of change of state is recorded. The circuit remains open for 30 seconds, allowing the system to
120
+ * correct itself as much as possible. All events during this time end up in the buffer
121
+ * 3. If the 30s is expired, the system resets to a half open state where new events are allowed to go through and a
122
+ * success state is recorded.
123
+ * 4. In the half open state if there are 10 attempts that succeed, then the circuit goes to closed and all pending
124
+ * messages are published.
125
+ *
126
+ * Here is a pictorial representation
127
+ *
128
+ * [Failure threshold met]
129
+ * CLOSED --------------------> OPEN
130
+ * ^ |
131
+ * | |
132
+ * | [Max success | [Open duration elapsed]
133
+ * | in half-open] |
134
+ * | v
135
+ * -------------------- HALF-OPEN
136
+ *
137
+ * These properties are configurable
138
+ */
139
+ if (this.config.circuitBreaker.enabled && !this.circuitBreaker.isAllowed()) {
140
+ await this.circuitBreaker.storeEvent(data);
141
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Circuit is open, event stored for later processing');
142
+ return 'CIRCUIT_BREAKER_FLOW';
143
+ }
144
+ try {
145
+ const streamName = `sl:${data.eventName}`; // Use sl: prefix
146
+ let key = '*'; // Default to auto-generated ID
147
+ tracker.startRedisOperation();
148
+ try {
149
+ // Publish directly to the single stream
150
+ const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
151
+ tracker.endRedisOperation();
152
+ if (generatedKey === null) {
153
+ // Handle the case where xadd failed unexpectedly
154
+ throw new Error(`XADD command failed to return a message ID for stream ${streamName}`);
155
+ }
156
+ key = generatedKey; // Assign the non-null key
157
+ // Increment metrics
158
+ tracker.incrementEventCount();
159
+ tracker.incrementMessageRate('publish', data.eventName);
160
+ if (this.metricsCollector) {
161
+ this.metricsCollector.addMetrics(tracker.getMetrics());
162
+ }
163
+ // Notify subscribers
164
+ await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
165
+ await this.circuitBreaker.recordSuccess();
166
+ }
167
+ catch (error) {
168
+ tracker.endRedisOperation(); // Ensure tracker ends even on error
169
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error publishing to stream ${streamName}:`, error);
170
+ throw error; // Rethrow the error
171
+ }
172
+ const metrics = tracker.getMetrics();
173
+ this.logPerformance(`PTIME;${key};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
174
+ return key;
175
+ }
176
+ catch (error) {
177
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
178
+ tracker.incrementErrorCount('publish');
179
+ if (this.metricsCollector) {
180
+ this.metricsCollector.addMetrics(tracker.getMetrics());
181
+ }
182
+ await this.circuitBreaker.recordFailure();
183
+ throw new Error('Publisher Error');
184
+ }
185
+ }
186
+ /**
187
+ * Listens for events with the given name and returns an Observable that emits an EventData<T> object
188
+ * each time a new event is received.
189
+ *
190
+ * The method uses a BehaviorSubject to emit the events as Observables. The BehaviorSubject ensures
191
+ * that new subscribers receive the last emitted event, even if they subscribe after the event has been emitted.
192
+ *
193
+ * If an error occurs while subscribing, the method logs the error to the PUBLISHER_LOGGER and throws
194
+ * an error. This is done to prevent the service from continuing without a proper event subscription.
195
+ *
196
+ * There is retry logic with exponential backoff to handle error cases. These are also controllable by the
197
+ * calling service
198
+ *
199
+ * @param eventName - The name of the event to listen for.
200
+ *
201
+ * @returns An Observable that emits an EventData<T> object each time a new event is received.
202
+ *
203
+ * @example
204
+ *
205
+ * // Listen for "order.created" events
206
+ * const orderCreated = streams.listen<OrderCreatedEvent>('order.created');
207
+ *
208
+ * // Subscribe to the Observable and log each new event
209
+ * orderCreated.subscribe((event) => {
210
+ * PUBLISHER_LOGGER.log('New order created:', event.data);
211
+ * });
212
+ */
213
+ listen(eventName, listenerOptions) {
214
+ const options = {
215
+ maxRetries: this.config.maxRetries,
216
+ initialDelay: this.config.initialRetryDelay,
217
+ filterKeepAlive: this.config.filterKeepAlive,
218
+ publishOnceGuarantee: false,
219
+ externalAcknowledgement: false,
220
+ ...listenerOptions,
221
+ };
222
+ const subscriptionId = (0, id_1.generateID)('HEX');
223
+ return this.listenInternals(eventName, subscriptionId, options.eventFilter, options.filterKeepAlive, options.publishOnceGuarantee, options.externalAcknowledgement).pipe((0, rxjs_1.retry)({
224
+ count: options.maxRetries,
225
+ delay: (error, retryAttempt) => {
226
+ const delay = options.initialDelay * Math.pow(2, retryAttempt);
227
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
228
+ return (0, rxjs_1.timer)(delay);
229
+ },
230
+ }), (0, rxjs_1.catchError)((error) => {
231
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${options.maxRetries} retries: ${error.message}`);
232
+ return (0, rxjs_1.throwError)(() => new Error(error.message));
233
+ }), (0, rxjs_1.finalize)(() => {
234
+ this.removeSubscription(eventName, subscriptionId);
235
+ }));
236
+ }
237
+ async createConsumerAndRegister(eventName) {
238
+ const streamName = `sl:${eventName}`; // Use sl: prefix
239
+ const key = `instance:${this.instanceId}:subscribedEvents`;
240
+ const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
241
+ if (!this.eventsListened.includes(eventName)) {
242
+ this.eventsListened.push(eventName);
243
+ }
244
+ try {
245
+ // Try to create the consumer group on the single stream
246
+ try {
247
+ await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
248
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Group ${this.consumerGroupName} created for stream ${streamName}`);
249
+ }
250
+ catch (e) {
251
+ // BUSYGROUP error means group already exists, which is fine
252
+ if (!e.message.includes('BUSYGROUP') || !e.includes('BUSYGROUP')) {
253
+ throw e;
254
+ }
255
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Group ${this.consumerGroupName} already exists for stream ${streamName}`);
256
+ }
257
+ // Create consumer (idempotent operation) - Note: CREATECONSUMER is not needed if group exists
258
+ // const createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
259
+ // Register event and consumer group in parallel
260
+ const [, addToFlushSet] = await Promise.all([
261
+ this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName),
262
+ this.redisGroups.sadd(key, eventName), // Still track subscribed events per instance
263
+ ]);
264
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Consumer ${this.instanceId} registered for group ${this.consumerGroupName} on stream ${streamName} with status ${JSON.stringify({
265
+ addToFlushSet,
266
+ })}`);
267
+ return true;
268
+ }
269
+ catch (error) {
270
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Consumer registration failed for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}:`, error);
271
+ return false;
272
+ }
273
+ }
274
+ listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false, externalAcknowledgement = false) {
275
+ const eventSubscriptions = this.subscriptions.get(eventName) || new Map();
276
+ const isNewSubscription = !this.subscriptions.has(eventName);
277
+ if (isNewSubscription) {
278
+ this.subscriptions.set(eventName, eventSubscriptions);
279
+ }
280
+ const bs = new rxjs_1.BehaviorSubject(null);
281
+ const subscription = {
282
+ subject: bs,
283
+ filter: eventFilter,
284
+ lastMatchTime: Date.now(),
285
+ keepAlive: filterKeepAlive,
286
+ };
287
+ eventSubscriptions.set(subscriptionId, subscription);
288
+ if (!isNewSubscription) {
289
+ return bs.asObservable().pipe((0, rxjs_1.skip)(1));
290
+ }
291
+ const cleanupInterval = 10000;
292
+ const timer = (0, rxjs_1.interval)(cleanupInterval).subscribe({
293
+ next: async () => {
294
+ try {
295
+ await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
296
+ }
297
+ catch (error) {
298
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in running recurring cleanup task:', error);
299
+ }
300
+ },
301
+ error: (error) => {
302
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Fatal error in cleanup timer:', error);
303
+ },
304
+ });
305
+ const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
306
+ timer.unsubscribe();
307
+ this.removeSubscription(eventName, subscriptionId);
308
+ }));
309
+ const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
310
+ // Skip processing if subscription was removed. This is needed because the processing is independent of the subscription
311
+ if (!this.subscriptions.has(eventName)) {
312
+ return;
313
+ }
314
+ const streamName = `sl:${eventName}`; // Use sl: prefix
315
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName} / ${this.consumerGroupName}`);
316
+ try {
317
+ // Removed redundant XPENDING check block
318
+ let eventData;
319
+ tracker.startRedisOperation();
320
+ try {
321
+ /**
322
+ * Both multicast messages and pending messages cannot be read by xreadgroup
323
+ * Multicast messages should not be claimed by a single consumer. And pending messages
324
+ * are usually behind in the stream so XREADGROUP will not read them and hence
325
+ * they need to be read using XRANGE.
326
+ */
327
+ // Simplified condition: XRANGE is only needed for multicast messages now
328
+ if (multicast) {
329
+ // XRANGE is still needed for multicast as XREADGROUP won't deliver the same message to all consumers
330
+ const messages = await redisClient.xrange(streamName, messageId, messageId);
331
+ if (messages?.length) {
332
+ try {
333
+ eventData = JSON.parse(messages[0][1][1]);
334
+ }
335
+ catch (error) {
336
+ logger_1.PUBLISHER_LOGGER.error(`JSON parsing failed for message: ${messages[0][1][1]}`);
337
+ return;
338
+ }
339
+ }
340
+ }
341
+ else {
342
+ // Use XREADGROUP for regular consumer group processing
343
+ const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
344
+ // Handle potential null response from BLOCK timeout
345
+ if (!messages || messages.length === 0) {
346
+ // PUBLISHER_LOGGER.log(`PUBLISHER: No new messages for ${streamName}/${this.consumerGroupName}`);
347
+ return; // No new messages
348
+ }
349
+ if (messages[0]?.[1]?.[0]) {
350
+ // Check structure carefully
351
+ const messageIdRead = messages[0][1][0][0];
352
+ // Log if the message ID read is different from expected (can happen with retries/claims)
353
+ if (messageId !== '0' && messageIdRead !== messageId) {
354
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reading message ${messageIdRead} instead of expected ${messageId} for ${streamName}`);
355
+ }
356
+ messageId = messageIdRead; // Update messageId to the one actually read
357
+ try {
358
+ eventData = JSON.parse(messages[0][1][0][1][1]);
359
+ }
360
+ catch (error) {
361
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: JSON parsing failed for message: ${messages[0][1][0][1][1]}`);
362
+ return;
363
+ }
364
+ }
365
+ }
366
+ }
367
+ catch (error) {
368
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error retrieving or parsing event data:', error);
369
+ return;
370
+ }
371
+ finally {
372
+ tracker.endRedisOperation();
373
+ }
374
+ tracker.startProcessing();
375
+ if (eventData) {
376
+ if (publishOnceGuarantee) {
377
+ if (await this.duplicateChecker.isDuplicate(eventData, this.consumerGroupName)) {
378
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Duplicate event detected, skipping processing for consumer group ${this.consumerGroupName}`);
379
+ tracker.incrementDuplicateEvent();
380
+ await redisClient.xack(streamName, this.consumerGroupName, messageId);
381
+ return;
382
+ }
383
+ }
384
+ try {
385
+ const ackKey = this.frameMessageKey(streamName, messageId);
386
+ const subscriptions = this.subscriptions.get(eventName);
387
+ if (subscriptions) {
388
+ const currentTime = Date.now();
389
+ const subscriptionEntries = Array.from(subscriptions.entries());
390
+ // Process subscriptions in parallel for better performance
391
+ await Promise.all(subscriptionEntries.map(async ([subId, sub]) => {
392
+ try {
393
+ if (!sub.filter || sub.filter(eventData)) {
394
+ sub.subject.next({ ...eventData, ackKey });
395
+ sub.lastMatchTime = currentTime;
396
+ }
397
+ else if (currentTime - sub.lastMatchTime > sub.keepAlive) {
398
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
399
+ sub.lastMatchTime = currentTime;
400
+ }
401
+ }
402
+ catch (error) {
403
+ // Log error but don't fail entire processing
404
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing subscription ${subId}:`, error);
405
+ }
406
+ }));
407
+ }
408
+ // Acknowledge message if needed
409
+ if (!externalAcknowledgement) {
410
+ await this.acknowledgeMessage(ackKey);
411
+ }
412
+ // Update metrics
413
+ const currentTime = Date.now();
414
+ tracker.incrementMessageRate('subscribe', eventData.eventName);
415
+ const processingTime = currentTime - eventData.createdAt;
416
+ tracker.addProcessingTime(processingTime);
417
+ tracker.setConsumerLag(this.consumerGroupName, currentTime - eventData.createdAt);
418
+ }
419
+ catch (error) {
420
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Processing error for message ${messageId}:`, error);
421
+ const dlqEvent = {
422
+ ...eventData,
423
+ failureReason: error.message,
424
+ retryCount: (eventData.retryCount || 0) + 1,
425
+ originalStream: streamName,
426
+ consumerGroupName: this.consumerGroupName,
427
+ timestamp: Date.now(),
428
+ };
429
+ await this.dlq.addToDLQ(dlqEvent);
430
+ // Don't rethrow to prevent message loss
431
+ }
432
+ }
433
+ else {
434
+ // This case might happen if XRANGE was used for a multicast message that was deleted before reading
435
+ // Or if XREADGROUP returned an empty message array unexpectedly
436
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No event data found for message ${messageId} in ${streamName}`);
437
+ }
438
+ tracker.endProcessing();
439
+ // Ensure custom pending message processing logic is fully removed
440
+ }
441
+ catch (e) {
442
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing message ${messageId} for ${streamName}/${this.consumerGroupName}`, e);
443
+ if (!multicast) {
444
+ const dlqEvent = {
445
+ eventId: messageId,
446
+ eventName: eventName, // Use base event name
447
+ data: {}, // Consider adding actual data if available
448
+ failureReason: e.message,
449
+ retryCount: 1,
450
+ originalStream: streamName, // Keep full stream name
451
+ timestamp: Date.now(),
452
+ consumerGroupName: this.consumerGroupName,
453
+ };
454
+ await this.dlq.addToDLQ(dlqEvent);
455
+ tracker.incrementErrorCount('subscribe');
456
+ if (this.metricsCollector) {
457
+ this.metricsCollector.addMetrics(tracker.getMetrics());
458
+ }
459
+ }
460
+ }
461
+ };
462
+ /** Register the consumer and setup the Observable */
463
+ this.createConsumerAndRegister(eventName)
464
+ .then((consumerRegistered) => {
465
+ if (!consumerRegistered)
466
+ throw new Error('PUBLISHER: Cannot setup consumer');
467
+ const eventStreamClient = registry_1.RedisRegistry.getConnection(this.redisConnectionId, `sub-${eventName}`);
468
+ eventStreamClient.subscribe(eventName).then(() => {
469
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Redis Subscription connection initiated for ${eventName}`);
470
+ });
471
+ eventStreamClient.on('message', async (channel, data) => {
472
+ const tracker = new tracker_1.MetricsTracker();
473
+ let messageIdRead, multicastRead;
474
+ try {
475
+ const { messageId, multicast } = JSON.parse(data);
476
+ messageIdRead = messageId;
477
+ multicastRead = multicast;
478
+ }
479
+ catch (e) {
480
+ messageIdRead = data;
481
+ multicastRead = false;
482
+ }
483
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream Notification Received for event ${eventName} with message ID ${messageIdRead}`);
484
+ await processMessage(this.redisGroups, messageIdRead, tracker, multicastRead);
485
+ const metrics = tracker.getMetrics();
486
+ logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
487
+ if (this.metricsCollector) {
488
+ this.metricsCollector.addMetrics(tracker.getMetrics());
489
+ }
490
+ });
491
+ })
492
+ .catch((e) => {
493
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during consumer registration for ${eventName}`, e);
494
+ });
495
+ return observable;
496
+ }
497
+ /**
498
+ * This method allows the possibility of a graceful shutdown by cleaning up the
499
+ * redis connections.
500
+ *
501
+ * In all services where the library is used, its better to implement this method
502
+ *
503
+ * process.on('SIGTERM', shutdown);
504
+ * process.on('SIGINT', shutdown);
505
+ *
506
+ * async function shutdown(): Promise<void> {
507
+ * PUBLISHER_LOGGER.log('Graceful shutdown initiated.');
508
+ * try {
509
+ * await streams.close();
510
+ * PUBLISHER_LOGGER.log('Resources and connections successfully closed.');
511
+ * } catch (error) {
512
+ * PUBLISHER_LOGGER.error('Error during graceful shutdown:', error);
513
+ * }
514
+ * process.exit(0);
515
+ * }
516
+ */
517
+ async close() {
518
+ try {
519
+ // Removed reset of optimization state and script SHA
520
+ if (this.cleanUpTimer) {
521
+ clearInterval(this.cleanUpTimer);
522
+ }
523
+ if (this._redisPublisher) {
524
+ await this._redisPublisher.quit();
525
+ }
526
+ for (const eventName of this.eventsListened) {
527
+ await registry_1.RedisRegistry.getConnection(this.redisConnectionId, `sub-${eventName}`).quit();
528
+ }
529
+ if (this._redisGroups) {
530
+ await this._redisGroups.quit();
531
+ }
532
+ }
533
+ catch (error) {
534
+ logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error during cleanup:', error);
535
+ }
536
+ }
537
+ // Updated cleanup logic
538
+ async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
539
+ const streamName = `sl:${eventName}`; // Use sl: prefix
540
+ const twentyFourHoursAgoTimestamp = Date.now() - 24 * 60 * 60 * 1000;
541
+ let minLastAckTimestamp = Date.now(); // Default to now if no groups/acks found
542
+ try {
543
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Started cleanup for stream ${streamName}`);
544
+ // 1. Get all consumer groups for the base event name
545
+ const consumerGroups = await (0, utils_1.getAllConsumerGroups)(eventName, this.redisPublisher); // Use base event name to find groups
546
+ if (!consumerGroups || consumerGroups.length === 0) {
547
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No consumer groups found for event ${eventName}, skipping cleanup for ${streamName}`);
548
+ // Optionally, still trim based on 24 hours if desired, even without consumers
549
+ // await this.redisGroups.xtrim(streamName, 'MINID', twentyFourHoursAgoTimestamp);
550
+ return;
551
+ }
552
+ // 2. Get the last acknowledged ID for each consumer group on this stream
553
+ const lastAckPromises = consumerGroups.map(async (cg) => {
554
+ const lastAckKey = `last_ack:${streamName}:${cg}`; // Key includes stream and group
555
+ const lastAckId = await this.redisGroups.get(lastAckKey);
556
+ if (lastAckId) {
557
+ const [timestamp] = lastAckId.split('-').map(Number);
558
+ return timestamp;
559
+ }
560
+ return null; // Return null if no ack ID found for this group
561
+ });
562
+ const lastAckTimestamps = (await Promise.all(lastAckPromises)).filter((ts) => ts !== null);
563
+ // 3. Find the minimum last acknowledged timestamp across all groups
564
+ if (lastAckTimestamps.length > 0) {
565
+ minLastAckTimestamp = Math.min(...lastAckTimestamps);
566
+ }
567
+ else {
568
+ // If no groups have acknowledged anything, maybe default to 24 hours ago?
569
+ // Or keep minLastAckTimestamp as Date.now() to effectively only use the 24h rule.
570
+ // Let's default to 24h ago if no acks found, to allow trimming old messages.
571
+ minLastAckTimestamp = twentyFourHoursAgoTimestamp;
572
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No acknowledged messages found for any group on ${streamName}, using 24h threshold.`);
573
+ }
574
+ // 4. Determine the final cleanup threshold
575
+ const cleanupThreshold = Math.max(twentyFourHoursAgoTimestamp, minLastAckTimestamp);
576
+ const cleanupThresholdDate = new Date(cleanupThreshold).toISOString();
577
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Determined cleanup threshold for ${streamName}: ${cleanupThreshold} (${cleanupThresholdDate}) (minAck: ${new Date(minLastAckTimestamp).toISOString()}, 24hAgo: ${new Date(twentyFourHoursAgoTimestamp).toISOString()})`);
578
+ // 5. Perform XTRIM
579
+ // Check if stream exists before trimming (optional, XTRIM handles non-existent streams gracefully)
580
+ const streamExists = await this.redisGroups.exists(streamName);
581
+ if (streamExists) {
582
+ await this.redisGroups.xtrim(streamName, 'MINID', cleanupThreshold);
583
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Performed XTRIM on ${streamName} with MINID ${cleanupThreshold}`);
584
+ }
585
+ else {
586
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream ${streamName} does not exist, skipping XTRIM.`);
587
+ }
588
+ }
589
+ catch (error) {
590
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during cleanup for ${streamName}:`, error);
591
+ }
592
+ }
593
+ async getDiagnosticData(events) {
594
+ if (events.length > 100) {
595
+ return { status: 'ERROR', message: 'Please pass in a maximum of 100 elements to fetch diagnostics' };
596
+ }
597
+ const tempPromises = events.map(async (baseEventName) => {
598
+ const streamName = `sl:${baseEventName}`; // Use sl: prefix
599
+ const consumerGroups = await (0, utils_1.getAllConsumerGroups)(baseEventName, this.redisPublisher); // Get groups based on base name
600
+ const consumerGroupMap = await Promise.all(consumerGroups.map(async (consumerGroup) => {
601
+ // Query the single stream for this specific consumer group
602
+ const diagnostics = await (0, utils_1.getSummaryOnStreamConsumerGroup)(this.redisGroups, consumerGroup, streamName);
603
+ return { consumerGroup, diagnostics };
604
+ }));
605
+ return { eventName: baseEventName, streamName, consumerGroupMap }; // Include stream name in output
606
+ });
607
+ const returnData = await Promise.all(tempPromises);
608
+ return { status: 'SUCCESS', data: returnData, message: 'We recommend not running this in times of heavy load' };
609
+ }
610
+ logPerformance(message) {
611
+ if (this.config.performanceLogger) {
612
+ this.config.performanceLogger(message);
613
+ }
614
+ else {
615
+ logger_1.PERFORMANCE_LOGGER.log(message);
616
+ }
617
+ }
618
+ /**
619
+ * @description
620
+ * This method is use to retry an event that has ended in the dead letter queue,
621
+ * which happens after the first retry.
622
+ */
623
+ async retryFromDLQ(eventId) {
624
+ return this.dlq.retryFromDLQ(eventId);
625
+ }
626
+ /**
627
+ * @description
628
+ * This returns the number of items and the rate at which events are added
629
+ * to the queue. The queue is global and hence remains as is
630
+ */
631
+ async getDLQStats() {
632
+ return this.dlq.getDLQStats();
633
+ }
634
+ removeSubscription(eventName, subscriptionId) {
635
+ const eventSubscriptions = this.subscriptions.get(eventName);
636
+ if (eventSubscriptions) {
637
+ eventSubscriptions.delete(subscriptionId);
638
+ if (eventSubscriptions.size === 0) {
639
+ this.subscriptions.delete(eventName);
640
+ }
641
+ }
642
+ }
643
+ /**
644
+ * @description
645
+ * This is a simple helper utility that can be used externally to create alerts based
646
+ * on thresholds that can be provided into the function. It returns true/false for each
647
+ * key that is provided. Not all keys are required
648
+ */
649
+ async checkThresholds(thresholds) {
650
+ if (!this.metricsCollector) {
651
+ throw new Error('Metrics collection is not enabled');
652
+ }
653
+ const latestMetrics = await this.metricsCollector.getLatestMetrics();
654
+ if (!latestMetrics) {
655
+ throw new Error('No metrics available');
656
+ }
657
+ const alerts = {};
658
+ for (const [key, threshold] of Object.entries(thresholds)) {
659
+ if (key in latestMetrics) {
660
+ alerts[key] = latestMetrics[key] > threshold;
661
+ }
662
+ }
663
+ return alerts;
664
+ }
665
+ /**
666
+ * @description
667
+ * This will return you the stats of the publisher for the last 6 hours after cleaning
668
+ */
669
+ async getMetrics(startTime, endTime) {
670
+ return this.metricsCollector.getMetrics(startTime, endTime);
671
+ }
672
+ /**
673
+ * @description
674
+ * This will return you the latest stats of the publisher
675
+ */
676
+ async getLatestMetrics() {
677
+ return this.metricsCollector.getLatestMetrics();
678
+ }
679
+ /**
680
+ * @description
681
+ * This returns the status of the performance control setup. This includes
682
+ * the circuit breaker
683
+ */
684
+ async getPerformanceControlStatus() {
685
+ const circuitBreakerState = this.circuitBreaker.getState();
686
+ return {
687
+ circuitBreakerState,
688
+ };
689
+ }
690
+ /**
691
+ * @description
692
+ * This is a manual control to process stored events in case the
693
+ * circuit is OPEN
694
+ */
695
+ async processStoredEvents() {
696
+ if (this.circuitBreaker.getState() === circuit_breaker_1.CircuitState.CLOSED) {
697
+ const storedEvents = await this.circuitBreaker.getStoredEvents();
698
+ for (const event of storedEvents) {
699
+ await this.publish(event);
700
+ }
701
+ await this.circuitBreaker.clearStoredEvents();
702
+ }
703
+ }
704
+ /**
705
+ * Acknowledges a message and updates the last acknowledged message ID for the specific consumer group.
706
+ * This is used to track cleanup progress and ensure we don't delete unprocessed messages.
707
+ */
708
+ async acknowledgeMessage(ackKey) {
709
+ const { streamName, messageId } = this.demergeMessageKey(ackKey);
710
+ // Construct the specific last_ack key for this stream and consumer group
711
+ const lastAckKey = `last_ack:${streamName}:${this.consumerGroupName}`;
712
+ const validMessageId = messageId === '0' ? '0-0' : messageId; // Ensure valid format if '0'
713
+ try {
714
+ // Update last acknowledged ID and acknowledge message atomically
715
+ await Promise.all([
716
+ this.redisGroups.xack(streamName, this.consumerGroupName, validMessageId),
717
+ this.redisGroups.set(lastAckKey, validMessageId), // Use the specific key
718
+ ]);
719
+ }
720
+ catch (error) {
721
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error acknowledging message ${validMessageId} for ${streamName}/${this.consumerGroupName}:`, error);
722
+ throw error;
723
+ }
724
+ }
725
+ // Keep framing/demerging simple for now, assuming streamName includes sl: prefix
726
+ frameMessageKey(streamName, messageId) {
727
+ return `${streamName}##${messageId}`;
728
+ }
729
+ demergeMessageKey(messageKey) {
730
+ const [streamName, messageId] = messageKey.split('##');
731
+ return { streamName, messageId };
732
+ }
733
+ }
734
+ exports.StreamsLite = StreamsLite;