@jetit/publisher 5.2.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,13 +1,13 @@
1
1
  {
2
2
  "name": "@jetit/publisher",
3
- "version": "5.2.2",
3
+ "version": "5.4.0",
4
4
  "type": "commonjs",
5
5
  "dependencies": {
6
- "@jetit/id": "^0.0.12",
7
- "events": "3.3.0",
6
+ "@jetit/id": "^0.0.13",
8
7
  "ioredis": "^5.3.0",
9
8
  "rxjs": "^7.8.0",
10
- "tslib": "2.5.0"
9
+ "tslib": "2.7.0"
11
10
  },
11
+ "types": "./src/index.d.ts",
12
12
  "main": "./src/index.js"
13
- }
13
+ }
@@ -111,33 +111,26 @@ class MetricsCollector {
111
111
  const individualDepths = {};
112
112
  let cursor = '0';
113
113
  do {
114
- const [nextCursor, keys] = await this.redisClient.scan(cursor, 'MATCH', `ack:*:cg-*`, 'COUNT', 100);
114
+ const [nextCursor, keys] = await this.redisClient.scan(cursor, 'MATCH', '*:cg-*', 'COUNT', 100);
115
115
  cursor = nextCursor;
116
116
  if (keys.length > 0) {
117
- const pipeline = this.redisClient.pipeline();
118
- keys.forEach((key) => {
119
- pipeline.xlen(key.slice(4));
120
- pipeline.zcard(key);
121
- });
122
- const results = await pipeline.exec();
123
- if (!results) {
124
- continue;
125
- }
126
- for (let i = 0; i < results.length; i += 2) {
127
- const key = keys[i / 2];
128
- const [streamLengthErr, streamLength] = results[i];
129
- const [ackCountErr, ackCount] = results[i + 1];
130
- if (streamLengthErr) {
131
- console.error(`Error getting length for key: ${streamLengthErr}`);
132
- continue;
117
+ for (const streamKey of keys) {
118
+ try {
119
+ // Get stream length and pending info
120
+ const streamLength = await this.redisClient.xlen(streamKey);
121
+ // Extract consumer group name from stream key (format: eventName:cg-serviceName)
122
+ const consumerGroup = streamKey.split(':')[1];
123
+ // XPENDING returns [count, min-id, max-id, consumer-list]
124
+ const pendingInfo = await this.redisClient.xpending(streamKey, consumerGroup);
125
+ const totalPending = pendingInfo ? Number(pendingInfo[0]) : 0;
126
+ const queueDepth = Math.max(0, streamLength - totalPending);
127
+ totalDepth += queueDepth;
128
+ individualDepths[streamKey] = queueDepth;
133
129
  }
134
- if (ackCountErr) {
135
- console.error(`Error getting ack count for key: ${ackCountErr}`);
130
+ catch (error) {
131
+ console.error(`Error processing key ${streamKey}:`, error);
136
132
  continue;
137
133
  }
138
- const queueDepth = Math.max(0, streamLength - ackCount);
139
- totalDepth += queueDepth;
140
- individualDepths[key] = queueDepth;
141
134
  }
142
135
  }
143
136
  } while (cursor !== '0');
@@ -1,4 +1,3 @@
1
- /// <reference types="node" />
2
1
  import { EventEmitter } from 'events';
3
2
  import { RedisType } from '../redis/registry';
4
3
  import { EventData, IStreamsConfig } from '../redis/types';
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.publishScheduledBatch = exports.publishBatch = void 0;
3
+ exports.publishBatch = publishBatch;
4
+ exports.publishScheduledBatch = publishScheduledBatch;
4
5
  const id_1 = require("@jetit/id");
5
6
  const tracker_1 = require("../monitoring/tracker");
6
7
  const logger_1 = require("./logger");
@@ -72,8 +73,6 @@ async function publishBatchWithRetry(streams, events, options) {
72
73
  function publishBatch(streams, events, options = {}) {
73
74
  return publishBatchWithRetry(streams, events, options);
74
75
  }
75
- exports.publishBatch = publishBatch;
76
76
  function publishScheduledBatch(streams, events, options) {
77
77
  return publishBatchWithRetry(streams, events, options);
78
78
  }
79
- exports.publishScheduledBatch = publishScheduledBatch;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.setRedisConnectionSettings = exports.RedisRegistry = void 0;
3
+ exports.RedisRegistry = void 0;
4
+ exports.setRedisConnectionSettings = setRedisConnectionSettings;
4
5
  const ioredis_1 = require("ioredis");
5
6
  const logger_1 = require("./logger");
6
7
  class RedisRegistry {
@@ -103,4 +104,3 @@ RedisRegistry.options = {
103
104
  function setRedisConnectionSettings(options) {
104
105
  RedisRegistry.setOptions(options);
105
106
  }
106
- exports.setRedisConnectionSettings = setRedisConnectionSettings;
@@ -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;
@@ -257,131 +257,151 @@ class Streams {
257
257
  const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
258
258
  this.eventsListened.push(eventName);
259
259
  try {
260
- // Check if the consumer group already exists
261
- let groupInfo = [];
260
+ // Try to create the consumer group and consumer in one go
261
+ // If group doesn't exist, this will create it with MKSTREAM
262
+ // If group exists but consumer doesn't, this will create just the consumer
263
+ // If both exist, this will be a no-op
262
264
  try {
263
- groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
264
- }
265
- catch (e) {
266
- // Do nothing
267
- }
268
- let groupExists = false;
269
- for (const group of groupInfo) {
270
- if (group[1] === this.consumerGroupName) {
271
- groupExists = true;
272
- break;
273
- }
274
- }
275
- if (!groupExists) {
276
265
  await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
277
266
  logger_1.PUBLISHER_LOGGER.log(`Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
278
267
  }
279
- else {
268
+ catch (e) {
269
+ // BUSYGROUP error means group already exists, which is fine
270
+ if (!e.message.includes('BUSYGROUP')) {
271
+ throw e;
272
+ }
280
273
  logger_1.PUBLISHER_LOGGER.log(`Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
281
274
  }
275
+ // Create consumer (idempotent operation)
276
+ const createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
277
+ // Register event and consumer group in parallel
278
+ const [addToCGSet, addToFlushSet] = await Promise.all([
279
+ this.redisGroups.sadd(`${eventName}`, this.consumerGroupName),
280
+ this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName),
281
+ this.redisGroups.sadd(key, eventName),
282
+ ]);
283
+ logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Consumer Registered with ${this.instanceId} under ${this.consumerGroupName} with status ${JSON.stringify({
284
+ createConsumerStatus,
285
+ addToCGSet,
286
+ addToFlushSet,
287
+ })}`);
288
+ return true;
282
289
  }
283
- catch (e) {
284
- logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Group creation failed with error ${e.message} for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
285
- }
286
- // Check if the consumer already exists in the group
287
- let consumers = [];
288
- try {
289
- consumers = (await this.redisGroups.xinfo('CONSUMERS', streamName, this.consumerGroupName));
290
- }
291
- catch (e) {
292
- // Do nothing
293
- }
294
- let consumerExists = false;
295
- for (const consumer of consumers) {
296
- if (consumer[1] === this.instanceId) {
297
- consumerExists = true;
298
- break;
299
- }
300
- }
301
- let createConsumerStatus;
302
- if (!consumerExists) {
303
- createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
304
- }
305
- else {
306
- createConsumerStatus = 0; // Consumer already exists
307
- logger_1.PUBLISHER_LOGGER.log(`Consumer already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName, instanceId: this.instanceId })}`);
290
+ catch (error) {
291
+ logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Consumer registration failed for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}:`, error);
292
+ return false;
308
293
  }
309
- await this.redisGroups.sadd(key, eventName);
310
- const addToCGSet = await this.redisGroups.sadd(`${eventName}`, this.consumerGroupName);
311
- const addToFlushSet = await this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName);
312
- logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Consumer Registered and created with ${this.instanceId} under ${this.consumerGroupName} with ${createConsumerStatus} consumers and with the following status ${JSON.stringify({ addToCGSet, addToFlushSet })}`);
313
- return createConsumerStatus === 0 || createConsumerStatus === 1;
314
294
  }
315
295
  listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false, externalAcknowledgement = false) {
316
- if (!this.subscriptions.has(eventName)) {
317
- 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);
318
301
  }
319
302
  const bs = new rxjs_1.BehaviorSubject(null);
320
- const subscription = {
303
+ // Making the subscription Immutable
304
+ const subscription = Object.freeze({
321
305
  subject: bs,
322
306
  filter: eventFilter,
323
307
  lastMatchTime: Date.now(),
324
308
  keepAlive: filterKeepAlive,
325
- };
326
- this.subscriptions.get(eventName).set(subscriptionId, subscription);
327
- const timer = (0, rxjs_1.interval)(10000).subscribe(async () => {
328
- /** Clear earlier unprocessed messages. Runs every 10 seconds */
329
- await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
330
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
331
330
  const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
332
- /** Cleanup timer */
333
331
  timer.unsubscribe();
332
+ // Clean up subscription on completion
333
+ this.removeSubscription(eventName, subscriptionId);
334
334
  }));
335
- const streamName = `${eventName}:${this.consumerGroupName}`;
336
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}`;
337
341
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName}`);
338
342
  try {
339
- try {
340
- /**
341
- * Check if the message is already acquired by another client and is pending.
342
- * This check should only happen if the message is not a multicast message
343
- */
344
- const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
345
- if (pendingDetails[2] === 0 && !multicast) {
346
- logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: MACK ${messageId} for ${streamName}`);
347
- return;
343
+ // Skip XPENDING check for:
344
+ // 1. Multicast messages (meant for all consumers)
345
+ // 2. Initial message processing (messageId = '0')
346
+ // 3. Pending message reprocessing
347
+ if (!multicast && messageId !== '0' && !processPending) {
348
+ try {
349
+ const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
350
+ if (pendingDetails[2] === 0) {
351
+ logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Message ${messageId} already processed for ${streamName}`);
352
+ return;
353
+ }
354
+ }
355
+ catch (e) {
356
+ // If XPENDING fails, continue with processing
357
+ logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: XPENDING check failed for ${messageId}, continuing with processing`);
348
358
  }
349
- }
350
- catch (e) {
351
- // Ignore the xpending error and continue
352
- logger_1.PUBLISHER_LOGGER.error('XPENDING ERROR: To be handled');
353
- logger_1.PUBLISHER_LOGGER.warn(JSON.stringify(e));
354
359
  }
355
360
  let eventData;
356
- /**
357
- * Both multicast messages and pending messages cannot be read by xreadgroup
358
- * Multicast messages should not be claimed by a single consumer. And pending messages
359
- * are usually behind in the stream so XREADGROUP will not read them and hence
360
- * they need to be read using XRANGE.
361
- */
362
361
  tracker.startRedisOperation();
363
- if (multicast || processPending) {
364
- const messages = await redisClient.xrange(streamName, messageId, messageId);
365
- if (messages && messages.length) {
366
- try {
367
- eventData = JSON.parse(messages[0][1][1]);
368
- }
369
- catch (e) {
370
- 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
+ }
371
379
  }
372
380
  }
373
- }
374
- else {
375
- const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
376
- if (messages && messages.length) {
377
- if (messageId === '0') {
378
- messageId = messages[0][1][0][0];
379
- 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
+ }
380
395
  }
381
- eventData = JSON.parse(messages[0][1][0][1][1]);
382
396
  }
383
397
  }
384
- 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
+ }
385
405
  tracker.startProcessing();
386
406
  if (eventData) {
387
407
  if (publishOnceGuarantee) {
@@ -396,61 +416,68 @@ class Streams {
396
416
  const ackKey = this.frameMessageKey(streamName, messageId);
397
417
  const subscriptions = this.subscriptions.get(eventName);
398
418
  if (subscriptions) {
419
+ const currentTime = Date.now();
399
420
  const subscriptionEntries = Array.from(subscriptions.entries());
400
- for (let i = 0; i < subscriptionEntries.length; i++) {
401
- const [subId, sub] = subscriptionEntries[i];
402
- if (!sub.filter || sub.filter(eventData)) {
403
- sub.subject.next({ ...eventData, ackKey });
404
- 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
+ }
405
432
  }
406
- else if (Date.now() - sub.lastMatchTime > sub.keepAlive) {
407
- /**
408
- * Reset the lastMatch time every day by default. For now only
409
- * log the data. Should add functionality to remove the filter
410
- * if its not used at all to gain minor improvements in
411
- * performace
412
- */
413
- logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
414
- 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);
415
436
  }
416
- }
437
+ }));
438
+ }
439
+ // Acknowledge message if needed
440
+ if (!externalAcknowledgement) {
441
+ await this.acknowledgeMessage(ackKey);
417
442
  }
418
- if (!externalAcknowledgement)
419
- this.acknowledgeMessage(ackKey);
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);
420
449
  }
421
- catch (processingError) {
422
- 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);
423
452
  const dlqEvent = {
424
453
  ...eventData,
425
- failureReason: processingError.message,
454
+ failureReason: error.message,
426
455
  retryCount: (eventData.retryCount || 0) + 1,
427
456
  originalStream: streamName,
428
457
  consumerGroupName: this.consumerGroupName,
429
458
  timestamp: Date.now(),
430
459
  };
431
460
  await this.dlq.addToDLQ(dlqEvent);
461
+ // Don't rethrow to prevent message loss
432
462
  }
433
- tracker.incrementMessageRate('subscribe', eventData.eventName);
434
- const processingTime = Date.now() - eventData.createdAt;
435
- tracker.addProcessingTime(processingTime);
436
- const lag = Date.now() - eventData.createdAt;
437
- tracker.setConsumerLag(this.consumerGroupName, lag);
438
463
  }
439
464
  else {
440
465
  logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message ${messageId} not found for ${streamName}`);
441
466
  }
442
467
  tracker.endProcessing();
443
- /** 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 */
444
469
  if (!processPending) {
445
470
  const unprocessedMessageIds = await (0, utils_1.getUnacknowledgedMessages)(redisClient, this.consumerGroupName, streamName, this.instanceId);
446
471
  if (unprocessedMessageIds.countOnThisConsumer &&
447
472
  unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
448
473
  logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
449
474
  }
450
- for (const id of unprocessedMessageIds.messageIds) {
451
- 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));
452
478
  await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
453
- }
479
+ };
480
+ unprocessedMessageIds.messageIds.map((id, index) => processWithDelay(id, index));
454
481
  }
455
482
  }
456
483
  catch (e) {
@@ -550,17 +577,30 @@ class Streams {
550
577
  }
551
578
  async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
552
579
  const streamName = `${eventName}:${this.consumerGroupName}`;
553
- const cleanupThreshold = Date.now() - interval;
554
- const acknowledgedMessages = await this.redisGroups.zrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
555
- if (acknowledgedMessages && acknowledgedMessages.length > 0) {
556
- // Remove acknowledged messages from the stream in batches
557
- const batchSize = 100;
558
- for (let i = 0; i < acknowledgedMessages.length; i += batchSize) {
559
- const batch = acknowledgedMessages.slice(i, i + batchSize);
560
- await this.redisGroups.xdel(streamName, ...batch);
580
+ const lastAckKey = `last_ack:${streamName}`;
581
+ const oneHourAgo = Date.now() - interval;
582
+ try {
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;
561
595
  }
562
- // Remove acknowledged messages from the Sorted Set
563
- await this.redisGroups.zremrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
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}`);
601
+ }
602
+ catch (error) {
603
+ logger_1.PUBLISHER_LOGGER.error(`Error during cleanup for ${streamName}:`, error);
564
604
  }
565
605
  }
566
606
  async getDiagnosticData(events) {
@@ -673,10 +713,21 @@ class Streams {
673
713
  await this.circuitBreaker.clearStoredEvents();
674
714
  }
675
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
+ */
676
720
  async acknowledgeMessage(ackKey) {
677
721
  const { streamName, messageId } = this.demergeMessageKey(ackKey);
678
- await this.redisGroups.xack(streamName, this.consumerGroupName, messageId);
679
- await this.redisGroups.zadd(`ack:${streamName}`, Date.now().toString(), 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
+ }
680
731
  }
681
732
  frameMessageKey(streamName, messageId) {
682
733
  return `${streamName}##${messageId}`;
@@ -1,12 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UTILS = exports.decodeScheduledMessage = exports.encodeScheduledMessage = exports.removedScheduledJob = exports.notifySubscribers = exports.getMessageStatesCount = exports.getUnacknowledgedMessages = exports.getSummaryOnStreamConsumerGroup = exports.getAllConsumerGroups = void 0;
3
+ exports.UTILS = void 0;
4
+ exports.getAllConsumerGroups = getAllConsumerGroups;
5
+ exports.getSummaryOnStreamConsumerGroup = getSummaryOnStreamConsumerGroup;
6
+ exports.getUnacknowledgedMessages = getUnacknowledgedMessages;
7
+ exports.getMessageStatesCount = getMessageStatesCount;
8
+ exports.notifySubscribers = notifySubscribers;
9
+ exports.removedScheduledJob = removedScheduledJob;
10
+ exports.encodeScheduledMessage = encodeScheduledMessage;
11
+ exports.decodeScheduledMessage = decodeScheduledMessage;
4
12
  const logger_1 = require("./logger");
5
13
  async function getAllConsumerGroups(eventName, redisConnection) {
6
14
  const consumerGroups = await redisConnection.smembers(`${eventName}`);
7
15
  return consumerGroups;
8
16
  }
9
- exports.getAllConsumerGroups = getAllConsumerGroups;
10
17
  function* getSummaryOnStreamConsumerGroup(redisClient, consumerGroupName, streamName) {
11
18
  const [count, , , consumers] = (yield redisClient.xpending(streamName, consumerGroupName));
12
19
  yield {
@@ -21,7 +28,6 @@ function* getSummaryOnStreamConsumerGroup(redisClient, consumerGroupName, stream
21
28
  : [],
22
29
  };
23
30
  }
24
- exports.getSummaryOnStreamConsumerGroup = getSummaryOnStreamConsumerGroup;
25
31
  async function getUnacknowledgedMessages(redisClient, consumerGroupName, streamName, consumerName, count = 500) {
26
32
  try {
27
33
  // Get pending messages summary
@@ -32,12 +38,16 @@ async function getUnacknowledgedMessages(redisClient, consumerGroupName, streamN
32
38
  }
33
39
  // Use the smallest and largest IDs to get a detailed range
34
40
  const pendingMessageCount = summary[0];
35
- // Get detailed information in the range
36
- let pendingMessages = (await redisClient.xpending(streamName, consumerGroupName, '-', '+', count, consumerName));
41
+ // Create a message ID for 2 seconds ago to exclude recent messages
42
+ const oneSecondAgo = Date.now() - 2000;
43
+ const minId = '0-0'; // Start from beginning
44
+ const maxId = `${oneSecondAgo}-0`; // Only include messages older than 1 second
45
+ // Get detailed information in the range, excluding recent messages
46
+ let pendingMessages = (await redisClient.xpending(streamName, consumerGroupName, minId, maxId, count, consumerName));
37
47
  /** If no pending messages on consumer, fetch messages from other consumers that haven't been claimed for more than 10s */
38
48
  if (count > pendingMessages.length && pendingMessages.length === 0) {
39
49
  await redisClient.xautoclaim(streamName, consumerGroupName, consumerName, 10000, '0-0', 'COUNT', 100);
40
- pendingMessages = (await redisClient.xpending(streamName, consumerGroupName, '-', '+', count, consumerName));
50
+ pendingMessages = (await redisClient.xpending(streamName, consumerGroupName, minId, maxId, count, consumerName));
41
51
  }
42
52
  return {
43
53
  count: pendingMessageCount,
@@ -51,7 +61,6 @@ async function getUnacknowledgedMessages(redisClient, consumerGroupName, streamN
51
61
  return { count: 0, messageIds: [] };
52
62
  }
53
63
  }
54
- exports.getUnacknowledgedMessages = getUnacknowledgedMessages;
55
64
  async function getMessageStatesCount(redisClient, streamName, consumerGroup) {
56
65
  try {
57
66
  const pendingInfo = (await redisClient.xpending(streamName, consumerGroup));
@@ -66,11 +75,9 @@ async function getMessageStatesCount(redisClient, streamName, consumerGroup) {
66
75
  return { acknowledged: 0, unacknowledged: 0 };
67
76
  }
68
77
  }
69
- exports.getMessageStatesCount = getMessageStatesCount;
70
78
  async function notifySubscribers(redisClient, eventName, messageId, multicast = false) {
71
79
  await redisClient.publish(eventName, JSON.stringify({ messageId, multicast }));
72
80
  }
73
- exports.notifySubscribers = notifySubscribers;
74
81
  async function removedScheduledJob(redisClient, eventString) {
75
82
  const currentTime = new Date().getTime();
76
83
  const events = await redisClient.zrangebyscore('se', 0, currentTime);
@@ -79,7 +86,6 @@ async function removedScheduledJob(redisClient, eventString) {
79
86
  const eventsLater = await redisClient.zrangebyscore('se', 0, currentTime);
80
87
  logger_1.PUBLISHER_LOGGER.log(`Total Events in scheduled queue: ${eventsLater.length}`);
81
88
  }
82
- exports.removedScheduledJob = removedScheduledJob;
83
89
  function encodeScheduledMessage(data) {
84
90
  const eventName = data.eventName;
85
91
  const eventData = JSON.stringify(data.data);
@@ -87,7 +93,6 @@ function encodeScheduledMessage(data) {
87
93
  const eventDataBuffer = Buffer.from(eventData, 'utf8').toString('base64');
88
94
  return `${eventName}%%${eventDataBuffer}%%${repeatInterval}`;
89
95
  }
90
- exports.encodeScheduledMessage = encodeScheduledMessage;
91
96
  function decodeScheduledMessage(data) {
92
97
  const parts = data.split('%%');
93
98
  const eventName = parts[0];
@@ -97,7 +102,6 @@ function decodeScheduledMessage(data) {
97
102
  repeatInterval: parseInt(parts[2]),
98
103
  };
99
104
  }
100
- exports.decodeScheduledMessage = decodeScheduledMessage;
101
105
  exports.UTILS = {
102
106
  getMessageStatesCount,
103
107
  getUnacknowledgedMessages,