@jetit/publisher 5.3.2 → 5.4.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jetit/publisher",
3
- "version": "5.3.2",
3
+ "version": "5.4.0",
4
4
  "type": "commonjs",
5
5
  "dependencies": {
6
- "@jetit/id": "^0.0.12",
6
+ "@jetit/id": "^0.0.13",
7
7
  "ioredis": "^5.3.0",
8
8
  "rxjs": "^7.8.0",
9
9
  "tslib": "2.7.0"
@@ -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;
@@ -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
- if (!this.subscriptions.has(eventName)) {
297
- this.subscriptions.set(eventName, new Map());
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);
300
- const subscription = {
303
+ // Making the subscription Immutable
304
+ const subscription = Object.freeze({
301
305
  subject: bs,
302
306
  filter: eventFilter,
303
307
  lastMatchTime: Date.now(),
304
308
  keepAlive: filterKeepAlive,
305
- };
306
- this.subscriptions.get(eventName).set(subscriptionId, subscription);
307
- const timer = (0, rxjs_1.interval)(10000).subscribe(async () => {
308
- /** Clear earlier unprocessed messages. Runs every 10 seconds */
309
- await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
310
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
+ },
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
- if (multicast || processPending) {
345
- const messages = await redisClient.xrange(streamName, messageId, messageId);
346
- if (messages && messages.length) {
347
- try {
348
- eventData = JSON.parse(messages[0][1][1]);
349
- }
350
- catch (e) {
351
- console.error(`JSON parsing failed for the following message ${messages[0][1][1]} in the publisher.`);
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
- else {
356
- const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
357
- if (messages && messages.length) {
358
- if (messageId === '0') {
359
- messageId = messages[0][1][0][0];
360
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${messageId}`);
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
- tracker.endRedisOperation();
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
- for (let i = 0; i < subscriptionEntries.length; i++) {
382
- const [subId, sub] = subscriptionEntries[i];
383
- if (!sub.filter || sub.filter(eventData)) {
384
- sub.subject.next({ ...eventData, ackKey });
385
- sub.lastMatchTime = Date.now();
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
- else if (Date.now() - sub.lastMatchTime > sub.keepAlive) {
388
- /**
389
- * Reset the lastMatch time every day by default. For now only
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 (!externalAcknowledgement)
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 (processingError) {
403
- logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, processingError);
450
+ catch (error) {
451
+ logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, error);
404
452
  const dlqEvent = {
405
453
  ...eventData,
406
- failureReason: processingError.message,
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 Message if this is a main tree, otherwise limit to processing 100 messages that are unacknowledged */
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
- for (const id of unprocessedMessageIds.messageIds) {
432
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${id}`);
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 cleanupThreshold = Date.now() - interval;
535
- const CHUNK_SIZE = 10000; // Process messages in chunks to avoid memory issues
580
+ const lastAckKey = `last_ack:${streamName}`;
581
+ const oneHourAgo = Date.now() - interval;
536
582
  try {
537
- // Get pending info for this consumer group once
538
- const pendingInfo = await this.redisGroups.xpending(streamName, this.consumerGroupName);
539
- const hasPendingMessages = pendingInfo && pendingInfo[0] > 0;
540
- let lastId = '-'; // Start from the beginning of the stream
541
- while (true) {
542
- // Get a chunk of messages
543
- const messages = await this.redisGroups.xrange(streamName, lastId, cleanupThreshold.toString(), 'COUNT', CHUNK_SIZE);
544
- if (!messages || messages.length === 0)
545
- break;
546
- // Update lastId for next iteration (exclusive)
547
- lastId = messages[messages.length - 1][0];
548
- if (hasPendingMessages) {
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,21 @@ 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
721
  const { streamName, messageId } = this.demergeMessageKey(ackKey);
683
- await this.redisGroups.xack(streamName, this.consumerGroupName, messageId);
722
+ const lastAckKey = `last_ack:${streamName}`;
723
+ try {
724
+ // Update last acknowledged ID and acknowledge message atomically
725
+ await Promise.all([this.redisGroups.xack(streamName, this.consumerGroupName, messageId), this.redisGroups.set(lastAckKey, messageId)]);
726
+ }
727
+ catch (error) {
728
+ logger_1.PUBLISHER_LOGGER.error(`Error acknowledging message ${messageId} for ${streamName}:`, error);
729
+ throw error;
730
+ }
684
731
  }
685
732
  frameMessageKey(streamName, messageId) {
686
733
  return `${streamName}##${messageId}`;