@jetit/publisher 5.3.2 → 5.4.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/package.json +2 -2
- package/src/lib/redis/streams.d.ts +4 -0
- package/src/lib/redis/streams.js +142 -91
package/package.json
CHANGED
|
@@ -200,6 +200,10 @@ export declare class Streams {
|
|
|
200
200
|
* circuit is OPEN
|
|
201
201
|
*/
|
|
202
202
|
processStoredEvents(): Promise<void>;
|
|
203
|
+
/**
|
|
204
|
+
* Acknowledges a message and updates the last acknowledged message ID.
|
|
205
|
+
* This is used to track cleanup progress and ensure we don't delete unprocessed messages.
|
|
206
|
+
*/
|
|
203
207
|
acknowledgeMessage(ackKey: string): Promise<void>;
|
|
204
208
|
private frameMessageKey;
|
|
205
209
|
private demergeMessageKey;
|
package/src/lib/redis/streams.js
CHANGED
|
@@ -293,27 +293,51 @@ class Streams {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false, externalAcknowledgement = false) {
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
// Get or create subscription map for this event
|
|
297
|
+
const eventSubscriptions = this.subscriptions.get(eventName) || new Map();
|
|
298
|
+
const isNewSubscription = !this.subscriptions.has(eventName);
|
|
299
|
+
if (isNewSubscription) {
|
|
300
|
+
this.subscriptions.set(eventName, eventSubscriptions);
|
|
298
301
|
}
|
|
299
302
|
const bs = new rxjs_1.BehaviorSubject(null);
|
|
303
|
+
// Making the subscription Immutable
|
|
300
304
|
const subscription = {
|
|
301
305
|
subject: bs,
|
|
302
306
|
filter: eventFilter,
|
|
303
307
|
lastMatchTime: Date.now(),
|
|
304
308
|
keepAlive: filterKeepAlive,
|
|
305
309
|
};
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
+
eventSubscriptions.set(subscriptionId, subscription);
|
|
311
|
+
// Return early if not first subscription
|
|
312
|
+
if (!isNewSubscription) {
|
|
313
|
+
return bs.asObservable().pipe((0, rxjs_1.skip)(1));
|
|
314
|
+
}
|
|
315
|
+
const cleanupInterval = 10000; // 10 seconds
|
|
316
|
+
const timer = (0, rxjs_1.interval)(cleanupInterval).subscribe({
|
|
317
|
+
next: async () => {
|
|
318
|
+
try {
|
|
319
|
+
await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
logger_1.PUBLISHER_LOGGER.error('Error in running recurring cleanup task:', error);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
error: (error) => {
|
|
326
|
+
logger_1.PUBLISHER_LOGGER.error('Fatal error in cleanup timer:', error);
|
|
327
|
+
},
|
|
310
328
|
});
|
|
329
|
+
// Create observable with proper cleanup
|
|
311
330
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
312
|
-
/** Cleanup timer */
|
|
313
331
|
timer.unsubscribe();
|
|
332
|
+
// Clean up subscription on completion
|
|
333
|
+
this.removeSubscription(eventName, subscriptionId);
|
|
314
334
|
}));
|
|
315
|
-
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
316
335
|
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
336
|
+
// Skip processing if subscription was removed. This is needed because the processing is independent of the subscription
|
|
337
|
+
if (!this.subscriptions.has(eventName)) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
317
341
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName}`);
|
|
318
342
|
try {
|
|
319
343
|
// Skip XPENDING check for:
|
|
@@ -334,35 +358,50 @@ class Streams {
|
|
|
334
358
|
}
|
|
335
359
|
}
|
|
336
360
|
let eventData;
|
|
337
|
-
/**
|
|
338
|
-
* Both multicast messages and pending messages cannot be read by xreadgroup
|
|
339
|
-
* Multicast messages should not be claimed by a single consumer. And pending messages
|
|
340
|
-
* are usually behind in the stream so XREADGROUP will not read them and hence
|
|
341
|
-
* they need to be read using XRANGE.
|
|
342
|
-
*/
|
|
343
361
|
tracker.startRedisOperation();
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
try {
|
|
363
|
+
/**
|
|
364
|
+
* Both multicast messages and pending messages cannot be read by xreadgroup
|
|
365
|
+
* Multicast messages should not be claimed by a single consumer. And pending messages
|
|
366
|
+
* are usually behind in the stream so XREADGROUP will not read them and hence
|
|
367
|
+
* they need to be read using XRANGE.
|
|
368
|
+
*/
|
|
369
|
+
if (multicast || processPending) {
|
|
370
|
+
const messages = await redisClient.xrange(streamName, messageId, messageId);
|
|
371
|
+
if (messages?.length) {
|
|
372
|
+
try {
|
|
373
|
+
eventData = JSON.parse(messages[0][1][1]);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
logger_1.PUBLISHER_LOGGER.error(`JSON parsing failed for message: ${messages[0][1][1]}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
352
379
|
}
|
|
353
380
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
381
|
+
else {
|
|
382
|
+
const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
|
|
383
|
+
if (messages?.length) {
|
|
384
|
+
if (messageId === '0') {
|
|
385
|
+
messageId = messages[0][1][0][0];
|
|
386
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${messageId}`);
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
eventData = JSON.parse(messages[0][1][0][1][1]);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
logger_1.PUBLISHER_LOGGER.error(`JSON parsing failed for message: ${messages[0][1][0][1][1]}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
361
395
|
}
|
|
362
|
-
eventData = JSON.parse(messages[0][1][0][1][1]);
|
|
363
396
|
}
|
|
364
397
|
}
|
|
365
|
-
|
|
398
|
+
catch (error) {
|
|
399
|
+
logger_1.PUBLISHER_LOGGER.error('Error retrieving or parsing event data:', error);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
tracker.endRedisOperation();
|
|
404
|
+
}
|
|
366
405
|
tracker.startProcessing();
|
|
367
406
|
if (eventData) {
|
|
368
407
|
if (publishOnceGuarantee) {
|
|
@@ -377,61 +416,68 @@ class Streams {
|
|
|
377
416
|
const ackKey = this.frameMessageKey(streamName, messageId);
|
|
378
417
|
const subscriptions = this.subscriptions.get(eventName);
|
|
379
418
|
if (subscriptions) {
|
|
419
|
+
const currentTime = Date.now();
|
|
380
420
|
const subscriptionEntries = Array.from(subscriptions.entries());
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
sub.
|
|
385
|
-
|
|
421
|
+
// Process subscriptions in parallel for better performance
|
|
422
|
+
await Promise.all(subscriptionEntries.map(async ([subId, sub]) => {
|
|
423
|
+
try {
|
|
424
|
+
if (!sub.filter || sub.filter(eventData)) {
|
|
425
|
+
sub.subject.next({ ...eventData, ackKey });
|
|
426
|
+
sub.lastMatchTime = currentTime;
|
|
427
|
+
}
|
|
428
|
+
else if (currentTime - sub.lastMatchTime > sub.keepAlive) {
|
|
429
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
|
|
430
|
+
sub.lastMatchTime = currentTime;
|
|
431
|
+
}
|
|
386
432
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
* log the data. Should add functionality to remove the filter
|
|
391
|
-
* if its not used at all to gain minor improvements in
|
|
392
|
-
* performace
|
|
393
|
-
*/
|
|
394
|
-
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
|
|
395
|
-
sub.lastMatchTime = Date.now();
|
|
433
|
+
catch (error) {
|
|
434
|
+
// Log error but don't fail entire processing
|
|
435
|
+
logger_1.PUBLISHER_LOGGER.error(`Error processing subscription ${subId}:`, error);
|
|
396
436
|
}
|
|
397
|
-
}
|
|
437
|
+
}));
|
|
398
438
|
}
|
|
399
|
-
if
|
|
439
|
+
// Acknowledge message if needed
|
|
440
|
+
if (!externalAcknowledgement) {
|
|
400
441
|
await this.acknowledgeMessage(ackKey);
|
|
442
|
+
}
|
|
443
|
+
// Update metrics
|
|
444
|
+
const currentTime = Date.now();
|
|
445
|
+
tracker.incrementMessageRate('subscribe', eventData.eventName);
|
|
446
|
+
const processingTime = currentTime - eventData.createdAt;
|
|
447
|
+
tracker.addProcessingTime(processingTime);
|
|
448
|
+
tracker.setConsumerLag(this.consumerGroupName, currentTime - eventData.createdAt);
|
|
401
449
|
}
|
|
402
|
-
catch (
|
|
403
|
-
logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`,
|
|
450
|
+
catch (error) {
|
|
451
|
+
logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, error);
|
|
404
452
|
const dlqEvent = {
|
|
405
453
|
...eventData,
|
|
406
|
-
failureReason:
|
|
454
|
+
failureReason: error.message,
|
|
407
455
|
retryCount: (eventData.retryCount || 0) + 1,
|
|
408
456
|
originalStream: streamName,
|
|
409
457
|
consumerGroupName: this.consumerGroupName,
|
|
410
458
|
timestamp: Date.now(),
|
|
411
459
|
};
|
|
412
460
|
await this.dlq.addToDLQ(dlqEvent);
|
|
461
|
+
// Don't rethrow to prevent message loss
|
|
413
462
|
}
|
|
414
|
-
tracker.incrementMessageRate('subscribe', eventData.eventName);
|
|
415
|
-
const processingTime = Date.now() - eventData.createdAt;
|
|
416
|
-
tracker.addProcessingTime(processingTime);
|
|
417
|
-
const lag = Date.now() - eventData.createdAt;
|
|
418
|
-
tracker.setConsumerLag(this.consumerGroupName, lag);
|
|
419
463
|
}
|
|
420
464
|
else {
|
|
421
465
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message ${messageId} not found for ${streamName}`);
|
|
422
466
|
}
|
|
423
467
|
tracker.endProcessing();
|
|
424
|
-
/** Process Unprocessed
|
|
468
|
+
/** Process Unprocessed Messages with rate limiting */
|
|
425
469
|
if (!processPending) {
|
|
426
470
|
const unprocessedMessageIds = await (0, utils_1.getUnacknowledgedMessages)(redisClient, this.consumerGroupName, streamName, this.instanceId);
|
|
427
471
|
if (unprocessedMessageIds.countOnThisConsumer &&
|
|
428
472
|
unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
|
|
429
473
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
|
|
430
474
|
}
|
|
431
|
-
|
|
432
|
-
|
|
475
|
+
// Process messages with rate limiting
|
|
476
|
+
const processWithDelay = async (id, index) => {
|
|
477
|
+
await new Promise((resolve) => setTimeout(resolve, index * 20));
|
|
433
478
|
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
434
|
-
}
|
|
479
|
+
};
|
|
480
|
+
unprocessedMessageIds.messageIds.map((id, index) => processWithDelay(id, index));
|
|
435
481
|
}
|
|
436
482
|
}
|
|
437
483
|
catch (e) {
|
|
@@ -531,38 +577,27 @@ class Streams {
|
|
|
531
577
|
}
|
|
532
578
|
async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
|
|
533
579
|
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
534
|
-
const
|
|
535
|
-
const
|
|
580
|
+
const lastAckKey = `last_ack:${streamName}`;
|
|
581
|
+
const oneHourAgo = Date.now() - interval;
|
|
536
582
|
try {
|
|
537
|
-
// Get
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
// If there are pending messages, we need to check each message
|
|
550
|
-
const pendingDetails = (await this.redisGroups.xpending(streamName, this.consumerGroupName, lastId, '+', CHUNK_SIZE, this.instanceId));
|
|
551
|
-
const pendingIds = new Set(pendingDetails.map((detail) => detail[0]));
|
|
552
|
-
const acknowledgedIds = messages.map((msg) => msg[0]).filter((id) => !pendingIds.has(id));
|
|
553
|
-
if (acknowledgedIds.length > 0) {
|
|
554
|
-
await this.redisGroups.xdel(streamName, ...acknowledgedIds);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
else {
|
|
558
|
-
// If no pending messages, we can safely delete all messages in this chunk
|
|
559
|
-
const messageIds = messages.map((msg) => msg[0]);
|
|
560
|
-
await this.redisGroups.xdel(streamName, ...messageIds);
|
|
561
|
-
}
|
|
562
|
-
// If we got less than CHUNK_SIZE messages, we've reached the end
|
|
563
|
-
if (messages.length < CHUNK_SIZE)
|
|
564
|
-
break;
|
|
583
|
+
// Get consumer group info to check if consumers are active
|
|
584
|
+
const groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
|
|
585
|
+
// If no active consumers, leave stream as is
|
|
586
|
+
if (!groupInfo || !groupInfo.some((group) => group.consumers > 0)) {
|
|
587
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No active consumers for ${streamName}, leaving stream as is`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// Get last acknowledged message ID
|
|
591
|
+
const lastAckId = await this.redisGroups.get(lastAckKey);
|
|
592
|
+
if (!lastAckId) {
|
|
593
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No acknowledged messages for ${streamName}`);
|
|
594
|
+
return;
|
|
565
595
|
}
|
|
596
|
+
// Extract timestamp from message ID
|
|
597
|
+
const [timestamp] = lastAckId.split('-').map(Number);
|
|
598
|
+
const cleanupThreshold = Math.min(timestamp, oneHourAgo);
|
|
599
|
+
await this.redisGroups.xtrim(streamName, 'MINID', cleanupThreshold);
|
|
600
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Cleaned up messages before last acknowledged message ${timestamp} from ${streamName}`);
|
|
566
601
|
}
|
|
567
602
|
catch (error) {
|
|
568
603
|
logger_1.PUBLISHER_LOGGER.error(`Error during cleanup for ${streamName}:`, error);
|
|
@@ -678,9 +713,25 @@ class Streams {
|
|
|
678
713
|
await this.circuitBreaker.clearStoredEvents();
|
|
679
714
|
}
|
|
680
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Acknowledges a message and updates the last acknowledged message ID.
|
|
718
|
+
* This is used to track cleanup progress and ensure we don't delete unprocessed messages.
|
|
719
|
+
*/
|
|
681
720
|
async acknowledgeMessage(ackKey) {
|
|
682
|
-
|
|
683
|
-
|
|
721
|
+
let { streamName, messageId } = this.demergeMessageKey(ackKey);
|
|
722
|
+
const lastAckKey = `last_ack:${streamName}`;
|
|
723
|
+
messageId == '0' ? '0-0' : messageId;
|
|
724
|
+
try {
|
|
725
|
+
// Update last acknowledged ID and acknowledge message atomically
|
|
726
|
+
await Promise.all([
|
|
727
|
+
this.redisGroups.xack(streamName, this.consumerGroupName, messageId),
|
|
728
|
+
this.redisGroups.set(lastAckKey, messageId.toString()),
|
|
729
|
+
]);
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
logger_1.PUBLISHER_LOGGER.error(`Error acknowledging message ${messageId} for ${streamName}:`, error);
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
684
735
|
}
|
|
685
736
|
frameMessageKey(streamName, messageId) {
|
|
686
737
|
return `${streamName}##${messageId}`;
|