@jetit/publisher 4.1.1 → 5.1.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 +249 -171
- package/package.json +3 -2
- package/src/lib/monitoring/adapters/prom.d.ts +42 -0
- package/src/lib/monitoring/adapters/prom.js +179 -0
- package/src/lib/monitoring/collector.d.ts +22 -0
- package/src/lib/monitoring/collector.js +163 -0
- package/src/lib/monitoring/tracker.d.ts +20 -0
- package/src/lib/monitoring/tracker.js +106 -0
- package/src/lib/monitoring/types.d.ts +58 -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 +30 -0
- package/src/lib/redis/scheduler.js +1 -1
- package/src/lib/redis/streams.d.ts +61 -3
- package/src/lib/redis/streams.js +407 -77
- 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,145 @@ 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: 10000,
|
|
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
|
+
tracker.incrementMessageRate('publish', data.eventName);
|
|
153
|
+
if (this.metricsCollector) {
|
|
154
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
155
|
+
}
|
|
76
156
|
if (key === '*')
|
|
77
157
|
key = generatedKey ?? key;
|
|
78
158
|
}
|
|
79
159
|
await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
|
|
160
|
+
await this.circuitBreaker.recordSuccess();
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Event publish failed for event ${data.eventName}, reason: no consumers ${consumerGroups}`);
|
|
80
164
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
165
|
+
const metrics = tracker.getMetrics();
|
|
166
|
+
this.logPerformance(`PTIME;${key};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
|
|
167
|
+
return key;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
|
|
171
|
+
tracker.incrementErrorCount('publish');
|
|
172
|
+
if (this.metricsCollector) {
|
|
173
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
84
174
|
}
|
|
175
|
+
await this.circuitBreaker.recordFailure();
|
|
176
|
+
throw new Error('Publisher Error');
|
|
85
177
|
}
|
|
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
178
|
}
|
|
93
179
|
async scheduledPublish(scheduledTime, eventData, uniquePerInstance = false, repeatInterval = 0, multicast = false) {
|
|
94
180
|
const currentTime = new Date();
|
|
@@ -99,7 +185,7 @@ class Streams {
|
|
|
99
185
|
if (scheduledTime < currentTime) {
|
|
100
186
|
throw new Error('PUBLISHER: Cannot schedule an event in the past');
|
|
101
187
|
}
|
|
102
|
-
else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <=
|
|
188
|
+
else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= this.config.immediatePublishThreshold) {
|
|
103
189
|
await this.publish(eventData, multicast);
|
|
104
190
|
}
|
|
105
191
|
else {
|
|
@@ -107,7 +193,7 @@ class Streams {
|
|
|
107
193
|
if (uniquePerInstance === true) {
|
|
108
194
|
const existingJob = await this.redisPublisher.zscore('se', key);
|
|
109
195
|
if (existingJob) {
|
|
110
|
-
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${eventData}' already exists. Skipping.`);
|
|
196
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Job with data '${JSON.stringify(eventData)}' already exists. Skipping.`);
|
|
111
197
|
return;
|
|
112
198
|
}
|
|
113
199
|
}
|
|
@@ -141,17 +227,27 @@ class Streams {
|
|
|
141
227
|
* PUBLISHER_LOGGER.log('New order created:', event.data);
|
|
142
228
|
* });
|
|
143
229
|
*/
|
|
144
|
-
listen(eventName,
|
|
145
|
-
|
|
146
|
-
|
|
230
|
+
listen(eventName, listenerOptions) {
|
|
231
|
+
const options = {
|
|
232
|
+
maxRetries: this.config.maxRetries,
|
|
233
|
+
initialDelay: this.config.initialRetryDelay,
|
|
234
|
+
filterKeepAlive: this.config.filterKeepAlive,
|
|
235
|
+
publishOnceGuarantee: false,
|
|
236
|
+
...listenerOptions,
|
|
237
|
+
};
|
|
238
|
+
const subscriptionId = (0, id_1.generateID)('HEX');
|
|
239
|
+
return this.listenInternals(eventName, subscriptionId, options.eventFilter, options.filterKeepAlive, options.publishOnceGuarantee).pipe((0, rxjs_1.retry)({
|
|
240
|
+
count: options.maxRetries,
|
|
147
241
|
delay: (error, retryAttempt) => {
|
|
148
|
-
const delay = initialDelay * Math.pow(2, retryAttempt);
|
|
242
|
+
const delay = options.initialDelay * Math.pow(2, retryAttempt);
|
|
149
243
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
|
|
150
244
|
return (0, rxjs_1.timer)(delay);
|
|
151
245
|
},
|
|
152
246
|
}), (0, rxjs_1.catchError)((error) => {
|
|
153
|
-
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${maxRetries} retries: ${error.message}`);
|
|
247
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${options.maxRetries} retries: ${error.message}`);
|
|
154
248
|
return (0, rxjs_1.throwError)(() => new Error(error.message));
|
|
249
|
+
}), (0, rxjs_1.finalize)(() => {
|
|
250
|
+
this.removeSubscription(eventName, subscriptionId);
|
|
155
251
|
}));
|
|
156
252
|
}
|
|
157
253
|
async createConsumerAndRegister(eventName) {
|
|
@@ -159,41 +255,94 @@ class Streams {
|
|
|
159
255
|
const key = `instance:${this.instanceId}:subscribedEvents`;
|
|
160
256
|
const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
|
|
161
257
|
this.eventsListened.push(eventName);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
258
|
+
try {
|
|
259
|
+
// Check if the consumer group already exists
|
|
260
|
+
let groupInfo = [];
|
|
261
|
+
try {
|
|
262
|
+
groupInfo = (await this.redisGroups.xinfo('GROUPS', streamName));
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
// Do nothing
|
|
266
|
+
}
|
|
267
|
+
let groupExists = false;
|
|
268
|
+
for (const group of groupInfo) {
|
|
269
|
+
if (group[1] === this.consumerGroupName) {
|
|
270
|
+
groupExists = true;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!groupExists) {
|
|
275
|
+
await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
|
|
276
|
+
logger_1.PUBLISHER_LOGGER.log(`Group created for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
logger_1.PUBLISHER_LOGGER.log(`Group already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
168
283
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Group creation failed with error ${e.message} for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}`);
|
|
169
|
-
}
|
|
170
|
-
|
|
284
|
+
}
|
|
285
|
+
// Check if the consumer already exists in the group
|
|
286
|
+
let consumers = [];
|
|
287
|
+
try {
|
|
288
|
+
consumers = (await this.redisGroups.xinfo('CONSUMERS', streamName, this.consumerGroupName));
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
// Do nothing
|
|
292
|
+
}
|
|
293
|
+
let consumerExists = false;
|
|
294
|
+
for (const consumer of consumers) {
|
|
295
|
+
if (consumer[1] === this.instanceId) {
|
|
296
|
+
consumerExists = true;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
let createConsumerStatus;
|
|
301
|
+
if (!consumerExists) {
|
|
302
|
+
createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
createConsumerStatus = 0; // Consumer already exists
|
|
306
|
+
logger_1.PUBLISHER_LOGGER.log(`Consumer already exists for ${JSON.stringify({ streamName, cgn: this.consumerGroupName, instanceId: this.instanceId })}`);
|
|
307
|
+
}
|
|
171
308
|
await this.redisGroups.sadd(key, eventName);
|
|
172
309
|
const addToCGSet = await this.redisGroups.sadd(`${eventName}`, this.consumerGroupName);
|
|
173
310
|
const addToFlushSet = await this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName);
|
|
174
311
|
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
312
|
return createConsumerStatus === 0 || createConsumerStatus === 1;
|
|
176
313
|
}
|
|
177
|
-
listenInternals(eventName) {
|
|
314
|
+
listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false) {
|
|
315
|
+
if (!this.subscriptions.has(eventName)) {
|
|
316
|
+
this.subscriptions.set(eventName, new Map());
|
|
317
|
+
}
|
|
178
318
|
const bs = new rxjs_1.BehaviorSubject(null);
|
|
319
|
+
const subscription = {
|
|
320
|
+
subject: bs,
|
|
321
|
+
filter: eventFilter,
|
|
322
|
+
lastMatchTime: Date.now(),
|
|
323
|
+
keepAlive: filterKeepAlive,
|
|
324
|
+
};
|
|
325
|
+
this.subscriptions.get(eventName).set(subscriptionId, subscription);
|
|
179
326
|
const timer = (0, rxjs_1.interval)(10000).subscribe(async () => {
|
|
180
327
|
/** Clear earlier unprocessed messages. Runs every 10 seconds */
|
|
181
|
-
await processMessage(this.redisGroups, '0', false);
|
|
328
|
+
await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
|
|
182
329
|
});
|
|
330
|
+
let lastMatchTime = Date.now();
|
|
183
331
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
184
332
|
/** Cleanup timer */
|
|
185
333
|
timer.unsubscribe();
|
|
186
334
|
}));
|
|
187
335
|
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
188
|
-
const processMessage = async (redisClient, messageId, multicast = false, processPending = false) => {
|
|
336
|
+
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
189
337
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName}`);
|
|
190
338
|
try {
|
|
191
339
|
try {
|
|
192
340
|
/**
|
|
193
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
|
|
194
343
|
*/
|
|
195
344
|
const pendingDetails = await redisClient.xpending(streamName, this.consumerGroupName, messageId, messageId, 1);
|
|
196
|
-
if (pendingDetails[2] === 0 && multicast
|
|
345
|
+
if (pendingDetails[2] === 0 && !multicast) {
|
|
197
346
|
logger_1.PUBLISHER_LOGGER.warn(`PUBLISHER: MACK ${messageId} for ${streamName}`);
|
|
198
347
|
return;
|
|
199
348
|
}
|
|
@@ -210,40 +359,118 @@ class Streams {
|
|
|
210
359
|
* are usually behind in the stream so XREADGROUP will not read them and hence
|
|
211
360
|
* they need to be read using XRANGE.
|
|
212
361
|
*/
|
|
213
|
-
|
|
362
|
+
tracker.startRedisOperation();
|
|
363
|
+
if (multicast || processPending) {
|
|
214
364
|
const messages = await redisClient.xrange(streamName, messageId, messageId);
|
|
215
365
|
if (messages && messages.length) {
|
|
216
|
-
|
|
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.`);
|
|
371
|
+
}
|
|
217
372
|
}
|
|
218
373
|
}
|
|
219
374
|
else {
|
|
220
375
|
const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
|
|
221
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}`);
|
|
380
|
+
}
|
|
222
381
|
eventData = JSON.parse(messages[0][1][0][1][1]);
|
|
223
382
|
}
|
|
224
383
|
}
|
|
384
|
+
tracker.endRedisOperation();
|
|
385
|
+
tracker.startProcessing();
|
|
225
386
|
if (eventData) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
387
|
+
if (publishOnceGuarantee) {
|
|
388
|
+
if (await this.duplicateChecker.isDuplicate(eventData, this.consumerGroupName)) {
|
|
389
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Duplicate event detected, skipping processing for consumer group ${this.consumerGroupName}`);
|
|
390
|
+
tracker.incrementDuplicateEvent();
|
|
391
|
+
await redisClient.xack(streamName, this.consumerGroupName, messageId);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const subscriptions = this.subscriptions.get(eventName);
|
|
397
|
+
if (subscriptions) {
|
|
398
|
+
const subscriptionEntries = Array.from(subscriptions.entries());
|
|
399
|
+
for (let i = 0; i < subscriptionEntries.length; i++) {
|
|
400
|
+
const [subId, sub] = subscriptionEntries[i];
|
|
401
|
+
if (!sub.filter || sub.filter(eventData)) {
|
|
402
|
+
sub.subject.next(eventData);
|
|
403
|
+
sub.lastMatchTime = Date.now();
|
|
404
|
+
}
|
|
405
|
+
else if (Date.now() - sub.lastMatchTime > sub.keepAlive) {
|
|
406
|
+
/**
|
|
407
|
+
* Reset the lastMatch time every day by default. For now only
|
|
408
|
+
* log the data. Should add functionality to remove the filter
|
|
409
|
+
* if its not used at all to gain minor improvements in
|
|
410
|
+
* performace
|
|
411
|
+
*/
|
|
412
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
|
|
413
|
+
sub.lastMatchTime = Date.now();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
await redisClient.xack(streamName, this.consumerGroupName, messageId);
|
|
418
|
+
await redisClient.zadd(`ack:${streamName}`, Date.now().toString(), messageId);
|
|
419
|
+
}
|
|
420
|
+
catch (processingError) {
|
|
421
|
+
logger_1.PUBLISHER_LOGGER.error(`Processing error for message ${messageId}:`, processingError);
|
|
422
|
+
const dlqEvent = {
|
|
423
|
+
...eventData,
|
|
424
|
+
failureReason: processingError.message,
|
|
425
|
+
retryCount: (eventData.retryCount || 0) + 1,
|
|
426
|
+
originalStream: streamName,
|
|
427
|
+
consumerGroupName: this.consumerGroupName,
|
|
428
|
+
timestamp: Date.now(),
|
|
429
|
+
};
|
|
430
|
+
await this.dlq.addToDLQ(dlqEvent);
|
|
431
|
+
}
|
|
432
|
+
tracker.incrementMessageRate('subscribe', eventData.eventName);
|
|
433
|
+
const processingTime = Date.now() - eventData.createdAt;
|
|
434
|
+
tracker.addProcessingTime(processingTime);
|
|
435
|
+
const lag = Date.now() - eventData.createdAt;
|
|
436
|
+
tracker.setConsumerLag(this.consumerGroupName, lag);
|
|
229
437
|
}
|
|
230
438
|
else {
|
|
231
439
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Message ${messageId} not found for ${streamName}`);
|
|
232
440
|
}
|
|
441
|
+
tracker.endProcessing();
|
|
233
442
|
/** Process Unprocessed Message if this is a main tree, otherwise limit to processing 100 messages that are unacknowledged */
|
|
234
443
|
if (!processPending) {
|
|
235
444
|
const unprocessedMessageIds = await (0, utils_1.getUnacknowledgedMessages)(redisClient, this.consumerGroupName, streamName, this.instanceId);
|
|
236
|
-
if (unprocessedMessageIds.countOnThisConsumer &&
|
|
445
|
+
if (unprocessedMessageIds.countOnThisConsumer &&
|
|
446
|
+
unprocessedMessageIds.countOnThisConsumer > this.config.unprocessedMessageThreshold) {
|
|
237
447
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Too many unprocessed events for ${streamName}: count: ${unprocessedMessageIds.count}`);
|
|
238
448
|
}
|
|
239
449
|
for (const id of unprocessedMessageIds.messageIds) {
|
|
240
|
-
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER:
|
|
241
|
-
await processMessage(redisClient, id, multicast, true);
|
|
450
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reprocessing unprocessed message with id: ${id}`);
|
|
451
|
+
await processMessage(redisClient, id, new tracker_1.MetricsTracker(), multicast, true);
|
|
242
452
|
}
|
|
243
453
|
}
|
|
244
454
|
}
|
|
245
455
|
catch (e) {
|
|
246
456
|
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing message ${messageId} for ${streamName}`, e);
|
|
457
|
+
if (!multicast) {
|
|
458
|
+
const dlqEvent = {
|
|
459
|
+
eventId: messageId,
|
|
460
|
+
eventName: streamName.split(':')[0],
|
|
461
|
+
data: {},
|
|
462
|
+
failureReason: e.message,
|
|
463
|
+
retryCount: 1,
|
|
464
|
+
originalStream: streamName,
|
|
465
|
+
timestamp: Date.now(),
|
|
466
|
+
consumerGroupName: this.consumerGroupName,
|
|
467
|
+
};
|
|
468
|
+
await this.dlq.addToDLQ(dlqEvent);
|
|
469
|
+
tracker.incrementErrorCount('subscribe');
|
|
470
|
+
if (this.metricsCollector) {
|
|
471
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
472
|
+
}
|
|
473
|
+
}
|
|
247
474
|
}
|
|
248
475
|
};
|
|
249
476
|
/** Register the consumer and setup the Observable */
|
|
@@ -256,7 +483,7 @@ class Streams {
|
|
|
256
483
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Redis Subscription connection initiated for ${eventName}`);
|
|
257
484
|
});
|
|
258
485
|
eventStreamClient.on('message', async (channel, data) => {
|
|
259
|
-
const
|
|
486
|
+
const tracker = new tracker_1.MetricsTracker();
|
|
260
487
|
let messageIdRead, multicastRead;
|
|
261
488
|
try {
|
|
262
489
|
const { messageId, multicast } = JSON.parse(data);
|
|
@@ -268,10 +495,12 @@ class Streams {
|
|
|
268
495
|
multicastRead = false;
|
|
269
496
|
}
|
|
270
497
|
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream Notification Received for event ${eventName} with message ID ${messageIdRead}`);
|
|
271
|
-
await processMessage(this.redisGroups, messageIdRead, multicastRead);
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
498
|
+
await processMessage(this.redisGroups, messageIdRead, tracker, multicastRead);
|
|
499
|
+
const metrics = tracker.getMetrics();
|
|
500
|
+
logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
|
|
501
|
+
if (this.metricsCollector) {
|
|
502
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
503
|
+
}
|
|
275
504
|
});
|
|
276
505
|
})
|
|
277
506
|
.catch((e) => {
|
|
@@ -300,27 +529,34 @@ class Streams {
|
|
|
300
529
|
* }
|
|
301
530
|
*/
|
|
302
531
|
async close() {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
532
|
+
try {
|
|
533
|
+
if (this.cleanUpTimer) {
|
|
534
|
+
clearInterval(this.cleanUpTimer);
|
|
535
|
+
}
|
|
536
|
+
if (this._redisPublisher) {
|
|
537
|
+
await this._redisPublisher.quit();
|
|
538
|
+
}
|
|
539
|
+
for (const eventName of this.eventsListened) {
|
|
540
|
+
await registry_1.RedisRegistry.getConnection(`sub-${eventName}`).quit();
|
|
541
|
+
}
|
|
542
|
+
if (this._redisGroups) {
|
|
543
|
+
await this._redisGroups.quit();
|
|
544
|
+
}
|
|
311
545
|
}
|
|
312
|
-
|
|
313
|
-
|
|
546
|
+
catch (error) {
|
|
547
|
+
logger_1.PUBLISHER_LOGGER.error('Error during cleanup:', error);
|
|
314
548
|
}
|
|
315
549
|
}
|
|
316
|
-
async cleanupAcknowledgedMessages(eventName, interval =
|
|
550
|
+
async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
|
|
317
551
|
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
318
552
|
const cleanupThreshold = Date.now() - interval;
|
|
319
553
|
const acknowledgedMessages = await this.redisGroups.zrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
|
|
320
554
|
if (acknowledgedMessages && acknowledgedMessages.length > 0) {
|
|
321
|
-
// Remove acknowledged messages from the stream
|
|
322
|
-
|
|
323
|
-
|
|
555
|
+
// Remove acknowledged messages from the stream in batches
|
|
556
|
+
const batchSize = 100;
|
|
557
|
+
for (let i = 0; i < acknowledgedMessages.length; i += batchSize) {
|
|
558
|
+
const batch = acknowledgedMessages.slice(i, i + batchSize);
|
|
559
|
+
await this.redisGroups.xdel(streamName, ...batch);
|
|
324
560
|
}
|
|
325
561
|
// Remove acknowledged messages from the Sorted Set
|
|
326
562
|
await this.redisGroups.zremrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
|
|
@@ -342,5 +578,99 @@ class Streams {
|
|
|
342
578
|
const returnData = await await Promise.all(tempPromises);
|
|
343
579
|
return { status: 'SUCCESS', data: returnData, message: 'We recommend not running this in times of heavy load' };
|
|
344
580
|
}
|
|
581
|
+
logPerformance(message) {
|
|
582
|
+
if (this.config.performanceLogger) {
|
|
583
|
+
this.config.performanceLogger(message);
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
logger_1.PERFORMANCE_LOGGER.log(message);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* @description
|
|
591
|
+
* This method is use to retry an event that has ended in the dead letter queue,
|
|
592
|
+
* which happens after the first retry.
|
|
593
|
+
*/
|
|
594
|
+
async retryFromDLQ(eventId) {
|
|
595
|
+
return this.dlq.retryFromDLQ(eventId);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* @description
|
|
599
|
+
* This returns the number of items and the rate at which events are added
|
|
600
|
+
* to the queue. The queue is global and hence remains as is
|
|
601
|
+
*/
|
|
602
|
+
async getDLQStats() {
|
|
603
|
+
return this.dlq.getDLQStats();
|
|
604
|
+
}
|
|
605
|
+
removeSubscription(eventName, subscriptionId) {
|
|
606
|
+
const eventSubscriptions = this.subscriptions.get(eventName);
|
|
607
|
+
if (eventSubscriptions) {
|
|
608
|
+
eventSubscriptions.delete(subscriptionId);
|
|
609
|
+
if (eventSubscriptions.size === 0) {
|
|
610
|
+
this.subscriptions.delete(eventName);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* @description
|
|
616
|
+
* This is a simple helper utility that can be used externally to create alerts based
|
|
617
|
+
* on thresholds that can be provided into the function. It returns true/false for each
|
|
618
|
+
* key that is provided. Not all keys are required
|
|
619
|
+
*/
|
|
620
|
+
async checkThresholds(thresholds) {
|
|
621
|
+
if (!this.metricsCollector) {
|
|
622
|
+
throw new Error('Metrics collection is not enabled');
|
|
623
|
+
}
|
|
624
|
+
const latestMetrics = await this.metricsCollector.getLatestMetrics();
|
|
625
|
+
if (!latestMetrics) {
|
|
626
|
+
throw new Error('No metrics available');
|
|
627
|
+
}
|
|
628
|
+
const alerts = {};
|
|
629
|
+
for (const [key, threshold] of Object.entries(thresholds)) {
|
|
630
|
+
if (key in latestMetrics) {
|
|
631
|
+
alerts[key] = latestMetrics[key] > threshold;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return alerts;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* @description
|
|
638
|
+
* This will return you the stats of the publisher for the last 6 hours after cleaning
|
|
639
|
+
*/
|
|
640
|
+
async getMetrics(startTime, endTime) {
|
|
641
|
+
return this.metricsCollector.getMetrics(startTime, endTime);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* @description
|
|
645
|
+
* This will return you the latest stats of the publisher
|
|
646
|
+
*/
|
|
647
|
+
async getLatestMetrics() {
|
|
648
|
+
return this.metricsCollector.getLatestMetrics();
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* @description
|
|
652
|
+
* This returns the status of the performance control setup. This includes
|
|
653
|
+
* the circuit breaker
|
|
654
|
+
*/
|
|
655
|
+
async getPerformanceControlStatus() {
|
|
656
|
+
const circuitBreakerState = this.circuitBreaker.getState();
|
|
657
|
+
return {
|
|
658
|
+
circuitBreakerState,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* @description
|
|
663
|
+
* This is a manual control to process stored events in case the
|
|
664
|
+
* circuit is OPEN
|
|
665
|
+
*/
|
|
666
|
+
async processStoredEvents() {
|
|
667
|
+
if (this.circuitBreaker.getState() === circuit_breaker_1.CircuitState.CLOSED) {
|
|
668
|
+
const storedEvents = await this.circuitBreaker.getStoredEvents();
|
|
669
|
+
for (const event of storedEvents) {
|
|
670
|
+
await this.publish(event);
|
|
671
|
+
}
|
|
672
|
+
await this.circuitBreaker.clearStoredEvents();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
345
675
|
}
|
|
346
676
|
exports.Streams = Streams;
|