@jetit/publisher 6.0.0 → 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.
- package/README.md +2 -0
- package/package.json +2 -3
- package/src/lib/redis/streams.js +49 -20
- package/src/lib/redis/types.d.ts +1 -0
package/README.md
CHANGED
|
@@ -125,6 +125,7 @@ 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)
|
|
128
129
|
};
|
|
129
130
|
|
|
130
131
|
const publisher = new Publisher('MyService', config);
|
|
@@ -358,6 +359,7 @@ The Circuit Breaker has three states:
|
|
|
358
359
|
- Retry logic with exponential backoff for failed operations
|
|
359
360
|
- Circuit Breaker to prevent overwhelming failed services
|
|
360
361
|
- Dead Letter Queue (DLQ) for handling subscription failures
|
|
362
|
+
- **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
363
|
- **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
364
|
|
|
363
365
|
## 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.1",
|
|
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"
|
package/src/lib/redis/streams.js
CHANGED
|
@@ -128,6 +128,7 @@ 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,
|
|
131
132
|
};
|
|
132
133
|
/** Initialise Config properties */
|
|
133
134
|
this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
|
|
@@ -573,26 +574,19 @@ class Streams {
|
|
|
573
574
|
if (!isNewSubscription) {
|
|
574
575
|
return bs.asObservable().pipe((0, rxjs_1.skip)(1));
|
|
575
576
|
}
|
|
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
|
-
});
|
|
577
|
+
// 10-second cleanup timer disabled — was re-enabled in v6.0.0 but causes
|
|
578
|
+
// double publishes and triggers unbounded pending retry spawning.
|
|
579
|
+
// Pending retries are driven by Pub/Sub notifications only.
|
|
580
|
+
const timer = { unsubscribe: () => { } };
|
|
590
581
|
// Create observable with proper cleanup
|
|
591
582
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
592
583
|
timer.unsubscribe();
|
|
593
584
|
// Clean up subscription on completion
|
|
594
585
|
this.removeSubscription(eventName, subscriptionId);
|
|
595
586
|
}));
|
|
587
|
+
let activePendingTasks = 0;
|
|
588
|
+
let lastCapLogTime = 0;
|
|
589
|
+
const maxPendingTasks = this.config.maxPendingTasks ?? 1000;
|
|
596
590
|
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
597
591
|
// Skip processing if subscription was removed. This is needed because the processing is independent of the subscription
|
|
598
592
|
if (!this.subscriptions.has(eventName)) {
|
|
@@ -786,12 +780,47 @@ class Streams {
|
|
|
786
780
|
unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
|
|
787
781
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
|
|
788
782
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
783
|
+
if (maxPendingTasks > 0) {
|
|
784
|
+
const available = maxPendingTasks - activePendingTasks;
|
|
785
|
+
if (available <= 0) {
|
|
786
|
+
const now = Date.now();
|
|
787
|
+
if (now - lastCapLogTime > 30000) {
|
|
788
|
+
logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: Pending task cap reached (${activePendingTasks}/${maxPendingTasks}), skipping batch for ${streamName}`);
|
|
789
|
+
lastCapLogTime = now;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
const batch = unprocessedMessageIds.messageIds.slice(0, available);
|
|
794
|
+
batch.forEach((id, index) => {
|
|
795
|
+
activePendingTasks++;
|
|
796
|
+
void (async () => {
|
|
797
|
+
try {
|
|
798
|
+
await new Promise((resolve) => setTimeout(resolve, index * getDelay(unprocessedMessageIds.countOnThisConsumer ?? 1)));
|
|
799
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in pending retry task for ${id}`, err);
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
activePendingTasks--;
|
|
806
|
+
}
|
|
807
|
+
})();
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
unprocessedMessageIds.messageIds.forEach((id, index) => {
|
|
813
|
+
void (async () => {
|
|
814
|
+
try {
|
|
815
|
+
await new Promise((resolve) => setTimeout(resolve, index * getDelay(unprocessedMessageIds.countOnThisConsumer ?? 1)));
|
|
816
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in pending retry task for ${id}`, err);
|
|
820
|
+
}
|
|
821
|
+
})();
|
|
822
|
+
});
|
|
823
|
+
}
|
|
795
824
|
}
|
|
796
825
|
}
|
|
797
826
|
catch (e) {
|
package/src/lib/redis/types.d.ts
CHANGED
|
@@ -56,6 +56,7 @@ export interface IStreamsConfig {
|
|
|
56
56
|
};
|
|
57
57
|
optimizationDurationMs?: number;
|
|
58
58
|
optimizationThreshold?: number;
|
|
59
|
+
maxPendingTasks?: number;
|
|
59
60
|
}
|
|
60
61
|
export type TEventFilter<T> = (event: EventData<T, string>) => boolean;
|
|
61
62
|
export interface ISubscription<T, TName extends string = string> {
|