@jetit/publisher 5.6.1 → 6.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 +66 -0
- package/package.json +7 -4
- package/src/lib/monitoring/adapters/prom.js +8 -4
- package/src/lib/monitoring/tracker.js +1 -1
- package/src/lib/publisher.d.ts +1 -0
- package/src/lib/publisher.js +3 -1
- package/src/lib/redis/scheduler.js +1 -1
- package/src/lib/redis/streams-lite.d.ts +187 -0
- package/src/lib/redis/streams-lite.js +734 -0
- package/src/lib/redis/streams.d.ts +24 -0
- package/src/lib/redis/streams.js +342 -48
- package/src/lib/redis/types.d.ts +2 -0
- package/src/lib/redis/utils.js +1 -1
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StreamsLite = void 0;
|
|
4
|
+
const id_1 = require("@jetit/id");
|
|
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");
|
|
11
|
+
const logger_1 = require("./logger");
|
|
12
|
+
const registry_1 = require("./registry");
|
|
13
|
+
const utils_1 = require("./utils");
|
|
14
|
+
class StreamsLite {
|
|
15
|
+
// Removed Lua script and related properties (findHighestStreamIdScript, findHighestStreamIdScriptSHA, optimizationActiveUntil)
|
|
16
|
+
get redisPublisher() {
|
|
17
|
+
if (!this._redisPublisher)
|
|
18
|
+
this._redisPublisher = registry_1.RedisRegistry.getConnection(this.redisConnectionId, 'publish');
|
|
19
|
+
return this._redisPublisher;
|
|
20
|
+
}
|
|
21
|
+
get redisGroups() {
|
|
22
|
+
if (!this._redisGroups)
|
|
23
|
+
this._redisGroups = registry_1.RedisRegistry.getConnection(this.redisConnectionId, 'groups');
|
|
24
|
+
return this._redisGroups;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new Streams instance for a given service.
|
|
28
|
+
*
|
|
29
|
+
* The constructor initializes the Redis connections for publishers, subscribers, and consumer groups.
|
|
30
|
+
* It also sets up an interval timer for clearing expired messages from Redis and another interval timer
|
|
31
|
+
* for processing scheduled events at regular intervals.
|
|
32
|
+
*
|
|
33
|
+
* @param serviceName - A unique name for the service that will be using this Streams instance.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
*
|
|
37
|
+
* // Create a new Streams instance for the "POS" service
|
|
38
|
+
* const streams = new Streams('POS');
|
|
39
|
+
*/
|
|
40
|
+
constructor(serviceName, config = {}, redisConnectionId = 'default') {
|
|
41
|
+
this.redisConnectionId = redisConnectionId;
|
|
42
|
+
this.eventsListened = [];
|
|
43
|
+
this.subscriptions = new Map();
|
|
44
|
+
this.DEFAULT_STREAMS_CONFIG = {
|
|
45
|
+
immediatePublishThreshold: 500,
|
|
46
|
+
unprocessedMessageThreshold: 25,
|
|
47
|
+
acknowledgedMessageCleanupInterval: 60 * 60 * 1000, // 1 hour
|
|
48
|
+
cleanUpInterval: 1000 * 60 * 60,
|
|
49
|
+
dlqEventThreshold: 2000,
|
|
50
|
+
filterKeepAlive: 24 * 60 * 60 * 1000,
|
|
51
|
+
duplicationCheckWindow: 84600,
|
|
52
|
+
circuitBreaker: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
errorThreshold: 50,
|
|
55
|
+
errorThresholdPercentage: 50,
|
|
56
|
+
openStateDuration: 30000,
|
|
57
|
+
halfOpenStateMaxAttempts: 10,
|
|
58
|
+
maxStoredEvents: 10000,
|
|
59
|
+
},
|
|
60
|
+
// New defaults
|
|
61
|
+
optimizationDurationMs: 2 * 60 * 1000, // 2 minutes
|
|
62
|
+
optimizationThreshold: 20, // Enable optimization for >20 consumer groups
|
|
63
|
+
};
|
|
64
|
+
/** Initialise Config properties */
|
|
65
|
+
this.config = { ...this.config, ...this.DEFAULT_STREAMS_CONFIG, ...config };
|
|
66
|
+
this.instanceUniqueId = process.env['INSTANCE_ID'] ?? (0, id_1.generateID)('HEX', 'FE');
|
|
67
|
+
this.instanceId = `${serviceName}:${this.instanceUniqueId}`;
|
|
68
|
+
this.consumerGroupName = `cg-${serviceName}`;
|
|
69
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Instance ID: ${this.instanceId} and with config: ${JSON.stringify(this.config)}`);
|
|
70
|
+
const cleanUpInterval = this.config.cleanUpInterval ?? parseInt(process.env['CLEANUP_INTERVAL'] ?? `${this.config.cleanUpInterval}`, 10);
|
|
71
|
+
this.cleanUpTimer = setInterval(() => {
|
|
72
|
+
this.runClear(cleanUpInterval).catch((error) => {
|
|
73
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error during cleanup:', error);
|
|
74
|
+
});
|
|
75
|
+
}, cleanUpInterval);
|
|
76
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Clean Up process setup for ${cleanUpInterval} ms`);
|
|
77
|
+
this.dlq = new dlq_1.DeadLetterQueue(this.redisPublisher, config.dlqEventThreshold);
|
|
78
|
+
this.metricsCollector = new collector_1.MetricsCollector({
|
|
79
|
+
redisClient: this.redisPublisher,
|
|
80
|
+
collectionInterval: 60000,
|
|
81
|
+
retentionPeriod: 6 * 60 * 60 * 1000,
|
|
82
|
+
}, this.dlq);
|
|
83
|
+
this.duplicateChecker = new duplication_1.ContentBasedDeduplication(this.redisPublisher, this.config.duplicationCheckWindow);
|
|
84
|
+
this.circuitBreaker = new circuit_breaker_1.CircuitBreaker(this.config.circuitBreaker, this.redisPublisher);
|
|
85
|
+
if (this.config.circuitBreaker.enabled)
|
|
86
|
+
this.setupCircuitBreakerListeners();
|
|
87
|
+
}
|
|
88
|
+
setupCircuitBreakerListeners() {
|
|
89
|
+
this.circuitBreaker.on('stateChange', async (newState) => {
|
|
90
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Circuit breaker state changed to: ${circuit_breaker_1.CircuitState[newState]}`);
|
|
91
|
+
if (newState === circuit_breaker_1.CircuitState.CLOSED) {
|
|
92
|
+
await this.processStoredEvents();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async runClear(cleanUpInterval) {
|
|
97
|
+
logger_1.PUBLISHER_LOGGER.log('PUBLISHER: Running Clearance', this.eventsListened);
|
|
98
|
+
const cleanupPromises = this.eventsListened.map((eventName) => this.cleanupAcknowledgedMessages(eventName, cleanUpInterval)
|
|
99
|
+
.then(() => {
|
|
100
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Cleanup process for Acknowledged messages completed for ${eventName}`);
|
|
101
|
+
})
|
|
102
|
+
.catch((error) => {
|
|
103
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during cleanup for ${eventName}:`, error);
|
|
104
|
+
}));
|
|
105
|
+
await Promise.all(cleanupPromises);
|
|
106
|
+
}
|
|
107
|
+
async publish(data, multicast = false) {
|
|
108
|
+
const tracker = new tracker_1.MetricsTracker();
|
|
109
|
+
if (data.eventId)
|
|
110
|
+
data.republishEvent = data.eventId;
|
|
111
|
+
data.eventId = (0, id_1.generateID)('HEX', 'FF');
|
|
112
|
+
if (!data.createdAt)
|
|
113
|
+
data.createdAt = Date.now();
|
|
114
|
+
/**
|
|
115
|
+
* This is a simplified description of the circuit breaker code
|
|
116
|
+
* 1. If the circuit breaker is enabled, then we check if the circuit is closed and messages are allowed to pass
|
|
117
|
+
* 2. If the circuit is open, then the event is stored and an error is logged notifying that the system has recorded
|
|
118
|
+
* a large number of publisher failures (controlled by errorThreshold and errorThresholdPercentage). This emits an
|
|
119
|
+
* event and the time of change of state is recorded. The circuit remains open for 30 seconds, allowing the system to
|
|
120
|
+
* correct itself as much as possible. All events during this time end up in the buffer
|
|
121
|
+
* 3. If the 30s is expired, the system resets to a half open state where new events are allowed to go through and a
|
|
122
|
+
* success state is recorded.
|
|
123
|
+
* 4. In the half open state if there are 10 attempts that succeed, then the circuit goes to closed and all pending
|
|
124
|
+
* messages are published.
|
|
125
|
+
*
|
|
126
|
+
* Here is a pictorial representation
|
|
127
|
+
*
|
|
128
|
+
* [Failure threshold met]
|
|
129
|
+
* CLOSED --------------------> OPEN
|
|
130
|
+
* ^ |
|
|
131
|
+
* | |
|
|
132
|
+
* | [Max success | [Open duration elapsed]
|
|
133
|
+
* | in half-open] |
|
|
134
|
+
* | v
|
|
135
|
+
* -------------------- HALF-OPEN
|
|
136
|
+
*
|
|
137
|
+
* These properties are configurable
|
|
138
|
+
*/
|
|
139
|
+
if (this.config.circuitBreaker.enabled && !this.circuitBreaker.isAllowed()) {
|
|
140
|
+
await this.circuitBreaker.storeEvent(data);
|
|
141
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Circuit is open, event stored for later processing');
|
|
142
|
+
return 'CIRCUIT_BREAKER_FLOW';
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const streamName = `sl:${data.eventName}`; // Use sl: prefix
|
|
146
|
+
let key = '*'; // Default to auto-generated ID
|
|
147
|
+
tracker.startRedisOperation();
|
|
148
|
+
try {
|
|
149
|
+
// Publish directly to the single stream
|
|
150
|
+
const generatedKey = await this.redisPublisher.xadd(streamName, key, 'data', JSON.stringify(data));
|
|
151
|
+
tracker.endRedisOperation();
|
|
152
|
+
if (generatedKey === null) {
|
|
153
|
+
// Handle the case where xadd failed unexpectedly
|
|
154
|
+
throw new Error(`XADD command failed to return a message ID for stream ${streamName}`);
|
|
155
|
+
}
|
|
156
|
+
key = generatedKey; // Assign the non-null key
|
|
157
|
+
// Increment metrics
|
|
158
|
+
tracker.incrementEventCount();
|
|
159
|
+
tracker.incrementMessageRate('publish', data.eventName);
|
|
160
|
+
if (this.metricsCollector) {
|
|
161
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
162
|
+
}
|
|
163
|
+
// Notify subscribers
|
|
164
|
+
await (0, utils_1.notifySubscribers)(this.redisPublisher, data.eventName, key, multicast);
|
|
165
|
+
await this.circuitBreaker.recordSuccess();
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
tracker.endRedisOperation(); // Ensure tracker ends even on error
|
|
169
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error publishing to stream ${streamName}:`, error);
|
|
170
|
+
throw error; // Rethrow the error
|
|
171
|
+
}
|
|
172
|
+
const metrics = tracker.getMetrics();
|
|
173
|
+
this.logPerformance(`PTIME;${key};${data.eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
|
|
174
|
+
return key;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
|
|
178
|
+
tracker.incrementErrorCount('publish');
|
|
179
|
+
if (this.metricsCollector) {
|
|
180
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
181
|
+
}
|
|
182
|
+
await this.circuitBreaker.recordFailure();
|
|
183
|
+
throw new Error('Publisher Error');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Listens for events with the given name and returns an Observable that emits an EventData<T> object
|
|
188
|
+
* each time a new event is received.
|
|
189
|
+
*
|
|
190
|
+
* The method uses a BehaviorSubject to emit the events as Observables. The BehaviorSubject ensures
|
|
191
|
+
* that new subscribers receive the last emitted event, even if they subscribe after the event has been emitted.
|
|
192
|
+
*
|
|
193
|
+
* If an error occurs while subscribing, the method logs the error to the PUBLISHER_LOGGER and throws
|
|
194
|
+
* an error. This is done to prevent the service from continuing without a proper event subscription.
|
|
195
|
+
*
|
|
196
|
+
* There is retry logic with exponential backoff to handle error cases. These are also controllable by the
|
|
197
|
+
* calling service
|
|
198
|
+
*
|
|
199
|
+
* @param eventName - The name of the event to listen for.
|
|
200
|
+
*
|
|
201
|
+
* @returns An Observable that emits an EventData<T> object each time a new event is received.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
*
|
|
205
|
+
* // Listen for "order.created" events
|
|
206
|
+
* const orderCreated = streams.listen<OrderCreatedEvent>('order.created');
|
|
207
|
+
*
|
|
208
|
+
* // Subscribe to the Observable and log each new event
|
|
209
|
+
* orderCreated.subscribe((event) => {
|
|
210
|
+
* PUBLISHER_LOGGER.log('New order created:', event.data);
|
|
211
|
+
* });
|
|
212
|
+
*/
|
|
213
|
+
listen(eventName, listenerOptions) {
|
|
214
|
+
const options = {
|
|
215
|
+
maxRetries: this.config.maxRetries,
|
|
216
|
+
initialDelay: this.config.initialRetryDelay,
|
|
217
|
+
filterKeepAlive: this.config.filterKeepAlive,
|
|
218
|
+
publishOnceGuarantee: false,
|
|
219
|
+
externalAcknowledgement: false,
|
|
220
|
+
...listenerOptions,
|
|
221
|
+
};
|
|
222
|
+
const subscriptionId = (0, id_1.generateID)('HEX');
|
|
223
|
+
return this.listenInternals(eventName, subscriptionId, options.eventFilter, options.filterKeepAlive, options.publishOnceGuarantee, options.externalAcknowledgement).pipe((0, rxjs_1.retry)({
|
|
224
|
+
count: options.maxRetries,
|
|
225
|
+
delay: (error, retryAttempt) => {
|
|
226
|
+
const delay = options.initialDelay * Math.pow(2, retryAttempt);
|
|
227
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
|
|
228
|
+
return (0, rxjs_1.timer)(delay);
|
|
229
|
+
},
|
|
230
|
+
}), (0, rxjs_1.catchError)((error) => {
|
|
231
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error in listen after ${options.maxRetries} retries: ${error.message}`);
|
|
232
|
+
return (0, rxjs_1.throwError)(() => new Error(error.message));
|
|
233
|
+
}), (0, rxjs_1.finalize)(() => {
|
|
234
|
+
this.removeSubscription(eventName, subscriptionId);
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
async createConsumerAndRegister(eventName) {
|
|
238
|
+
const streamName = `sl:${eventName}`; // Use sl: prefix
|
|
239
|
+
const key = `instance:${this.instanceId}:subscribedEvents`;
|
|
240
|
+
const setKeyForK8sHandling = `instance:${this.instanceUniqueId}:consumerGroupName`;
|
|
241
|
+
if (!this.eventsListened.includes(eventName)) {
|
|
242
|
+
this.eventsListened.push(eventName);
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
// Try to create the consumer group on the single stream
|
|
246
|
+
try {
|
|
247
|
+
await this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
|
|
248
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Group ${this.consumerGroupName} created for stream ${streamName}`);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
// BUSYGROUP error means group already exists, which is fine
|
|
252
|
+
if (!e.message.includes('BUSYGROUP') || !e.includes('BUSYGROUP')) {
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Group ${this.consumerGroupName} already exists for stream ${streamName}`);
|
|
256
|
+
}
|
|
257
|
+
// Create consumer (idempotent operation) - Note: CREATECONSUMER is not needed if group exists
|
|
258
|
+
// const createConsumerStatus = await this.redisGroups.xgroup('CREATECONSUMER', streamName, this.consumerGroupName, this.instanceId);
|
|
259
|
+
// Register event and consumer group in parallel
|
|
260
|
+
const [, addToFlushSet] = await Promise.all([
|
|
261
|
+
this.redisGroups.set(setKeyForK8sHandling, this.consumerGroupName),
|
|
262
|
+
this.redisGroups.sadd(key, eventName), // Still track subscribed events per instance
|
|
263
|
+
]);
|
|
264
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Consumer ${this.instanceId} registered for group ${this.consumerGroupName} on stream ${streamName} with status ${JSON.stringify({
|
|
265
|
+
addToFlushSet,
|
|
266
|
+
})}`);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Consumer registration failed for ${JSON.stringify({ streamName, cgn: this.consumerGroupName })}:`, error);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
listenInternals(eventName, subscriptionId, eventFilter, filterKeepAlive = 24 * 60 * 60 * 1000, publishOnceGuarantee = false, externalAcknowledgement = false) {
|
|
275
|
+
const eventSubscriptions = this.subscriptions.get(eventName) || new Map();
|
|
276
|
+
const isNewSubscription = !this.subscriptions.has(eventName);
|
|
277
|
+
if (isNewSubscription) {
|
|
278
|
+
this.subscriptions.set(eventName, eventSubscriptions);
|
|
279
|
+
}
|
|
280
|
+
const bs = new rxjs_1.BehaviorSubject(null);
|
|
281
|
+
const subscription = {
|
|
282
|
+
subject: bs,
|
|
283
|
+
filter: eventFilter,
|
|
284
|
+
lastMatchTime: Date.now(),
|
|
285
|
+
keepAlive: filterKeepAlive,
|
|
286
|
+
};
|
|
287
|
+
eventSubscriptions.set(subscriptionId, subscription);
|
|
288
|
+
if (!isNewSubscription) {
|
|
289
|
+
return bs.asObservable().pipe((0, rxjs_1.skip)(1));
|
|
290
|
+
}
|
|
291
|
+
const cleanupInterval = 10000;
|
|
292
|
+
const timer = (0, rxjs_1.interval)(cleanupInterval).subscribe({
|
|
293
|
+
next: async () => {
|
|
294
|
+
try {
|
|
295
|
+
await processMessage(this.redisGroups, '0', new tracker_1.MetricsTracker(), false);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error in running recurring cleanup task:', error);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
error: (error) => {
|
|
302
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Fatal error in cleanup timer:', error);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1), (0, rxjs_1.finalize)(() => {
|
|
306
|
+
timer.unsubscribe();
|
|
307
|
+
this.removeSubscription(eventName, subscriptionId);
|
|
308
|
+
}));
|
|
309
|
+
const processMessage = async (redisClient, messageId, tracker, multicast = false, processPending = false) => {
|
|
310
|
+
// Skip processing if subscription was removed. This is needed because the processing is independent of the subscription
|
|
311
|
+
if (!this.subscriptions.has(eventName)) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const streamName = `sl:${eventName}`; // Use sl: prefix
|
|
315
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Processing message ${messageId} for ${streamName} / ${this.consumerGroupName}`);
|
|
316
|
+
try {
|
|
317
|
+
// Removed redundant XPENDING check block
|
|
318
|
+
let eventData;
|
|
319
|
+
tracker.startRedisOperation();
|
|
320
|
+
try {
|
|
321
|
+
/**
|
|
322
|
+
* Both multicast messages and pending messages cannot be read by xreadgroup
|
|
323
|
+
* Multicast messages should not be claimed by a single consumer. And pending messages
|
|
324
|
+
* are usually behind in the stream so XREADGROUP will not read them and hence
|
|
325
|
+
* they need to be read using XRANGE.
|
|
326
|
+
*/
|
|
327
|
+
// Simplified condition: XRANGE is only needed for multicast messages now
|
|
328
|
+
if (multicast) {
|
|
329
|
+
// XRANGE is still needed for multicast as XREADGROUP won't deliver the same message to all consumers
|
|
330
|
+
const messages = await redisClient.xrange(streamName, messageId, messageId);
|
|
331
|
+
if (messages?.length) {
|
|
332
|
+
try {
|
|
333
|
+
eventData = JSON.parse(messages[0][1][1]);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
logger_1.PUBLISHER_LOGGER.error(`JSON parsing failed for message: ${messages[0][1][1]}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
// Use XREADGROUP for regular consumer group processing
|
|
343
|
+
const messages = (await redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'STREAMS', streamName, '>'));
|
|
344
|
+
// Handle potential null response from BLOCK timeout
|
|
345
|
+
if (!messages || messages.length === 0) {
|
|
346
|
+
// PUBLISHER_LOGGER.log(`PUBLISHER: No new messages for ${streamName}/${this.consumerGroupName}`);
|
|
347
|
+
return; // No new messages
|
|
348
|
+
}
|
|
349
|
+
if (messages[0]?.[1]?.[0]) {
|
|
350
|
+
// Check structure carefully
|
|
351
|
+
const messageIdRead = messages[0][1][0][0];
|
|
352
|
+
// Log if the message ID read is different from expected (can happen with retries/claims)
|
|
353
|
+
if (messageId !== '0' && messageIdRead !== messageId) {
|
|
354
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Reading message ${messageIdRead} instead of expected ${messageId} for ${streamName}`);
|
|
355
|
+
}
|
|
356
|
+
messageId = messageIdRead; // Update messageId to the one actually read
|
|
357
|
+
try {
|
|
358
|
+
eventData = JSON.parse(messages[0][1][0][1][1]);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: JSON parsing failed for message: ${messages[0][1][0][1][1]}`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error retrieving or parsing event data:', error);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
tracker.endRedisOperation();
|
|
373
|
+
}
|
|
374
|
+
tracker.startProcessing();
|
|
375
|
+
if (eventData) {
|
|
376
|
+
if (publishOnceGuarantee) {
|
|
377
|
+
if (await this.duplicateChecker.isDuplicate(eventData, this.consumerGroupName)) {
|
|
378
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Duplicate event detected, skipping processing for consumer group ${this.consumerGroupName}`);
|
|
379
|
+
tracker.incrementDuplicateEvent();
|
|
380
|
+
await redisClient.xack(streamName, this.consumerGroupName, messageId);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const ackKey = this.frameMessageKey(streamName, messageId);
|
|
386
|
+
const subscriptions = this.subscriptions.get(eventName);
|
|
387
|
+
if (subscriptions) {
|
|
388
|
+
const currentTime = Date.now();
|
|
389
|
+
const subscriptionEntries = Array.from(subscriptions.entries());
|
|
390
|
+
// Process subscriptions in parallel for better performance
|
|
391
|
+
await Promise.all(subscriptionEntries.map(async ([subId, sub]) => {
|
|
392
|
+
try {
|
|
393
|
+
if (!sub.filter || sub.filter(eventData)) {
|
|
394
|
+
sub.subject.next({ ...eventData, ackKey });
|
|
395
|
+
sub.lastMatchTime = currentTime;
|
|
396
|
+
}
|
|
397
|
+
else if (currentTime - sub.lastMatchTime > sub.keepAlive) {
|
|
398
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: No matching events for ${eventName} (Subscription ${subId}) in the last ${sub.keepAlive / 1000 / 60 / 60} hours`);
|
|
399
|
+
sub.lastMatchTime = currentTime;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
// Log error but don't fail entire processing
|
|
404
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing subscription ${subId}:`, error);
|
|
405
|
+
}
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
// Acknowledge message if needed
|
|
409
|
+
if (!externalAcknowledgement) {
|
|
410
|
+
await this.acknowledgeMessage(ackKey);
|
|
411
|
+
}
|
|
412
|
+
// Update metrics
|
|
413
|
+
const currentTime = Date.now();
|
|
414
|
+
tracker.incrementMessageRate('subscribe', eventData.eventName);
|
|
415
|
+
const processingTime = currentTime - eventData.createdAt;
|
|
416
|
+
tracker.addProcessingTime(processingTime);
|
|
417
|
+
tracker.setConsumerLag(this.consumerGroupName, currentTime - eventData.createdAt);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Processing error for message ${messageId}:`, error);
|
|
421
|
+
const dlqEvent = {
|
|
422
|
+
...eventData,
|
|
423
|
+
failureReason: error.message,
|
|
424
|
+
retryCount: (eventData.retryCount || 0) + 1,
|
|
425
|
+
originalStream: streamName,
|
|
426
|
+
consumerGroupName: this.consumerGroupName,
|
|
427
|
+
timestamp: Date.now(),
|
|
428
|
+
};
|
|
429
|
+
await this.dlq.addToDLQ(dlqEvent);
|
|
430
|
+
// Don't rethrow to prevent message loss
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
// This case might happen if XRANGE was used for a multicast message that was deleted before reading
|
|
435
|
+
// Or if XREADGROUP returned an empty message array unexpectedly
|
|
436
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No event data found for message ${messageId} in ${streamName}`);
|
|
437
|
+
}
|
|
438
|
+
tracker.endProcessing();
|
|
439
|
+
// Ensure custom pending message processing logic is fully removed
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error processing message ${messageId} for ${streamName}/${this.consumerGroupName}`, e);
|
|
443
|
+
if (!multicast) {
|
|
444
|
+
const dlqEvent = {
|
|
445
|
+
eventId: messageId,
|
|
446
|
+
eventName: eventName, // Use base event name
|
|
447
|
+
data: {}, // Consider adding actual data if available
|
|
448
|
+
failureReason: e.message,
|
|
449
|
+
retryCount: 1,
|
|
450
|
+
originalStream: streamName, // Keep full stream name
|
|
451
|
+
timestamp: Date.now(),
|
|
452
|
+
consumerGroupName: this.consumerGroupName,
|
|
453
|
+
};
|
|
454
|
+
await this.dlq.addToDLQ(dlqEvent);
|
|
455
|
+
tracker.incrementErrorCount('subscribe');
|
|
456
|
+
if (this.metricsCollector) {
|
|
457
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
/** Register the consumer and setup the Observable */
|
|
463
|
+
this.createConsumerAndRegister(eventName)
|
|
464
|
+
.then((consumerRegistered) => {
|
|
465
|
+
if (!consumerRegistered)
|
|
466
|
+
throw new Error('PUBLISHER: Cannot setup consumer');
|
|
467
|
+
const eventStreamClient = registry_1.RedisRegistry.getConnection(this.redisConnectionId, `sub-${eventName}`);
|
|
468
|
+
eventStreamClient.subscribe(eventName).then(() => {
|
|
469
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Redis Subscription connection initiated for ${eventName}`);
|
|
470
|
+
});
|
|
471
|
+
eventStreamClient.on('message', async (channel, data) => {
|
|
472
|
+
const tracker = new tracker_1.MetricsTracker();
|
|
473
|
+
let messageIdRead, multicastRead;
|
|
474
|
+
try {
|
|
475
|
+
const { messageId, multicast } = JSON.parse(data);
|
|
476
|
+
messageIdRead = messageId;
|
|
477
|
+
multicastRead = multicast;
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
messageIdRead = data;
|
|
481
|
+
multicastRead = false;
|
|
482
|
+
}
|
|
483
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream Notification Received for event ${eventName} with message ID ${messageIdRead}`);
|
|
484
|
+
await processMessage(this.redisGroups, messageIdRead, tracker, multicastRead);
|
|
485
|
+
const metrics = tracker.getMetrics();
|
|
486
|
+
logger_1.PERFORMANCE_LOGGER.log(`STIME;${messageIdRead};${eventName};${Date.now()};${metrics.totalTime};${metrics.redisOperationTime};${metrics.processingTime}`);
|
|
487
|
+
if (this.metricsCollector) {
|
|
488
|
+
this.metricsCollector.addMetrics(tracker.getMetrics());
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
})
|
|
492
|
+
.catch((e) => {
|
|
493
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during consumer registration for ${eventName}`, e);
|
|
494
|
+
});
|
|
495
|
+
return observable;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* This method allows the possibility of a graceful shutdown by cleaning up the
|
|
499
|
+
* redis connections.
|
|
500
|
+
*
|
|
501
|
+
* In all services where the library is used, its better to implement this method
|
|
502
|
+
*
|
|
503
|
+
* process.on('SIGTERM', shutdown);
|
|
504
|
+
* process.on('SIGINT', shutdown);
|
|
505
|
+
*
|
|
506
|
+
* async function shutdown(): Promise<void> {
|
|
507
|
+
* PUBLISHER_LOGGER.log('Graceful shutdown initiated.');
|
|
508
|
+
* try {
|
|
509
|
+
* await streams.close();
|
|
510
|
+
* PUBLISHER_LOGGER.log('Resources and connections successfully closed.');
|
|
511
|
+
* } catch (error) {
|
|
512
|
+
* PUBLISHER_LOGGER.error('Error during graceful shutdown:', error);
|
|
513
|
+
* }
|
|
514
|
+
* process.exit(0);
|
|
515
|
+
* }
|
|
516
|
+
*/
|
|
517
|
+
async close() {
|
|
518
|
+
try {
|
|
519
|
+
// Removed reset of optimization state and script SHA
|
|
520
|
+
if (this.cleanUpTimer) {
|
|
521
|
+
clearInterval(this.cleanUpTimer);
|
|
522
|
+
}
|
|
523
|
+
if (this._redisPublisher) {
|
|
524
|
+
await this._redisPublisher.quit();
|
|
525
|
+
}
|
|
526
|
+
for (const eventName of this.eventsListened) {
|
|
527
|
+
await registry_1.RedisRegistry.getConnection(this.redisConnectionId, `sub-${eventName}`).quit();
|
|
528
|
+
}
|
|
529
|
+
if (this._redisGroups) {
|
|
530
|
+
await this._redisGroups.quit();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
logger_1.PUBLISHER_LOGGER.error('PUBLISHER: Error during cleanup:', error);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Updated cleanup logic
|
|
538
|
+
async cleanupAcknowledgedMessages(eventName, interval = this.config.acknowledgedMessageCleanupInterval) {
|
|
539
|
+
const streamName = `sl:${eventName}`; // Use sl: prefix
|
|
540
|
+
const twentyFourHoursAgoTimestamp = Date.now() - 24 * 60 * 60 * 1000;
|
|
541
|
+
let minLastAckTimestamp = Date.now(); // Default to now if no groups/acks found
|
|
542
|
+
try {
|
|
543
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Started cleanup for stream ${streamName}`);
|
|
544
|
+
// 1. Get all consumer groups for the base event name
|
|
545
|
+
const consumerGroups = await (0, utils_1.getAllConsumerGroups)(eventName, this.redisPublisher); // Use base event name to find groups
|
|
546
|
+
if (!consumerGroups || consumerGroups.length === 0) {
|
|
547
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No consumer groups found for event ${eventName}, skipping cleanup for ${streamName}`);
|
|
548
|
+
// Optionally, still trim based on 24 hours if desired, even without consumers
|
|
549
|
+
// await this.redisGroups.xtrim(streamName, 'MINID', twentyFourHoursAgoTimestamp);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// 2. Get the last acknowledged ID for each consumer group on this stream
|
|
553
|
+
const lastAckPromises = consumerGroups.map(async (cg) => {
|
|
554
|
+
const lastAckKey = `last_ack:${streamName}:${cg}`; // Key includes stream and group
|
|
555
|
+
const lastAckId = await this.redisGroups.get(lastAckKey);
|
|
556
|
+
if (lastAckId) {
|
|
557
|
+
const [timestamp] = lastAckId.split('-').map(Number);
|
|
558
|
+
return timestamp;
|
|
559
|
+
}
|
|
560
|
+
return null; // Return null if no ack ID found for this group
|
|
561
|
+
});
|
|
562
|
+
const lastAckTimestamps = (await Promise.all(lastAckPromises)).filter((ts) => ts !== null);
|
|
563
|
+
// 3. Find the minimum last acknowledged timestamp across all groups
|
|
564
|
+
if (lastAckTimestamps.length > 0) {
|
|
565
|
+
minLastAckTimestamp = Math.min(...lastAckTimestamps);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// If no groups have acknowledged anything, maybe default to 24 hours ago?
|
|
569
|
+
// Or keep minLastAckTimestamp as Date.now() to effectively only use the 24h rule.
|
|
570
|
+
// Let's default to 24h ago if no acks found, to allow trimming old messages.
|
|
571
|
+
minLastAckTimestamp = twentyFourHoursAgoTimestamp;
|
|
572
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: No acknowledged messages found for any group on ${streamName}, using 24h threshold.`);
|
|
573
|
+
}
|
|
574
|
+
// 4. Determine the final cleanup threshold
|
|
575
|
+
const cleanupThreshold = Math.max(twentyFourHoursAgoTimestamp, minLastAckTimestamp);
|
|
576
|
+
const cleanupThresholdDate = new Date(cleanupThreshold).toISOString();
|
|
577
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Determined cleanup threshold for ${streamName}: ${cleanupThreshold} (${cleanupThresholdDate}) (minAck: ${new Date(minLastAckTimestamp).toISOString()}, 24hAgo: ${new Date(twentyFourHoursAgoTimestamp).toISOString()})`);
|
|
578
|
+
// 5. Perform XTRIM
|
|
579
|
+
// Check if stream exists before trimming (optional, XTRIM handles non-existent streams gracefully)
|
|
580
|
+
const streamExists = await this.redisGroups.exists(streamName);
|
|
581
|
+
if (streamExists) {
|
|
582
|
+
await this.redisGroups.xtrim(streamName, 'MINID', cleanupThreshold);
|
|
583
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Performed XTRIM on ${streamName} with MINID ${cleanupThreshold}`);
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
logger_1.PUBLISHER_LOGGER.log(`PUBLISHER: Stream ${streamName} does not exist, skipping XTRIM.`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error during cleanup for ${streamName}:`, error);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async getDiagnosticData(events) {
|
|
594
|
+
if (events.length > 100) {
|
|
595
|
+
return { status: 'ERROR', message: 'Please pass in a maximum of 100 elements to fetch diagnostics' };
|
|
596
|
+
}
|
|
597
|
+
const tempPromises = events.map(async (baseEventName) => {
|
|
598
|
+
const streamName = `sl:${baseEventName}`; // Use sl: prefix
|
|
599
|
+
const consumerGroups = await (0, utils_1.getAllConsumerGroups)(baseEventName, this.redisPublisher); // Get groups based on base name
|
|
600
|
+
const consumerGroupMap = await Promise.all(consumerGroups.map(async (consumerGroup) => {
|
|
601
|
+
// Query the single stream for this specific consumer group
|
|
602
|
+
const diagnostics = await (0, utils_1.getSummaryOnStreamConsumerGroup)(this.redisGroups, consumerGroup, streamName);
|
|
603
|
+
return { consumerGroup, diagnostics };
|
|
604
|
+
}));
|
|
605
|
+
return { eventName: baseEventName, streamName, consumerGroupMap }; // Include stream name in output
|
|
606
|
+
});
|
|
607
|
+
const returnData = await Promise.all(tempPromises);
|
|
608
|
+
return { status: 'SUCCESS', data: returnData, message: 'We recommend not running this in times of heavy load' };
|
|
609
|
+
}
|
|
610
|
+
logPerformance(message) {
|
|
611
|
+
if (this.config.performanceLogger) {
|
|
612
|
+
this.config.performanceLogger(message);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
logger_1.PERFORMANCE_LOGGER.log(message);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* @description
|
|
620
|
+
* This method is use to retry an event that has ended in the dead letter queue,
|
|
621
|
+
* which happens after the first retry.
|
|
622
|
+
*/
|
|
623
|
+
async retryFromDLQ(eventId) {
|
|
624
|
+
return this.dlq.retryFromDLQ(eventId);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* @description
|
|
628
|
+
* This returns the number of items and the rate at which events are added
|
|
629
|
+
* to the queue. The queue is global and hence remains as is
|
|
630
|
+
*/
|
|
631
|
+
async getDLQStats() {
|
|
632
|
+
return this.dlq.getDLQStats();
|
|
633
|
+
}
|
|
634
|
+
removeSubscription(eventName, subscriptionId) {
|
|
635
|
+
const eventSubscriptions = this.subscriptions.get(eventName);
|
|
636
|
+
if (eventSubscriptions) {
|
|
637
|
+
eventSubscriptions.delete(subscriptionId);
|
|
638
|
+
if (eventSubscriptions.size === 0) {
|
|
639
|
+
this.subscriptions.delete(eventName);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* @description
|
|
645
|
+
* This is a simple helper utility that can be used externally to create alerts based
|
|
646
|
+
* on thresholds that can be provided into the function. It returns true/false for each
|
|
647
|
+
* key that is provided. Not all keys are required
|
|
648
|
+
*/
|
|
649
|
+
async checkThresholds(thresholds) {
|
|
650
|
+
if (!this.metricsCollector) {
|
|
651
|
+
throw new Error('Metrics collection is not enabled');
|
|
652
|
+
}
|
|
653
|
+
const latestMetrics = await this.metricsCollector.getLatestMetrics();
|
|
654
|
+
if (!latestMetrics) {
|
|
655
|
+
throw new Error('No metrics available');
|
|
656
|
+
}
|
|
657
|
+
const alerts = {};
|
|
658
|
+
for (const [key, threshold] of Object.entries(thresholds)) {
|
|
659
|
+
if (key in latestMetrics) {
|
|
660
|
+
alerts[key] = latestMetrics[key] > threshold;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return alerts;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* @description
|
|
667
|
+
* This will return you the stats of the publisher for the last 6 hours after cleaning
|
|
668
|
+
*/
|
|
669
|
+
async getMetrics(startTime, endTime) {
|
|
670
|
+
return this.metricsCollector.getMetrics(startTime, endTime);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* @description
|
|
674
|
+
* This will return you the latest stats of the publisher
|
|
675
|
+
*/
|
|
676
|
+
async getLatestMetrics() {
|
|
677
|
+
return this.metricsCollector.getLatestMetrics();
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* @description
|
|
681
|
+
* This returns the status of the performance control setup. This includes
|
|
682
|
+
* the circuit breaker
|
|
683
|
+
*/
|
|
684
|
+
async getPerformanceControlStatus() {
|
|
685
|
+
const circuitBreakerState = this.circuitBreaker.getState();
|
|
686
|
+
return {
|
|
687
|
+
circuitBreakerState,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* @description
|
|
692
|
+
* This is a manual control to process stored events in case the
|
|
693
|
+
* circuit is OPEN
|
|
694
|
+
*/
|
|
695
|
+
async processStoredEvents() {
|
|
696
|
+
if (this.circuitBreaker.getState() === circuit_breaker_1.CircuitState.CLOSED) {
|
|
697
|
+
const storedEvents = await this.circuitBreaker.getStoredEvents();
|
|
698
|
+
for (const event of storedEvents) {
|
|
699
|
+
await this.publish(event);
|
|
700
|
+
}
|
|
701
|
+
await this.circuitBreaker.clearStoredEvents();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Acknowledges a message and updates the last acknowledged message ID for the specific consumer group.
|
|
706
|
+
* This is used to track cleanup progress and ensure we don't delete unprocessed messages.
|
|
707
|
+
*/
|
|
708
|
+
async acknowledgeMessage(ackKey) {
|
|
709
|
+
const { streamName, messageId } = this.demergeMessageKey(ackKey);
|
|
710
|
+
// Construct the specific last_ack key for this stream and consumer group
|
|
711
|
+
const lastAckKey = `last_ack:${streamName}:${this.consumerGroupName}`;
|
|
712
|
+
const validMessageId = messageId === '0' ? '0-0' : messageId; // Ensure valid format if '0'
|
|
713
|
+
try {
|
|
714
|
+
// Update last acknowledged ID and acknowledge message atomically
|
|
715
|
+
await Promise.all([
|
|
716
|
+
this.redisGroups.xack(streamName, this.consumerGroupName, validMessageId),
|
|
717
|
+
this.redisGroups.set(lastAckKey, validMessageId), // Use the specific key
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
logger_1.PUBLISHER_LOGGER.error(`PUBLISHER: Error acknowledging message ${validMessageId} for ${streamName}/${this.consumerGroupName}:`, error);
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Keep framing/demerging simple for now, assuming streamName includes sl: prefix
|
|
726
|
+
frameMessageKey(streamName, messageId) {
|
|
727
|
+
return `${streamName}##${messageId}`;
|
|
728
|
+
}
|
|
729
|
+
demergeMessageKey(messageKey) {
|
|
730
|
+
const [streamName, messageId] = messageKey.split('##');
|
|
731
|
+
return { streamName, messageId };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
exports.StreamsLite = StreamsLite;
|