@jetit/publisher 6.0.0 → 6.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -125,6 +125,8 @@ const config: Partial<IStreamsConfig> = {
|
|
|
125
125
|
halfOpenStateMaxAttempts: 10,
|
|
126
126
|
maxStoredEvents: 5000,
|
|
127
127
|
},
|
|
128
|
+
maxPendingTasks: 1000, // Max concurrent pending retry tasks per event per consumer (default: 1000, 0 = unbounded)
|
|
129
|
+
enableMetrics: true, // Set to false to disable MetricsCollector and its periodic SCAN (default: true)
|
|
128
130
|
};
|
|
129
131
|
|
|
130
132
|
const publisher = new Publisher('MyService', config);
|
|
@@ -358,6 +360,8 @@ The Circuit Breaker has three states:
|
|
|
358
360
|
- Retry logic with exponential backoff for failed operations
|
|
359
361
|
- Circuit Breaker to prevent overwhelming failed services
|
|
360
362
|
- Dead Letter Queue (DLQ) for handling subscription failures
|
|
363
|
+
- **Disable Metrics Collection**: Set `enableMetrics: false` to skip `MetricsCollector` initialization entirely. The collector runs `SCAN *:cg-* COUNT 100` every 60 seconds for queue depth — disabling it eliminates this periodic Redis overhead. Applies to both `Publisher` and `PublisherLite`. When disabled, `getMetrics()` returns `[]` and `getLatestMetrics()` returns `null`.
|
|
364
|
+
- **Pending Retry Task Cap**: Limits the number of concurrent in-flight pending message retry tasks per event per consumer instance. Prevents unbounded memory growth and Redis saturation under sustained load. Configurable via `maxPendingTasks` (default: 1000). Set to `0` to disable the cap (unbounded, pre-6.0.1 behavior). When the cap is reached, a rate-limited warning is logged and skipped messages are retried on the next Pub/Sub trigger.
|
|
361
365
|
- **Adaptive Redis Stream ID Generation (Publisher only)**: The default `Publisher` automatically switches to an optimized ID generation strategy using a Lua script when publishing to many consumer groups (>10 by default) or when ID conflicts are detected. This prevents `XADD` errors related to non-monotonic IDs in high-throughput scenarios. Configurable via `optimizationThreshold` and `optimizationDurationMs`. (`PublisherLite` does not include this complex logic).
|
|
362
366
|
|
|
363
367
|
## Cleanup and Graceful Shutdown
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jetit/publisher",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.2",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"@jetit/id": ">=0.0.14",
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
"rxjs": ">=7.0.0"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"
|
|
12
|
-
"tslib": "2.7.0"
|
|
11
|
+
"tslib": "2.8.1"
|
|
13
12
|
},
|
|
14
13
|
"types": "./src/index.d.ts",
|
|
15
14
|
"main": "./src/index.js"
|
|
@@ -75,11 +75,13 @@ class StreamsLite {
|
|
|
75
75
|
}, cleanUpInterval);
|
|
76
76
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Clean Up process setup for ${cleanUpInterval} ms`);
|
|
77
77
|
this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
if (this.config.enableMetrics !== false) {
|
|
79
|
+
this.metricsCollector = new collector_1.MetricsCollector({
|
|
80
|
+
redisClient: this.redisPublisher,
|
|
81
|
+
collectionInterval: 60000,
|
|
82
|
+
retentionPeriod: 6 * 60 * 60 * 1000,
|
|
83
|
+
}, this.dlq);
|
|
84
|
+
}
|
|
83
85
|
this.duplicateChecker = new duplication_1.ContentBasedDeduplication(this.redisPublisher, this.config.duplicationCheckWindow);
|
|
84
86
|
this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
|
|
85
87
|
if (this.config.circuitBreaker.enabled)
|
|
@@ -667,6 +669,8 @@ class StreamsLite {
|
|
|
667
669
|
* This will return you the stats of the publisher for the last 6 hours after cleaning
|
|
668
670
|
*/
|
|
669
671
|
async getMetrics(startTime, endTime) {
|
|
672
|
+
if (!this.metricsCollector)
|
|
673
|
+
return [];
|
|
670
674
|
return this.metricsCollector.getMetrics(startTime, endTime);
|
|
671
675
|
}
|
|
672
676
|
/**
|
|
@@ -674,6 +678,8 @@ class StreamsLite {
|
|
|
674
678
|
* This will return you the latest stats of the publisher
|
|
675
679
|
*/
|
|
676
680
|
async getLatestMetrics() {
|
|
681
|
+
if (!this.metricsCollector)
|
|
682
|
+
return null;
|
|
677
683
|
return this.metricsCollector.getLatestMetrics();
|
|
678
684
|
}
|
|
679
685
|
/**
|
package/src/lib/redis/streams.js
CHANGED
|
@@ -128,6 +128,8 @@ class Streams {
|
|
|
128
128
|
// New defaults
|
|
129
129
|
optimizationDurationMs: 2 * 60 * 1000, // 2 minutes
|
|
130
130
|
optimizationThreshold: 20, // Enable optimization for >20 consumer groups
|
|
131
|
+
maxPendingTasks: 1000,
|
|
132
|
+
enableMetrics: true,
|
|
131
133
|
};
|
|
132
134
|
/** Initialise Config properties */
|
|
133
135
|
this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
|
|
@@ -143,11 +145,13 @@ class Streams {
|
|
|
143
145
|
}, cleanUpInterval);
|
|
144
146
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Clean Up process setup for ${cleanUpInterval} ms`);
|
|
145
147
|
this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
|
|
146
|
-
this.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
if (this.config.enableMetrics !== false) {
|
|
149
|
+
this.metricsCollector = new collector_1.MetricsCollector({
|
|
150
|
+
redisClient: this.redisPublisher,
|
|
151
|
+
collectionInterval: 60000,
|
|
152
|
+
retentionPeriod: 6 * 60 * 60 * 1000,
|
|
153
|
+
}, this.dlq);
|
|
154
|
+
}
|
|
151
155
|
this.duplicateChecker = new duplication_1.ContentBasedDeduplication(this.redisPublisher, this.config.duplicationCheckWindow);
|
|
152
156
|
this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
|
|
153
157
|
if (this.config.circuitBreaker.enabled)
|
|
@@ -573,26 +577,19 @@ class Streams {
|
|
|
573
577
|
if (!isNewSubscription) {
|
|
574
578
|
return bs.asObservable().pipe((0, rxjs_1.skip)(1));
|
|
575
579
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
|
|
581
|
-
}
|
|
582
|
-
catch (error) {
|
|
583
|
-
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in running recurring cleanup task:', error);
|
|
584
|
-
}
|
|
585
|
-
},
|
|
586
|
-
error: (error) => {
|
|
587
|
-
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Fatal error in cleanup timer:', error);
|
|
588
|
-
},
|
|
589
|
-
});
|
|
580
|
+
// 10-second cleanup timer disabled — was re-enabled in v6.0.0 but causes
|
|
581
|
+
// double publishes and triggers unbounded pending retry spawning.
|
|
582
|
+
// Pending retries are driven by Pub/Sub notifications only.
|
|
583
|
+
const timer = { unsubscribe: () => { } };
|
|
590
584
|
// Create observable with proper cleanup
|
|
591
585
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
592
586
|
timer.unsubscribe();
|
|
593
587
|
// Clean up subscription on completion
|
|
594
588
|
this.removeSubscription(eventName, subscriptionId);
|
|
595
589
|
}));
|
|
590
|
+
let activePendingTasks = 0;
|
|
591
|
+
let lastCapLogTime = 0;
|
|
592
|
+
const maxPendingTasks = this.config.maxPendingTasks ?? 1000;
|
|
596
593
|
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
597
594
|
// Skip processing if subscription was removed. This is needed because the processing is independent of the subscription
|
|
598
595
|
if (!this.subscriptions.has(eventName)) {
|
|
@@ -786,12 +783,47 @@ class Streams {
|
|
|
786
783
|
unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
|
|
787
784
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
|
|
788
785
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
786
|
+
if (maxPendingTasks > 0) {
|
|
787
|
+
const available = maxPendingTasks - activePendingTasks;
|
|
788
|
+
if (available <= 0) {
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
if (now - lastCapLogTime > 30000) {
|
|
791
|
+
logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Pending task cap reached (${activePendingTasks}/${maxPendingTasks}), skipping batch for ${streamName}`);
|
|
792
|
+
lastCapLogTime = now;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
const batch = unprocessedMessageIds.messageIds.slice(0, available);
|
|
797
|
+
batch.forEach((id, index) => {
|
|
798
|
+
activePendingTasks++;
|
|
799
|
+
void (async () => {
|
|
800
|
+
try {
|
|
801
|
+
await new Promise((resolve) => setTimeout(resolve, index * getDelay(unprocessedMessageIds.countOnThisConsumer ?? 1)));
|
|
802
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in pending retry task for ${id}`, err);
|
|
806
|
+
}
|
|
807
|
+
finally {
|
|
808
|
+
activePendingTasks--;
|
|
809
|
+
}
|
|
810
|
+
})();
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
unprocessedMessageIds.messageIds.forEach((id, index) => {
|
|
816
|
+
void (async () => {
|
|
817
|
+
try {
|
|
818
|
+
await new Promise((resolve) => setTimeout(resolve, index * getDelay(unprocessedMessageIds.countOnThisConsumer ?? 1)));
|
|
819
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in pending retry task for ${id}`, err);
|
|
823
|
+
}
|
|
824
|
+
})();
|
|
825
|
+
});
|
|
826
|
+
}
|
|
795
827
|
}
|
|
796
828
|
}
|
|
797
829
|
catch (e) {
|
|
@@ -1003,6 +1035,8 @@ class Streams {
|
|
|
1003
1035
|
* This will return you the stats of the publisher for the last 6 hours after cleaning
|
|
1004
1036
|
*/
|
|
1005
1037
|
async getMetrics(startTime, endTime) {
|
|
1038
|
+
if (!this.metricsCollector)
|
|
1039
|
+
return [];
|
|
1006
1040
|
return this.metricsCollector.getMetrics(startTime, endTime);
|
|
1007
1041
|
}
|
|
1008
1042
|
/**
|
|
@@ -1010,6 +1044,8 @@ class Streams {
|
|
|
1010
1044
|
* This will return you the latest stats of the publisher
|
|
1011
1045
|
*/
|
|
1012
1046
|
async getLatestMetrics() {
|
|
1047
|
+
if (!this.metricsCollector)
|
|
1048
|
+
return null;
|
|
1013
1049
|
return this.metricsCollector.getLatestMetrics();
|
|
1014
1050
|
}
|
|
1015
1051
|
/**
|
package/src/lib/redis/types.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface IStreamsConfig {
|
|
|
56
56
|
};
|
|
57
57
|
optimizationDurationMs?: number;
|
|
58
58
|
optimizationThreshold?: number;
|
|
59
|
+
maxPendingTasks?: number;
|
|
60
|
+
enableMetrics?: boolean;
|
|
59
61
|
}
|
|
60
62
|
export type TEventFilter<T> = (event: EventData<T, string>) => boolean;
|
|
61
63
|
export interface ISubscription<T, TName extends string = string> {
|