@jetit/publisher 4.1.0 → 5.0.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/README.md +249 -171
- package/package.json +3 -2
- package/src/lib/monitoring/adapters/prom.d.ts +37 -0
- package/src/lib/monitoring/adapters/prom.js +126 -0
- package/src/lib/monitoring/collector.d.ts +17 -0
- package/src/lib/monitoring/collector.js +109 -0
- package/src/lib/monitoring/tracker.d.ts +15 -0
- package/src/lib/monitoring/tracker.js +59 -0
- package/src/lib/monitoring/types.d.ts +37 -0
- package/src/lib/monitoring/types.js +2 -0
- package/src/lib/performance/circuit_breaker.d.ts +29 -0
- package/src/lib/performance/circuit_breaker.js +103 -0
- package/src/lib/publisher.d.ts +3 -0
- package/src/lib/publisher.js +6 -1
- package/src/lib/redis/batch.d.ts +6 -0
- package/src/lib/redis/batch.js +79 -0
- package/src/lib/redis/dlq.d.ts +18 -0
- package/src/lib/redis/dlq.js +133 -0
- package/src/lib/redis/duplication.d.ts +10 -0
- package/src/lib/redis/duplication.js +28 -0
- package/src/lib/redis/streams.d.ts +61 -3
- package/src/lib/redis/streams.js +396 -79
- package/src/lib/redis/types.d.ts +64 -0
package/src/lib/redis/streams.js
CHANGED
|
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Streams = void 0;
|
|
4
4
|
const id_1 = require("@jetit/id");
|
|
5
5
|
const rxjs_1 = require("rxjs");
|
|
6
|
+
const collector_1 = require("../monitoring/collector");
|
|
7
|
+
const tracker_1 = require("../monitoring/tracker");
|
|
8
|
+
const circuit_breaker_1 = require("../performance/circuit_breaker");
|
|
9
|
+
const dlq_1 = require("./dlq");
|
|
10
|
+
const duplication_1 = require("./duplication");
|
|
6
11
|
const logger_1 = require("./logger");
|
|
7
12
|
const registry_1 = require("./registry");
|
|
8
13
|
const utils_1 = require("./utils");
|
|
9
|
-
function publisherErrorHandler(error) {
|
|
10
|
-
logger_1.PUBLISHER_LOGGER.error('PUBLISHER UNHANDLED ERROR: ', JSON.stringify(error));
|
|
11
|
-
}
|
|
12
14
|
class Streams {
|
|
13
15
|
get redisPublisher() {
|
|
14
16
|
if (!this._redisPublisher)
|
|
@@ -34,61 +36,144 @@ class Streams {
|
|
|
34
36
|
* // Create a new Streams instance for the "POS" service
|
|
35
37
|
* const streams = new Streams('POS');
|
|
36
38
|
*/
|
|
37
|
-
constructor(serviceName) {
|
|
39
|
+
constructor(serviceName, config = {}) {
|
|
38
40
|
this.eventsListened = [];
|
|
41
|
+
this.subscriptions = new Map();
|
|
42
|
+
this.DEFAULT_STREAMS_CONFIG = {
|
|
43
|
+
immediatePublishThreshold: 500,
|
|
44
|
+
unprocessedMessageThreshold: 25,
|
|
45
|
+
acknowledgedMessageCleanupInterval: 60 * 60 * 1000, // 1 hour
|
|
46
|
+
cleanUpInterval: 1000 * 60 * 60,
|
|
47
|
+
dlqEventThreshold: 2000,
|
|
48
|
+
filterKeepAlive: 24 * 60 * 60 * 1000,
|
|
49
|
+
duplicationCheckWindow: 84600,
|
|
50
|
+
circuitBreaker: {
|
|
51
|
+
enabled: true,
|
|
52
|
+
errorThreshold: 50,
|
|
53
|
+
errorThresholdPercentage: 50,
|
|
54
|
+
openStateDuration: 30000,
|
|
55
|
+
halfOpenStateMaxAttempts: 10,
|
|
56
|
+
maxStoredEvents: 5000,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
/** Initialise Config properties */
|
|
60
|
+
this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
|
|
39
61
|
this.instanceUniqueId = process.env['INSTANCE_ID'] ?? (0, id_1.generateID)('HEX', 'FE');
|
|
40
62
|
this.instanceId = `${serviceName}:${this.instanceUniqueId}`;
|
|
41
63
|
this.consumerGroupName = `cg-${serviceName}`;
|
|
42
64
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Instance ID: ${this.instanceId}`);
|
|
43
|
-
const cleanUpInterval = parseInt(process.env['CLEANUP_INTERVAL']
|
|
65
|
+
const cleanUpInterval = this.config.cleanUpInterval ?? parseInt(process.env['CLEANUP_INTERVAL'] ?? `${this.config.cleanUpInterval}`, 10);
|
|
44
66
|
this.cleanUpTimer = setInterval(() => {
|
|
45
|
-
this.runClear(cleanUpInterval)
|
|
67
|
+
this.runClear(cleanUpInterval).catch((error) => {
|
|
68
|
+
logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
|
|
69
|
+
});
|
|
46
70
|
}, cleanUpInterval);
|
|
71
|
+
this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
|
|
72
|
+
this.metricsCollector = new collector_1.MetricsCollector({
|
|
73
|
+
redisClient: this.redisPublisher,
|
|
74
|
+
collectionInterval: 60000,
|
|
75
|
+
retentionPeriod: 6 * 60 * 60 * 1000,
|
|
76
|
+
}, this.dlq);
|
|
77
|
+
this.duplicateChecker = new duplication_1.ContentBasedDeduplication(this.redisPublisher, this.config.duplicationCheckWindow);
|
|
78
|
+
this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
|
|
79
|
+
if (this.config.circuitBreaker.enabled)
|
|
80
|
+
this.setupCircuitBreakerListeners();
|
|
81
|
+
}
|
|
82
|
+
setupCircuitBreakerListeners() {
|
|
83
|
+
this.circuitBreaker.on('stateChange', async (newState) => {
|
|
84
|
+
logger_1.PUBLISHER_LOGGER.log(`Circuit breaker state changed to: ${circuit_breaker_1.CircuitState[newState]}`);
|
|
85
|
+
if (newState === circuit_breaker_1.CircuitState.CLOSED) {
|
|
86
|
+
await this.processStoredEvents();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
47
89
|
}
|
|
48
90
|
async runClear(cleanUpInterval) {
|
|
49
91
|
logger_1.PUBLISHER_LOGGER.log('PUBLISHER: Running Clearance', this.eventsListened);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
});
|
|
56
|
-
}
|
|
92
|
+
const cleanupPromises = this.eventsListened.map((eventName) => this.cleanupAcknowledgedMessages(eventName, cleanUpInterval)
|
|
93
|
+
.then(() => {
|
|
94
|
+
logger_1.PUBLISHER_LOGGER.log(`Cleanup process for Acknowledged messages completed for ${eventName}`);
|
|
95
|
+
})
|
|
96
|
+
.catch((error) => {
|
|
97
|
+
logger_1.PUBLISHER_LOGGER.error(`Error during cleanup for ${eventName}:`, error);
|
|
98
|
+
}));
|
|
99
|
+
await Promise.all(cleanupPromises);
|
|
57
100
|
}
|
|
58
101
|
async publish(data, multicast = false) {
|
|
59
|
-
const
|
|
102
|
+
const tracker = new tracker_1.MetricsTracker();
|
|
60
103
|
if (data.eventId)
|
|
61
104
|
data.republishEvent = data.eventId;
|
|
62
105
|
data.eventId = (0, id_1.generateID)('HEX', 'FF');
|
|
63
106
|
if (!data.createdAt)
|
|
64
107
|
data.createdAt = Date.now();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
/**
|
|
109
|
+
* This is a simplified description of the circuit breaker code
|
|
110
|
+
* 1. If the circuit breaker is enabled, then we check if the circuit is closed and messages are allowed to pass
|
|
111
|
+
* 2. If the circuit is open, then the event is stored and an error is logged notifying that the system has recorded
|
|
112
|
+
* a large number of publisher failures (controlled by errorThreshold and errorThresholdPercentage). This emits an
|
|
113
|
+
* event and the time of change of state is recorded. The circuit remains open for 30 seconds, allowing the system to
|
|
114
|
+
* correct itself as much as possible. All events during this time end up in the buffer
|
|
115
|
+
* 3. If the 30s is expired, the system resets to a half open state where new events are allowed to go through and a
|
|
116
|
+
* success state is recorded.
|
|
117
|
+
* 4. In the half open state if there are 10 attempts that succeed, then the circuit goes to closed and all pending
|
|
118
|
+
* messages are published.
|
|
119
|
+
*
|
|
120
|
+
* Here is a pictorial representation
|
|
121
|
+
*
|
|
122
|
+
* [Failure threshold met]
|
|
123
|
+
* CLOSED --------------------> OPEN
|
|
124
|
+
* ^ |
|
|
125
|
+
* | |
|
|
126
|
+
* | [Max success | [Open duration elapsed]
|
|
127
|
+
* | in half-open] |
|
|
128
|
+
* | v
|
|
129
|
+
* -------------------- HALF-OPEN
|
|
130
|
+
*
|
|
131
|
+
* These properties are configurable
|
|
132
|
+
*/
|
|
133
|
+
if (this.config.circuitBreaker.enabled && !this.circuitBreaker.isAllowed()) {
|
|
134
|
+
await this.circuitBreaker.storeEvent(data);
|
|
135
|
+
logger_1.PUBLISHER_LOGGER.error('Circuit is open, event stored for later processing');
|
|
136
|
+
return 'CIRCUIT_BREAKER_FLOW';
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
tracker.startRedisOperation();
|
|
140
|
+
const consumerGroups = await (0, utils_1.getAllConsumerGroups)(data.eventName, this.redisPublisher);
|
|
141
|
+
tracker.endRedisOperation();
|
|
142
|
+
let key = '*';
|
|
143
|
+
if (consumerGroups.length > 0) {
|
|
144
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
|
|
70
145
|
for (const consumerGroup of consumerGroups) {
|
|
71
146
|
// Publish the event to each consumer group's stream
|
|
72
147
|
const streamName = `${data.eventName}:${consumerGroup}`;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
148
|
+
tracker.startRedisOperation();
|
|
149
|
+
const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
|
|
150
|
+
tracker.endRedisOperation();
|
|
151
|
+
tracker.incrementEventCount();
|
|
152
|
+
if (this.metricsCollector) {
|
|
153
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
154
|
+
}
|
|
76
155
|
if (key === '*')
|
|
77
156
|
key = generatedKey ?? key;
|
|
78
157
|
}
|
|
79
158
|
await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
|
|
159
|
+
await this.circuitBreaker.recordSuccess();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
|
|
80
163
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
164
|
+
const metrics = tracker.getMetrics();
|
|
165
|
+
this.logPerformance(`PTIME;${key};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
|
|
166
|
+
return key;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
|
|
170
|
+
tracker.incrementErrorCount('publish');
|
|
171
|
+
if (this.metricsCollector) {
|
|
172
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
84
173
|
}
|
|
174
|
+
await this.circuitBreaker.recordFailure();
|
|
175
|
+
throw new Error('Publisher Error');
|
|
85
176
|
}
|
|
86
|
-
else
|
|
87
|
-
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
|
|
88
|
-
const publishEndTime = process.hrtime(publishStartTime);
|
|
89
|
-
const elapsedTime = publishEndTime[0] * 1000 + publishEndTime[1] / 1000000;
|
|
90
|
-
logger_1.PERFORMANCE_LOGGER.log(`PTIME;${key};${data.eventName};${Date.now()};${elapsedTime}`);
|
|
91
|
-
return key;
|
|
92
177
|
}
|
|
93
178
|
async scheduledPublish(scheduledTime, eventData, uniquePerInstance = false, repeatInterval = 0, multicast = false) {
|
|
94
179
|
const currentTime = new Date();
|
|
@@ -99,7 +184,7 @@ class Streams {
|
|
|
99
184
|
if (scheduledTime < currentTime) {
|
|
100
185
|
throw new Error('PUBLISHER: Cannot schedule an event in the past');
|
|
101
186
|
}
|
|
102
|
-
else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <=
|
|
187
|
+
else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= this.config.immediatePublishThreshold) {
|
|
103
188
|
await this.publish(eventData, multicast);
|
|
104
189
|
}
|
|
105
190
|
else {
|
|
@@ -107,7 +192,7 @@ class Streams {
|
|
|
107
192
|
if (uniquePerInstance === true) {
|
|
108
193
|
const existingJob = await this.redisPublisher.zscore('se', key);
|
|
109
194
|
if (existingJob) {
|
|
110
|
-
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${eventData}' already exists. Skipping.`);
|
|
195
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${JSON.stringify(eventData)}' already exists. Skipping.`);
|
|
111
196
|
return;
|
|
112
197
|
}
|
|
113
198
|
}
|
|
@@ -141,17 +226,27 @@ class Streams {
|
|
|
141
226
|
* PUBLISHER_LOGGER.log('New order created:', event.data);
|
|
142
227
|
* });
|
|
143
228
|
*/
|
|
144
|
-
listen(eventName,
|
|
145
|
-
|
|
146
|
-
|
|
229
|
+
listen(eventName, listenerOptions) {
|
|
230
|
+
const options = {
|
|
231
|
+
maxRetries: this.config.maxRetries,
|
|
232
|
+
initialDelay: this.config.initialRetryDelay,
|
|
233
|
+
filterKeepAlive: this.config.filterKeepAlive,
|
|
234
|
+
publishOnceGuarantee: false,
|
|
235
|
+
...listenerOptions,
|
|
236
|
+
};
|
|
237
|
+
const subscriptionId = (0, id_1.generateID)('HEX');
|
|
238
|
+
return this.listenInternals(eventName, subscriptionId, options.eventFilter, options.filterKeepAlive, options.publishOnceGuarantee).pipe((0, rxjs_1.retry)({
|
|
239
|
+
count: options.maxRetries,
|
|
147
240
|
delay: (error, retryAttempt) => {
|
|
148
|
-
const delay = initialDelay * Math.pow(2, retryAttempt);
|
|
241
|
+
const delay = options.initialDelay * Math.pow(2, retryAttempt);
|
|
149
242
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
|
|
150
243
|
return (0, rxjs_1.timer)(delay);
|
|
151
244
|
},
|
|
152
245
|
}), (0, rxjs_1.catchError)((error) => {
|
|
153
|
-
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${maxRetries} retries: ${error.message}`);
|
|
246
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${options.maxRetries} retries: ${error.message}`);
|
|
154
247
|
return (0, rxjs_1.throwError)(() => new Error(error.message));
|
|
248
|
+
}), (0, rxjs_1.finalize)(() => {
|
|
249
|
+
this.removeSubscription(eventName, subscriptionId);
|
|
155
250
|
}));
|
|
156
251
|
}
|
|
157
252
|
async createConsumerAndRegister(eventName) {
|
|
@@ -159,41 +254,82 @@ class Streams {
|
|
|
159
254
|
const key = `instance:${this.instanceId}:subscribedEvents`;
|
|
160
255
|
const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
|
|
161
256
|
this.eventsListened.push(eventName);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
257
|
+
try {
|
|
258
|
+
// Check if the consumer group already exists
|
|
259
|
+
const groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
|
|
260
|
+
let groupExists = false;
|
|
261
|
+
for (const group of groupInfo) {
|
|
262
|
+
if (group[1] === this.consumerGroupName) {
|
|
263
|
+
groupExists = true;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!groupExists) {
|
|
268
|
+
await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
|
|
269
|
+
logger_1.PUBLISHER_LOGGER.log(`Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
logger_1.PUBLISHER_LOGGER.log(`Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
168
276
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Group creation failed with error ${e.message} for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
|
|
169
|
-
}
|
|
170
|
-
|
|
277
|
+
}
|
|
278
|
+
// Check if the consumer already exists in the group
|
|
279
|
+
const consumers = (await this.redisGroups.xinfo('CONSUMERS', streamName, this.consumerGroupName));
|
|
280
|
+
let consumerExists = false;
|
|
281
|
+
for (const consumer of consumers) {
|
|
282
|
+
if (consumer[1] === this.instanceId) {
|
|
283
|
+
consumerExists = true;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let createConsumerStatus;
|
|
288
|
+
if (!consumerExists) {
|
|
289
|
+
createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
createConsumerStatus = 0; // Consumer already exists
|
|
293
|
+
logger_1.PUBLISHER_LOGGER.log(`Consumer already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName, instanceId: this.instanceId })}`);
|
|
294
|
+
}
|
|
171
295
|
await this.redisGroups.sadd(key, eventName);
|
|
172
296
|
const addToCGSet = await this.redisGroups.sadd(`${eventName}`, this.consumerGroupName);
|
|
173
297
|
const addToFlushSet = await this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName);
|
|
174
298
|
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 })}`);
|
|
175
299
|
return createConsumerStatus === 0 || createConsumerStatus === 1;
|
|
176
300
|
}
|
|
177
|
-
listenInternals(eventName) {
|
|
301
|
+
listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false) {
|
|
302
|
+
if (!this.subscriptions.has(eventName)) {
|
|
303
|
+
this.subscriptions.set(eventName, new Map());
|
|
304
|
+
}
|
|
178
305
|
const bs = new rxjs_1.BehaviorSubject(null);
|
|
306
|
+
const subscription = {
|
|
307
|
+
subject: bs,
|
|
308
|
+
filter: eventFilter,
|
|
309
|
+
lastMatchTime: Date.now(),
|
|
310
|
+
keepAlive: filterKeepAlive,
|
|
311
|
+
};
|
|
312
|
+
this.subscriptions.get(eventName).set(subscriptionId, subscription);
|
|
179
313
|
const timer = (0, rxjs_1.interval)(10000).subscribe(async () => {
|
|
180
314
|
/** Clear earlier unprocessed messages. Runs every 10 seconds */
|
|
181
|
-
await processMessage(this.redisGroups, '0', false);
|
|
315
|
+
await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
|
|
182
316
|
});
|
|
317
|
+
let lastMatchTime = Date.now();
|
|
183
318
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
184
319
|
/** Cleanup timer */
|
|
185
320
|
timer.unsubscribe();
|
|
186
321
|
}));
|
|
187
322
|
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
188
|
-
const processMessage = async (redisClient, messageId, multicast = false, processPending = false) => {
|
|
323
|
+
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
189
324
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName}`);
|
|
190
325
|
try {
|
|
191
326
|
try {
|
|
192
327
|
/**
|
|
193
328
|
* Check if the message is already acquired by another client and is pending.
|
|
329
|
+
* This check should only happen if the message is not a multicast message
|
|
194
330
|
*/
|
|
195
331
|
const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
|
|
196
|
-
if (pendingDetails[2] === 0 && multicast
|
|
332
|
+
if (pendingDetails[2] === 0 && !multicast) {
|
|
197
333
|
logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: MACK ${messageId} for ${streamName}`);
|
|
198
334
|
return;
|
|
199
335
|
}
|
|
@@ -210,10 +346,16 @@ class Streams {
|
|
|
210
346
|
* are usually behind in the stream so XREADGROUP will not read them and hence
|
|
211
347
|
* they need to be read using XRANGE.
|
|
212
348
|
*/
|
|
213
|
-
|
|
349
|
+
tracker.startRedisOperation();
|
|
350
|
+
if (multicast || processPending) {
|
|
214
351
|
const messages = await redisClient.xrange(streamName, messageId, messageId);
|
|
215
352
|
if (messages && messages.length) {
|
|
216
|
-
|
|
353
|
+
try {
|
|
354
|
+
eventData = JSON.parse(messages[0][1][1]);
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
console.error(`JSON parsing failed for the following message ${messages[0][1][1]} in the publisher.`);
|
|
358
|
+
}
|
|
217
359
|
}
|
|
218
360
|
}
|
|
219
361
|
else {
|
|
@@ -222,28 +364,91 @@ class Streams {
|
|
|
222
364
|
eventData = JSON.parse(messages[0][1][0][1][1]);
|
|
223
365
|
}
|
|
224
366
|
}
|
|
367
|
+
tracker.endRedisOperation();
|
|
368
|
+
tracker.startProcessing();
|
|
225
369
|
if (eventData) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
370
|
+
if (publishOnceGuarantee) {
|
|
371
|
+
if (await this.duplicateChecker.isDuplicate(eventData, this.consumerGroupName)) {
|
|
372
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Duplicate event detected, skipping processing for consumer group ${this.consumerGroupName}`);
|
|
373
|
+
tracker.incrementDuplicateEvent();
|
|
374
|
+
await redisClient.xack(streamName, this.consumerGroupName, messageId);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const subscriptions = this.subscriptions.get(eventName);
|
|
380
|
+
if (subscriptions) {
|
|
381
|
+
const subscriptionEntries = Array.from(subscriptions.entries());
|
|
382
|
+
for (let i = 0; i < subscriptionEntries.length; i++) {
|
|
383
|
+
const [subId, sub] = subscriptionEntries[i];
|
|
384
|
+
if (!sub.filter || sub.filter(eventData)) {
|
|
385
|
+
sub.subject.next(eventData);
|
|
386
|
+
sub.lastMatchTime = Date.now();
|
|
387
|
+
}
|
|
388
|
+
else if (Date.now() - sub.lastMatchTime > sub.keepAlive) {
|
|
389
|
+
/**
|
|
390
|
+
* Reset the lastMatch time every day by default. For now only
|
|
391
|
+
* log the data. Should add functionality to remove the filter
|
|
392
|
+
* if its not used at all to gain minor improvements in
|
|
393
|
+
* performace
|
|
394
|
+
*/
|
|
395
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
|
|
396
|
+
sub.lastMatchTime = Date.now();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
await redisClient.xack(streamName, this.consumerGroupName, messageId);
|
|
401
|
+
await redisClient.zadd(`ack:${streamName}`, Date.now().toString(), messageId);
|
|
402
|
+
}
|
|
403
|
+
catch (processingError) {
|
|
404
|
+
logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, processingError);
|
|
405
|
+
const dlqEvent = {
|
|
406
|
+
...eventData,
|
|
407
|
+
failureReason: processingError.message,
|
|
408
|
+
retryCount: (eventData.retryCount || 0) + 1,
|
|
409
|
+
originalStream: streamName,
|
|
410
|
+
consumerGroupName: this.consumerGroupName,
|
|
411
|
+
timestamp: Date.now(),
|
|
412
|
+
};
|
|
413
|
+
await this.dlq.addToDLQ(dlqEvent);
|
|
414
|
+
}
|
|
229
415
|
}
|
|
230
416
|
else {
|
|
231
417
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message ${messageId} not found for ${streamName}`);
|
|
232
418
|
}
|
|
419
|
+
tracker.endProcessing();
|
|
233
420
|
/** Process Unprocessed Message if this is a main tree, otherwise limit to processing 100 messages that are unacknowledged */
|
|
234
421
|
if (!processPending) {
|
|
235
422
|
const unprocessedMessageIds = await (0, utils_1.getUnacknowledgedMessages)(redisClient, this.consumerGroupName, streamName, this.instanceId);
|
|
236
|
-
if (unprocessedMessageIds.countOnThisConsumer &&
|
|
423
|
+
if (unprocessedMessageIds.countOnThisConsumer &&
|
|
424
|
+
unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
|
|
237
425
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
|
|
238
426
|
}
|
|
239
427
|
for (const id of unprocessedMessageIds.messageIds) {
|
|
240
|
-
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER:
|
|
241
|
-
await processMessage(redisClient, id, multicast, true);
|
|
428
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${id}`);
|
|
429
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
242
430
|
}
|
|
243
431
|
}
|
|
244
432
|
}
|
|
245
433
|
catch (e) {
|
|
246
434
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing message ${messageId} for ${streamName}`, e);
|
|
435
|
+
if (!multicast) {
|
|
436
|
+
const dlqEvent = {
|
|
437
|
+
eventId: messageId,
|
|
438
|
+
eventName: streamName.split(':')[0],
|
|
439
|
+
data: {},
|
|
440
|
+
failureReason: e.message,
|
|
441
|
+
retryCount: 1,
|
|
442
|
+
originalStream: streamName,
|
|
443
|
+
timestamp: Date.now(),
|
|
444
|
+
consumerGroupName: this.consumerGroupName,
|
|
445
|
+
};
|
|
446
|
+
await this.dlq.addToDLQ(dlqEvent);
|
|
447
|
+
tracker.incrementErrorCount('subscribe');
|
|
448
|
+
if (this.metricsCollector) {
|
|
449
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
450
|
+
}
|
|
451
|
+
}
|
|
247
452
|
}
|
|
248
453
|
};
|
|
249
454
|
/** Register the consumer and setup the Observable */
|
|
@@ -256,13 +461,24 @@ class Streams {
|
|
|
256
461
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Redis Subscription connection initiated for ${eventName}`);
|
|
257
462
|
});
|
|
258
463
|
eventStreamClient.on('message', async (channel, data) => {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
464
|
+
const tracker = new tracker_1.MetricsTracker();
|
|
465
|
+
let messageIdRead, multicastRead;
|
|
466
|
+
try {
|
|
467
|
+
const { messageId, multicast } = JSON.parse(data);
|
|
468
|
+
messageIdRead = messageId;
|
|
469
|
+
multicastRead = multicast;
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
messageIdRead = data;
|
|
473
|
+
multicastRead = false;
|
|
474
|
+
}
|
|
475
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream Notification Received for event ${eventName} with message ID ${messageIdRead}`);
|
|
476
|
+
await processMessage(this.redisGroups, messageIdRead, tracker, multicastRead);
|
|
477
|
+
const metrics = tracker.getMetrics();
|
|
478
|
+
logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
|
|
479
|
+
if (this.metricsCollector) {
|
|
480
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
481
|
+
}
|
|
266
482
|
});
|
|
267
483
|
})
|
|
268
484
|
.catch((e) => {
|
|
@@ -291,27 +507,34 @@ class Streams {
|
|
|
291
507
|
* }
|
|
292
508
|
*/
|
|
293
509
|
async close() {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
510
|
+
try {
|
|
511
|
+
if (this.cleanUpTimer) {
|
|
512
|
+
clearInterval(this.cleanUpTimer);
|
|
513
|
+
}
|
|
514
|
+
if (this._redisPublisher) {
|
|
515
|
+
await this._redisPublisher.quit();
|
|
516
|
+
}
|
|
517
|
+
for (const eventName of this.eventsListened) {
|
|
518
|
+
await registry_1.RedisRegistry.getConnection(`sub-${eventName}`).quit();
|
|
519
|
+
}
|
|
520
|
+
if (this._redisGroups) {
|
|
521
|
+
await this._redisGroups.quit();
|
|
522
|
+
}
|
|
302
523
|
}
|
|
303
|
-
|
|
304
|
-
|
|
524
|
+
catch (error) {
|
|
525
|
+
logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
|
|
305
526
|
}
|
|
306
527
|
}
|
|
307
|
-
async cleanupAcknowledgedMessages(eventName, interval =
|
|
528
|
+
async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
|
|
308
529
|
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
309
530
|
const cleanupThreshold = Date.now() - interval;
|
|
310
531
|
const acknowledgedMessages = await this.redisGroups.zrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
|
|
311
532
|
if (acknowledgedMessages && acknowledgedMessages.length > 0) {
|
|
312
|
-
// Remove acknowledged messages from the stream
|
|
313
|
-
|
|
314
|
-
|
|
533
|
+
// Remove acknowledged messages from the stream in batches
|
|
534
|
+
const batchSize = 100;
|
|
535
|
+
for (let i = 0; i < acknowledgedMessages.length; i += batchSize) {
|
|
536
|
+
const batch = acknowledgedMessages.slice(i, i + batchSize);
|
|
537
|
+
await this.redisGroups.xdel(streamName, ...batch);
|
|
315
538
|
}
|
|
316
539
|
// Remove acknowledged messages from the Sorted Set
|
|
317
540
|
await this.redisGroups.zremrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
|
|
@@ -333,5 +556,99 @@ class Streams {
|
|
|
333
556
|
const returnData = await await Promise.all(tempPromises);
|
|
334
557
|
return { status: 'SUCCESS', data: returnData, message: 'We recommend not running this in times of heavy load' };
|
|
335
558
|
}
|
|
559
|
+
logPerformance(message) {
|
|
560
|
+
if (this.config.performanceLogger) {
|
|
561
|
+
this.config.performanceLogger(message);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
logger_1.PERFORMANCE_LOGGER.log(message);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* @description
|
|
569
|
+
* This method is use to retry an event that has ended in the dead letter queue,
|
|
570
|
+
* which happens after the first retry.
|
|
571
|
+
*/
|
|
572
|
+
async retryFromDLQ(eventId) {
|
|
573
|
+
return this.dlq.retryFromDLQ(eventId);
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* @description
|
|
577
|
+
* This returns the number of items and the rate at which events are added
|
|
578
|
+
* to the queue. The queue is global and hence remains as is
|
|
579
|
+
*/
|
|
580
|
+
async getDLQStats() {
|
|
581
|
+
return this.dlq.getDLQStats();
|
|
582
|
+
}
|
|
583
|
+
removeSubscription(eventName, subscriptionId) {
|
|
584
|
+
const eventSubscriptions = this.subscriptions.get(eventName);
|
|
585
|
+
if (eventSubscriptions) {
|
|
586
|
+
eventSubscriptions.delete(subscriptionId);
|
|
587
|
+
if (eventSubscriptions.size === 0) {
|
|
588
|
+
this.subscriptions.delete(eventName);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* @description
|
|
594
|
+
* This is a simple helper utility that can be used externally to create alerts based
|
|
595
|
+
* on thresholds that can be provided into the function. It returns true/false for each
|
|
596
|
+
* key that is provided. Not all keys are required
|
|
597
|
+
*/
|
|
598
|
+
async checkThresholds(thresholds) {
|
|
599
|
+
if (!this.metricsCollector) {
|
|
600
|
+
throw new Error('Metrics collection is not enabled');
|
|
601
|
+
}
|
|
602
|
+
const latestMetrics = await this.metricsCollector.getLatestMetrics();
|
|
603
|
+
if (!latestMetrics) {
|
|
604
|
+
throw new Error('No metrics available');
|
|
605
|
+
}
|
|
606
|
+
const alerts = {};
|
|
607
|
+
for (const [key, threshold] of Object.entries(thresholds)) {
|
|
608
|
+
if (key in latestMetrics) {
|
|
609
|
+
alerts[key] = latestMetrics[key] > threshold;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return alerts;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* @description
|
|
616
|
+
* This will return you the stats of the publisher for the last 6 hours after cleaning
|
|
617
|
+
*/
|
|
618
|
+
async getMetrics(startTime, endTime) {
|
|
619
|
+
return this.metricsCollector.getMetrics(startTime, endTime);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* @description
|
|
623
|
+
* This will return you the latest stats of the publisher
|
|
624
|
+
*/
|
|
625
|
+
async getLatestMetrics() {
|
|
626
|
+
return this.metricsCollector.getLatestMetrics();
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* @description
|
|
630
|
+
* This returns the status of the performance control setup. This includes
|
|
631
|
+
* the circuit breaker
|
|
632
|
+
*/
|
|
633
|
+
async getPerformanceControlStatus() {
|
|
634
|
+
const circuitBreakerState = this.circuitBreaker.getState();
|
|
635
|
+
return {
|
|
636
|
+
circuitBreakerState,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* @description
|
|
641
|
+
* This is a manual control to process stored events in case the
|
|
642
|
+
* circuit is OPEN
|
|
643
|
+
*/
|
|
644
|
+
async processStoredEvents() {
|
|
645
|
+
if (this.circuitBreaker.getState() === circuit_breaker_1.CircuitState.CLOSED) {
|
|
646
|
+
const storedEvents = await this.circuitBreaker.getStoredEvents();
|
|
647
|
+
for (const event of storedEvents) {
|
|
648
|
+
await this.publish(event);
|
|
649
|
+
}
|
|
650
|
+
await this.circuitBreaker.clearStoredEvents();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
336
653
|
}
|
|
337
654
|
exports.Streams = Streams;
|