@jetit/publisher 1.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 ADDED
@@ -0,0 +1,43 @@
1
+ # publisher
2
+
3
+ publisher is a library for implementing an event-driven architecture using Redis PUB/SUB and Redis Streams. It provides a simple and scalable mechanism for publishing and consuming events in real-time, and supports features such as message deduplication, consumer group management, and scheduled event publishing.
4
+
5
+ ## Simple Example
6
+
7
+ ```typescript
8
+ import { Publisher, EventData } from '@jetit/streams';
9
+
10
+ // Create an instance of the publisher
11
+ const streams = new Streams('Websockets');
12
+
13
+ // Publish an event
14
+ const eventData: EventData<{ message: string }> = {
15
+ eventName: 'my-event',
16
+ data: { message: 'Hello, world!' }
17
+ };
18
+
19
+ await streams.publish(eventData);
20
+
21
+ // Subscribe to an event
22
+ streams.listen('my-event').subscribe(event => {
23
+ console.log(`Received event: ${event.eventName}`, event.data);
24
+ });
25
+ ```
26
+
27
+ ## Possible use cases
28
+
29
+ 1. Microservices communication: If your system is composed of multiple microservices, the publisher can be used to facilitate communication between them by publishing and listening to events.
30
+
31
+ 2. Event sourcing and CQRS: In an event-sourced system, the publisher can be used to store and process events that represent the state changes of the system, enabling Command Query Responsibility Segregation (CQRS) by separating the read and write models.
32
+
33
+ 3. Task queues: The publisher can be used to create task queues for distributing workloads among different worker instances, ensuring that tasks are processed in the order they were created.
34
+
35
+ 4. Data streaming and processing: The publisher can be used to ingest and process large volumes of data in real-time, such as log files, clickstream data, or other event-based data.
36
+
37
+ 5. Distributed system coordination: In a distributed system, the publisher can be used for coordination between different components, such as managing leader election or maintaining configuration information.
38
+
39
+ 6. Real-time analytics and monitoring: The publisher can be used to collect and process real-time analytics data, such as user behavior, application performance metrics, or system monitoring information.
40
+
41
+ 7. Event-driven workflows: You can use the publisher to create event-driven workflows, where each step in the workflow is triggered by the completion of a previous step. This can be useful for orchestrating complex, multi-step processes.
42
+
43
+ 8. Message broadcasting: The publisher can be used to broadcast messages to multiple consumers or subscribers, allowing for efficient and scalable communication in applications with many components or services.
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@jetit/publisher",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@jetit/id": "0.0.6",
7
+ "ioredis": "5.3.1",
8
+ "rxjs": "7.8.0"
9
+ },
10
+ "peerDependencies": {
11
+ "@types/ioredis-mock": "8.2.1",
12
+ "tslib": "2.5.0"
13
+ },
14
+ "main": "./src/index.js",
15
+ "types": "./src/index.d.ts"
16
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './lib/publisher';
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './lib/publisher';
@@ -0,0 +1,3 @@
1
+ export { Streams as Publisher } from './redis/streams';
2
+ export { setRedisConnectionSettings as setRedisConfig } from './redis/registry';
3
+ export { ScheduledProcessor as __SCHEDULER_INTERNALS__ } from './redis/scheduler';
@@ -0,0 +1,3 @@
1
+ export { Streams as Publisher } from './redis/streams';
2
+ export { setRedisConnectionSettings as setRedisConfig } from './redis/registry';
3
+ export { ScheduledProcessor as __SCHEDULER_INTERNALS__ } from './redis/scheduler';
@@ -0,0 +1,20 @@
1
+ import Redis, { Cluster } from 'ioredis';
2
+ import { IOptions } from './types';
3
+ export type RedisType = Redis | Cluster;
4
+ export declare class RedisRegistry {
5
+ private static registry;
6
+ private static options;
7
+ static attemptConnection(connectionKey: string, storeRef?: number): Redis;
8
+ static getConnection(connectionType?: string, storeRef?: number): RedisType;
9
+ static setOptions(options: IOptions): void;
10
+ static _getOptions(): IOptions;
11
+ }
12
+ /**
13
+ * This function is used to set Redis Connection options per instance. If no
14
+ * options are provided, then the service connects as to a single instance
15
+ * with the environment values from REDIS_PORT and REDIS_HOST. if those
16
+ * environment values are not provided, it attempts to connect to localhost:6379
17
+ *
18
+ * @param options
19
+ */
20
+ export declare function setRedisConnectionSettings(options: IOptions): void;
@@ -0,0 +1,52 @@
1
+ var _a, _b;
2
+ import Redis, { Cluster } from 'ioredis';
3
+ export class RedisRegistry {
4
+ static attemptConnection(connectionKey, storeRef = 0) {
5
+ let ref;
6
+ if (RedisRegistry.options.cluster) {
7
+ ref = new Cluster(RedisRegistry.options.cluster.nodes, Object.assign(Object.assign({}, RedisRegistry.options.cluster.options), { redisOptions: {
8
+ db: storeRef,
9
+ } }));
10
+ }
11
+ ref = new Redis(Object.assign(Object.assign({}, RedisRegistry.options.redis), { db: storeRef }));
12
+ RedisRegistry.registry.set(connectionKey, ref);
13
+ return ref;
14
+ }
15
+ static getConnection(connectionType = 'primary', storeRef = 0) {
16
+ const connectionKey = `${connectionType}${storeRef}`;
17
+ let ref = this.registry.get(connectionKey);
18
+ if (!ref) {
19
+ if (RedisRegistry.options.cluster) {
20
+ ref = new Cluster(RedisRegistry.options.cluster.nodes, Object.assign(Object.assign({}, RedisRegistry.options.cluster.options), { redisOptions: {
21
+ db: storeRef,
22
+ } }));
23
+ }
24
+ ref = new Redis(Object.assign(Object.assign({}, RedisRegistry.options.redis), { db: storeRef }));
25
+ }
26
+ return ref;
27
+ }
28
+ static setOptions(options) {
29
+ RedisRegistry.options = options;
30
+ }
31
+ static _getOptions() {
32
+ return RedisRegistry.options;
33
+ }
34
+ }
35
+ RedisRegistry.registry = new Map();
36
+ RedisRegistry.options = {
37
+ redis: {
38
+ port: parseInt((_a = process.env['REDIS_PORT']) !== null && _a !== void 0 ? _a : '6379'),
39
+ host: (_b = process.env['REDIS_HOST']) !== null && _b !== void 0 ? _b : 'localhost',
40
+ },
41
+ };
42
+ /**
43
+ * This function is used to set Redis Connection options per instance. If no
44
+ * options are provided, then the service connects as to a single instance
45
+ * with the environment values from REDIS_PORT and REDIS_HOST. if those
46
+ * environment values are not provided, it attempts to connect to localhost:6379
47
+ *
48
+ * @param options
49
+ */
50
+ export function setRedisConnectionSettings(options) {
51
+ RedisRegistry.setOptions(options);
52
+ }
@@ -0,0 +1,14 @@
1
+ import { RedisType } from './registry';
2
+ /**
3
+ * DO NOT USE THIS CLASS IF YOU DON'T KNOW WHAT YOU ARE DOING. This class is
4
+ * meant to be used internally by the scheduler application
5
+ */
6
+ export declare class ScheduledProcessor {
7
+ private scheduledMessagesTimer;
8
+ private _redisPublisher?;
9
+ get redisPublisher(): RedisType;
10
+ constructor(duration?: number);
11
+ private processScheduledEvents;
12
+ getAllScheduledEvents(): Promise<Array<string>>;
13
+ close(): Promise<void>;
14
+ }
@@ -0,0 +1,55 @@
1
+ import { __awaiter } from "tslib";
2
+ import { generateID } from '@jetit/id';
3
+ import { interval } from 'rxjs';
4
+ import { RedisRegistry } from './registry';
5
+ /**
6
+ * DO NOT USE THIS CLASS IF YOU DON'T KNOW WHAT YOU ARE DOING. This class is
7
+ * meant to be used internally by the scheduler application
8
+ */
9
+ export class ScheduledProcessor {
10
+ get redisPublisher() {
11
+ if (!this._redisPublisher)
12
+ this._redisPublisher = RedisRegistry.getConnection('publish');
13
+ return this._redisPublisher;
14
+ }
15
+ constructor(duration = 1000) {
16
+ this.scheduledMessagesTimer = interval(duration).subscribe(() => {
17
+ this.processScheduledEvents().catch((error) => {
18
+ console.error('Error while processing scheduled events:', error);
19
+ });
20
+ });
21
+ }
22
+ processScheduledEvents() {
23
+ return __awaiter(this, void 0, void 0, function* () {
24
+ const currentTime = new Date().getTime();
25
+ const events = yield this.redisPublisher.zrangebyscore('se', 0, currentTime);
26
+ for (const eventString of events) {
27
+ const eventData = JSON.parse(eventString);
28
+ /**
29
+ * Remove the event from the Redis Sorted Set first. Please note that
30
+ * there is a chance of failure here if the process crashes before
31
+ * the event is published. In that case, the event will be lost.
32
+ *
33
+ * Instead of using the publish method directly, the entire logic is
34
+ * copy pasted to reduce the case of failure.
35
+ */
36
+ const transaction = this.redisPublisher.multi();
37
+ eventData.eventId = generateID('HEX', 'FF');
38
+ transaction.zrem('se', eventString);
39
+ transaction.xadd(eventData.eventName, '*', 'data', JSON.stringify(eventData));
40
+ transaction.publish(eventData.eventName, '');
41
+ yield transaction.exec();
42
+ }
43
+ });
44
+ }
45
+ getAllScheduledEvents() {
46
+ return this.redisPublisher.zrange('se', 0, -1);
47
+ }
48
+ close() {
49
+ return __awaiter(this, void 0, void 0, function* () {
50
+ if (this.scheduledMessagesTimer) {
51
+ this.scheduledMessagesTimer.unsubscribe();
52
+ }
53
+ });
54
+ }
55
+ }
@@ -0,0 +1,153 @@
1
+ import { RedisType } from './registry';
2
+ import { EventData } from './types';
3
+ import { Observable } from 'rxjs';
4
+ export declare class Streams {
5
+ private _redisPublisher?;
6
+ private _redisSubscriber?;
7
+ private _redisGroups?;
8
+ private consumerGroupName;
9
+ private instanceId;
10
+ private cleanUpTimer;
11
+ private eventsListened;
12
+ get redisPublisher(): RedisType;
13
+ get redisSubscriber(): RedisType;
14
+ get redisGroups(): RedisType;
15
+ /**
16
+ * Creates a new Streams instance for a given service.
17
+ *
18
+ * The constructor initializes the Redis connections for publishers, subscribers, and consumer groups.
19
+ * It also sets up an interval timer for clearing expired messages from Redis and another interval timer
20
+ * for processing scheduled events at regular intervals.
21
+ *
22
+ * @param serviceName - A unique name for the service that will be using this Streams instance.
23
+ *
24
+ * @example
25
+ *
26
+ * // Create a new Streams instance for the "POS" service
27
+ * const streams = new Streams('POS');
28
+ */
29
+ constructor(serviceName: string);
30
+ private createStream;
31
+ private isDuplicateMessage;
32
+ private clearDuplicationCheckKeys;
33
+ /**
34
+ * Publishes an event with the given data to the Redis event stream.
35
+ *
36
+ * The method generates a unique event ID for each event, and adds it to the event data to handle message
37
+ * deduplication.
38
+ *
39
+ * @param data - An EventData<T> object containing the data for the event.
40
+ *
41
+ * @returns A Promise that resolves when the event has been published to the Redis stream.
42
+ *
43
+ * @example
44
+ *
45
+ * // Publish an "order.created" event with the given order data
46
+ * const orderData = { id: '123', customerName: 'John Doe', amount: 100 };
47
+ * const eventData = { eventName: 'order.created', data: orderData };
48
+ * await streams.publish(eventData);
49
+ */
50
+ publish<T>(data: EventData<T>): Promise<void>;
51
+ /**
52
+ * Schedules an event to be published at a specified future time. Thee event gets published if the
53
+ * differnece between the current time and the scheduled time is less than 500ms.
54
+ *
55
+ * @param scheduledTime - The Date object representing the future time when the event should be published.
56
+ * @param eventData - The event data object, containing the event name and its associated data.
57
+ *
58
+ * @throws Error - Throws an error if the scheduled time is in the past.
59
+ *
60
+ * @example
61
+ *
62
+ * const streams = new Streams('app-service');
63
+ *
64
+ * const futureTime = new Date(Date.now() + 10000); // 10 seconds from now
65
+ * const eventData: EventData<string> = {
66
+ * eventName: 'order.created',
67
+ * data: 'Order data'
68
+ * };
69
+ *
70
+ * await streams.scheduledPublish(futureTime, eventData);
71
+ */
72
+ scheduledPublish<T>(scheduledTime: Date, eventData: EventData<T>, uniquePerInstance?: boolean): Promise<void>;
73
+ /**
74
+ * Listens for events with the given name and returns an Observable that emits an EventData<T> object
75
+ * each time a new event is received.
76
+ *
77
+ * The method uses a BehaviorSubject to emit the events as Observables. The BehaviorSubject ensures
78
+ * that new subscribers receive the last emitted event, even if they subscribe after the event has been emitted.
79
+ *
80
+ * If an error occurs while subscribing, the method logs the error to the console and throws
81
+ * an error. This is done to prevent the service from continuing without a proper event subscription.
82
+ *
83
+ * There is retry logic with exponential backoff to handle error cases. These are also controllable by the
84
+ * calling service
85
+ *
86
+ * @param eventName - The name of the event to listen for.
87
+ *
88
+ * @returns An Observable that emits an EventData<T> object each time a new event is received.
89
+ *
90
+ * @example
91
+ *
92
+ * // Listen for "order.created" events
93
+ * const orderCreated = streams.listen<OrderCreatedEvent>('order.created');
94
+ *
95
+ * // Subscribe to the Observable and log each new event
96
+ * orderCreated.subscribe((event) => {
97
+ * console.log('New order created:', event.data);
98
+ * });
99
+ */
100
+ listen<T>(eventName: string, maxRetries?: number, initialDelay?: number): Observable<EventData<T>>;
101
+ private listenInternals;
102
+ /**
103
+ * This method takes all messages allocated to this instance and republishes them so
104
+ * that other instances of this service can receive and process them.
105
+ *
106
+ * This needs to be handled every 1-2 minutes if the queue becomes too long and messages
107
+ * are not being processed.
108
+ *
109
+ * Ideal implementation would be to wrap this inside a setInterval
110
+ * @param streamName
111
+ */
112
+ republishUnprocessedEvents(eventName: string): Promise<void>;
113
+ /**
114
+ * This method is used to claim messages in the event of a service crash. This library currently
115
+ * does not detect a service crash. This needs to be built as an extension of Kubernetes and
116
+ * a standalone service that notifies this service to process the events that are marked as
117
+ * pending
118
+ *
119
+ * @param streamName
120
+ * @param idleTimeout
121
+ *
122
+ * * @example
123
+ *
124
+ * // Attempt to recover messages from the "order.created" stream with an idle timeout of 10 seconds
125
+ * await streams.recoverCrashedConsumerMessages('order.created', 10000);
126
+ */
127
+ recoverCrashedConsumerMessages(eventName: string, idleTimeout: number): Promise<void>;
128
+ /**
129
+ * This method allows the possibility of a graceful shutdown by cleaning up the
130
+ * redis connections.
131
+ *
132
+ * In all services where the library is used, its better to implement this method
133
+ *
134
+ * process.on('SIGTERM', shutdown);
135
+ * process.on('SIGINT', shutdown);
136
+ *
137
+ * async function shutdown(): Promise<void> {
138
+ * console.log('Graceful shutdown initiated.');
139
+ * try {
140
+ * await streams.close();
141
+ * console.log('Resources and connections successfully closed.');
142
+ * } catch (error) {
143
+ * console.error('Error during graceful shutdown:', error);
144
+ * }
145
+ * process.exit(0);
146
+ * }
147
+ */
148
+ close(): Promise<void>;
149
+ private clearSubscribedEvents;
150
+ private registerConsumerGroup;
151
+ private getAllConsumerGroups;
152
+ private cleanupAcknowledgedMessages;
153
+ }
@@ -0,0 +1,393 @@
1
+ import { __awaiter } from "tslib";
2
+ import { RedisRegistry } from './registry';
3
+ import { BehaviorSubject, skip, timer, interval, catchError, retry, throwError } from 'rxjs';
4
+ import { generateID } from '@jetit/id';
5
+ export class Streams {
6
+ get redisPublisher() {
7
+ if (!this._redisPublisher)
8
+ this._redisPublisher = RedisRegistry.getConnection('publish');
9
+ return this._redisPublisher;
10
+ }
11
+ get redisSubscriber() {
12
+ if (!this._redisSubscriber)
13
+ this._redisSubscriber = RedisRegistry.getConnection('subscriber');
14
+ return this._redisSubscriber;
15
+ }
16
+ get redisGroups() {
17
+ if (!this._redisGroups)
18
+ this._redisGroups = RedisRegistry.getConnection('groups');
19
+ return this._redisGroups;
20
+ }
21
+ /**
22
+ * Creates a new Streams instance for a given service.
23
+ *
24
+ * The constructor initializes the Redis connections for publishers, subscribers, and consumer groups.
25
+ * It also sets up an interval timer for clearing expired messages from Redis and another interval timer
26
+ * for processing scheduled events at regular intervals.
27
+ *
28
+ * @param serviceName - A unique name for the service that will be using this Streams instance.
29
+ *
30
+ * @example
31
+ *
32
+ * // Create a new Streams instance for the "POS" service
33
+ * const streams = new Streams('POS');
34
+ */
35
+ constructor(serviceName) {
36
+ var _a;
37
+ this.eventsListened = [];
38
+ this.instanceId = `${serviceName}:${generateID('HEX', 'FE')}`;
39
+ this.consumerGroupName = `cg-${serviceName}`;
40
+ const cleanUpInterval = (_a = parseInt(process.env['CLEANUP_INTERVAL'] || '1000 * 60 * 60', 10)) !== null && _a !== void 0 ? _a : 1000 * 60 * 60;
41
+ this.cleanUpTimer = interval(cleanUpInterval).subscribe(() => {
42
+ this.clearDuplicationCheckKeys();
43
+ this.eventsListened.forEach((eventName) => this.cleanupAcknowledgedMessages(eventName, cleanUpInterval));
44
+ });
45
+ }
46
+ createStream(eventName) {
47
+ return __awaiter(this, void 0, void 0, function* () {
48
+ try {
49
+ const streamName = `${eventName}:${this.consumerGroupName}`;
50
+ yield this.redisGroups.xgroup('CREATE', streamName, this.consumerGroupName, '0', 'MKSTREAM');
51
+ }
52
+ catch (error) {
53
+ if (error.message !== 'BUSYGROUP Consumer Group name already exists') {
54
+ throw error;
55
+ }
56
+ }
57
+ });
58
+ }
59
+ isDuplicateMessage(streamName, messageId) {
60
+ return __awaiter(this, void 0, void 0, function* () {
61
+ const processedMessagesKey = `pm:${this.consumerGroupName}:${streamName}`;
62
+ const temp = yield Promise.race([
63
+ this.redisGroups.zscore(processedMessagesKey, messageId),
64
+ /** ioRedis doesnt seem to return the nil event. So waiting for 100ms before moving on */
65
+ new Promise((res) => setTimeout(() => res(null), 100)),
66
+ ]);
67
+ return temp !== null;
68
+ });
69
+ }
70
+ clearDuplicationCheckKeys() {
71
+ return __awaiter(this, void 0, void 0, function* () {
72
+ const processedMessagesKeyPattern = `pm:${this.consumerGroupName}:*`;
73
+ let cursor = '0';
74
+ do {
75
+ const [nextCursor, keys] = yield this.redisGroups.scan(cursor, 'MATCH', processedMessagesKeyPattern);
76
+ cursor = nextCursor;
77
+ for (const key of keys) {
78
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
79
+ yield this.redisGroups.zremrangebyscore(key, '-inf', oneHourAgo);
80
+ }
81
+ } while (cursor !== '0');
82
+ });
83
+ }
84
+ /**
85
+ * Publishes an event with the given data to the Redis event stream.
86
+ *
87
+ * The method generates a unique event ID for each event, and adds it to the event data to handle message
88
+ * deduplication.
89
+ *
90
+ * @param data - An EventData<T> object containing the data for the event.
91
+ *
92
+ * @returns A Promise that resolves when the event has been published to the Redis stream.
93
+ *
94
+ * @example
95
+ *
96
+ * // Publish an "order.created" event with the given order data
97
+ * const orderData = { id: '123', customerName: 'John Doe', amount: 100 };
98
+ * const eventData = { eventName: 'order.created', data: orderData };
99
+ * await streams.publish(eventData);
100
+ */
101
+ publish(data) {
102
+ return __awaiter(this, void 0, void 0, function* () {
103
+ data.eventId = generateID('HEX', 'FF'); // Added a unique Id to handle Message deduplication
104
+ const transaction = this.redisPublisher.multi();
105
+ const consumerGroups = yield this.getAllConsumerGroups(data.eventName);
106
+ if (consumerGroups.length > 0) {
107
+ console.log(`Publishing event ${data.eventName} to consumer groups: ${consumerGroups.join(', ')}`);
108
+ for (const consumerGroup of consumerGroups) {
109
+ // Publish the event to each consumer group's stream
110
+ const streamName = `${data.eventName}:${consumerGroup}`;
111
+ transaction.xadd(streamName, '*', 'data', JSON.stringify(data));
112
+ }
113
+ transaction.publish(data.eventName, '');
114
+ yield transaction.exec().catch((error) => {
115
+ console.error(`Error while publishing event for service ${this.consumerGroupName} with instance ${this.instanceId}: `, error);
116
+ throw new Error('Publisher Error');
117
+ });
118
+ }
119
+ });
120
+ }
121
+ /**
122
+ * Schedules an event to be published at a specified future time. Thee event gets published if the
123
+ * differnece between the current time and the scheduled time is less than 500ms.
124
+ *
125
+ * @param scheduledTime - The Date object representing the future time when the event should be published.
126
+ * @param eventData - The event data object, containing the event name and its associated data.
127
+ *
128
+ * @throws Error - Throws an error if the scheduled time is in the past.
129
+ *
130
+ * @example
131
+ *
132
+ * const streams = new Streams('app-service');
133
+ *
134
+ * const futureTime = new Date(Date.now() + 10000); // 10 seconds from now
135
+ * const eventData: EventData<string> = {
136
+ * eventName: 'order.created',
137
+ * data: 'Order data'
138
+ * };
139
+ *
140
+ * await streams.scheduledPublish(futureTime, eventData);
141
+ */
142
+ scheduledPublish(scheduledTime, eventData, uniquePerInstance = false) {
143
+ return __awaiter(this, void 0, void 0, function* () {
144
+ const currentTime = new Date();
145
+ if (scheduledTime < currentTime) {
146
+ throw new Error('Cannot schedule an event in the past');
147
+ }
148
+ else if (Math.abs(scheduledTime.getTime() - currentTime.getTime()) <= 500) {
149
+ yield this.publish(eventData);
150
+ }
151
+ else {
152
+ if (uniquePerInstance === true) {
153
+ const existingJob = yield this.redisPublisher.zscore('se', JSON.stringify(eventData));
154
+ if (existingJob) {
155
+ console.log(`Job with data '${eventData}' already exists. Skipping.`);
156
+ return;
157
+ }
158
+ }
159
+ yield this.redisPublisher.zadd('se', scheduledTime.getTime(), JSON.stringify(eventData));
160
+ }
161
+ });
162
+ }
163
+ /**
164
+ * Listens for events with the given name and returns an Observable that emits an EventData<T> object
165
+ * each time a new event is received.
166
+ *
167
+ * The method uses a BehaviorSubject to emit the events as Observables. The BehaviorSubject ensures
168
+ * that new subscribers receive the last emitted event, even if they subscribe after the event has been emitted.
169
+ *
170
+ * If an error occurs while subscribing, the method logs the error to the console and throws
171
+ * an error. This is done to prevent the service from continuing without a proper event subscription.
172
+ *
173
+ * There is retry logic with exponential backoff to handle error cases. These are also controllable by the
174
+ * calling service
175
+ *
176
+ * @param eventName - The name of the event to listen for.
177
+ *
178
+ * @returns An Observable that emits an EventData<T> object each time a new event is received.
179
+ *
180
+ * @example
181
+ *
182
+ * // Listen for "order.created" events
183
+ * const orderCreated = streams.listen<OrderCreatedEvent>('order.created');
184
+ *
185
+ * // Subscribe to the Observable and log each new event
186
+ * orderCreated.subscribe((event) => {
187
+ * console.log('New order created:', event.data);
188
+ * });
189
+ */
190
+ listen(eventName, maxRetries = 5, initialDelay = 1000) {
191
+ this.registerConsumerGroup(eventName); // Registers the consumer group for listening to the message
192
+ this.eventsListened.push(eventName);
193
+ return this.listenInternals(eventName).pipe(retry({
194
+ count: maxRetries,
195
+ delay: (error, retryAttempt) => {
196
+ const delay = initialDelay * Math.pow(2, retryAttempt);
197
+ console.error(`Error in listen: ${error.message}. Retrying in ${delay}ms (attempt ${retryAttempt + 1})`);
198
+ return timer(delay);
199
+ },
200
+ }), catchError((error) => {
201
+ console.error(`Error in listen after ${maxRetries} retries: ${error.message}`);
202
+ return throwError(() => new Error(error.message));
203
+ }));
204
+ }
205
+ listenInternals(eventName) {
206
+ try {
207
+ this.createStream(eventName);
208
+ const bs = new BehaviorSubject(null);
209
+ const observable = bs.asObservable().pipe(skip(1));
210
+ const streamName = `${eventName}:${this.consumerGroupName}`;
211
+ this.redisSubscriber.subscribe(eventName);
212
+ const processMessage = () => __awaiter(this, void 0, void 0, function* () {
213
+ try {
214
+ const result = yield this.redisGroups.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'BLOCK', 0, 'STREAMS', streamName, '>');
215
+ if (result) {
216
+ const [, streamMessages] = result[0];
217
+ for (const [id, data] of streamMessages) {
218
+ const eventData = JSON.parse(data[1]);
219
+ const messageId = eventData.eventId;
220
+ const isDuplicate = yield this.isDuplicateMessage(streamName, messageId);
221
+ if (isDuplicate) {
222
+ console.warn(`Duplicate message detected: ${messageId}`);
223
+ yield this.redisGroups.xack(streamName, this.consumerGroupName, id);
224
+ continue;
225
+ }
226
+ bs.next(eventData);
227
+ const pmKey = `pm:${this.consumerGroupName}:${streamName}`;
228
+ const currentTime = Date.now();
229
+ const transaction = this.redisGroups.multi();
230
+ transaction.zadd(pmKey, currentTime, messageId);
231
+ transaction.xack(streamName, this.consumerGroupName, id);
232
+ transaction.zadd(`ack:${streamName}`, Date.now(), id);
233
+ yield transaction.exec();
234
+ }
235
+ }
236
+ }
237
+ catch (e) {
238
+ console.error(JSON.stringify(e));
239
+ }
240
+ });
241
+ this.redisSubscriber.on('message', () => __awaiter(this, void 0, void 0, function* () {
242
+ processMessage();
243
+ }));
244
+ return observable;
245
+ }
246
+ catch (e) {
247
+ console.error(JSON.stringify(e));
248
+ throw e;
249
+ }
250
+ }
251
+ /**
252
+ * This method takes all messages allocated to this instance and republishes them so
253
+ * that other instances of this service can receive and process them.
254
+ *
255
+ * This needs to be handled every 1-2 minutes if the queue becomes too long and messages
256
+ * are not being processed.
257
+ *
258
+ * Ideal implementation would be to wrap this inside a setInterval
259
+ * @param streamName
260
+ */
261
+ republishUnprocessedEvents(eventName) {
262
+ return __awaiter(this, void 0, void 0, function* () {
263
+ const streamName = `${eventName}:${this.consumerGroupName}`;
264
+ const result = yield this.redisGroups.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'STREAMS', streamName, '>');
265
+ if (result) {
266
+ const [, streamMessages] = result[0];
267
+ for (const [id, data] of streamMessages) {
268
+ const eventData = JSON.parse(data[1]);
269
+ console.log(`Unprocessed event: ${id}, data:`, eventData);
270
+ const transaction = this.redisGroups.multi();
271
+ // Republishing the events
272
+ transaction.xadd(streamName, '*', 'data', JSON.stringify(eventData));
273
+ transaction.publish(eventName, '');
274
+ transaction.xack(streamName, this.consumerGroupName, id);
275
+ yield transaction.exec();
276
+ }
277
+ }
278
+ });
279
+ }
280
+ /**
281
+ * This method is used to claim messages in the event of a service crash. This library currently
282
+ * does not detect a service crash. This needs to be built as an extension of Kubernetes and
283
+ * a standalone service that notifies this service to process the events that are marked as
284
+ * pending
285
+ *
286
+ * @param streamName
287
+ * @param idleTimeout
288
+ *
289
+ * * @example
290
+ *
291
+ * // Attempt to recover messages from the "order.created" stream with an idle timeout of 10 seconds
292
+ * await streams.recoverCrashedConsumerMessages('order.created', 10000);
293
+ */
294
+ recoverCrashedConsumerMessages(eventName, idleTimeout) {
295
+ return __awaiter(this, void 0, void 0, function* () {
296
+ const streamName = `${eventName}:${this.consumerGroupName}`;
297
+ const pendingMessages = (yield this.redisGroups.xpending(streamName, this.consumerGroupName));
298
+ if (!pendingMessages)
299
+ return;
300
+ const [, minId, maxId, consumers] = pendingMessages;
301
+ for (const [consumer, pendingCount] of consumers) {
302
+ if (parseInt(pendingCount) > 0) {
303
+ const pending = (yield this.redisGroups.xpending(streamName, this.consumerGroupName, minId, maxId, Number(pendingCount), consumer));
304
+ for (const [messageId] of pending) {
305
+ const claimedMessage = (yield this.redisGroups.xclaim(streamName, this.consumerGroupName, this.instanceId, idleTimeout, messageId));
306
+ if (claimedMessage) {
307
+ const [, data] = claimedMessage[0];
308
+ const eventData = JSON.parse(data[1]);
309
+ const transaction = this.redisGroups.multi();
310
+ transaction.xadd(streamName, '*', 'data', JSON.stringify(eventData));
311
+ transaction.publish(eventName, '');
312
+ transaction.xack(streamName, this.consumerGroupName, messageId);
313
+ yield transaction.exec();
314
+ }
315
+ }
316
+ }
317
+ }
318
+ });
319
+ }
320
+ /**
321
+ * This method allows the possibility of a graceful shutdown by cleaning up the
322
+ * redis connections.
323
+ *
324
+ * In all services where the library is used, its better to implement this method
325
+ *
326
+ * process.on('SIGTERM', shutdown);
327
+ * process.on('SIGINT', shutdown);
328
+ *
329
+ * async function shutdown(): Promise<void> {
330
+ * console.log('Graceful shutdown initiated.');
331
+ * try {
332
+ * await streams.close();
333
+ * console.log('Resources and connections successfully closed.');
334
+ * } catch (error) {
335
+ * console.error('Error during graceful shutdown:', error);
336
+ * }
337
+ * process.exit(0);
338
+ * }
339
+ */
340
+ close() {
341
+ return __awaiter(this, void 0, void 0, function* () {
342
+ this.clearSubscribedEvents();
343
+ if (this.redisPublisher) {
344
+ yield this.redisPublisher.quit();
345
+ }
346
+ if (this.redisSubscriber) {
347
+ yield this.redisSubscriber.quit();
348
+ }
349
+ if (this.redisGroups) {
350
+ yield this.redisGroups.quit();
351
+ }
352
+ if (this.cleanUpTimer) {
353
+ this.cleanUpTimer.unsubscribe();
354
+ }
355
+ });
356
+ }
357
+ clearSubscribedEvents() {
358
+ return __awaiter(this, void 0, void 0, function* () {
359
+ console.log(`${this.eventsListened.length} events to be cleared`);
360
+ for (const eventName of this.eventsListened) {
361
+ yield this.redisGroups.srem(`consumerGroups:${eventName}`, this.consumerGroupName);
362
+ }
363
+ });
364
+ }
365
+ registerConsumerGroup(eventName) {
366
+ return __awaiter(this, void 0, void 0, function* () {
367
+ yield this.redisGroups.sadd(`consumerGroups:${eventName}`, this.consumerGroupName);
368
+ });
369
+ }
370
+ getAllConsumerGroups(eventName) {
371
+ return __awaiter(this, void 0, void 0, function* () {
372
+ const consumerGroups = yield this.redisGroups.smembers(`consumerGroups:${eventName}`);
373
+ return consumerGroups;
374
+ });
375
+ }
376
+ cleanupAcknowledgedMessages(eventName, interval = 60 * 60 * 1000) {
377
+ return __awaiter(this, void 0, void 0, function* () {
378
+ const streamName = `${eventName}:${this.consumerGroupName}`;
379
+ const cleanupThreshold = Date.now() - interval;
380
+ const acknowledgedMessages = yield this.redisGroups.zrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
381
+ if (acknowledgedMessages && acknowledgedMessages.length > 0) {
382
+ const transaction = this.redisGroups.multi();
383
+ // Remove acknowledged messages from the stream
384
+ for (const messageId of acknowledgedMessages) {
385
+ transaction.xdel(streamName, messageId);
386
+ }
387
+ // Remove acknowledged messages from the Sorted Set
388
+ transaction.zremrangebyscore(`ack:${streamName}`, '-inf', cleanupThreshold);
389
+ yield transaction.exec();
390
+ }
391
+ });
392
+ }
393
+ }
@@ -0,0 +1,15 @@
1
+ export type EventData<T> = {
2
+ data: T;
3
+ eventName: string;
4
+ eventId?: string;
5
+ };
6
+ export type PendingMessages = [never, never, string, string, Array<[string, number]>];
7
+ export type ClaimedMessages = Array<[never, string]>;
8
+ import { ClusterNode, ClusterOptions, RedisOptions } from 'ioredis';
9
+ export interface IOptions {
10
+ redis?: RedisOptions;
11
+ cluster?: {
12
+ options: ClusterOptions;
13
+ nodes: Array<ClusterNode>;
14
+ };
15
+ }
@@ -0,0 +1 @@
1
+ export {};