@jetit/publisher 1.6.0 → 1.6.2
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 +158 -0
- package/package.json +2 -2
- package/src/lib/redis/streams.js +15 -5
package/README.md
CHANGED
|
@@ -2,6 +2,164 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+
## IMPORTANT NOTE
|
|
6
|
+
This project currently does not have a means to clean up inactive consumers. This means that if you have a consumer that is no longer active, it will continue to receive events until it is removed from the consumer group. This is a known issue and will be addressed in a future release. A workaround is to use the following code to remove inactive consumers from the consumer group as part of your process cleanup:
|
|
7
|
+
|
|
8
|
+
```javascript
|
|
9
|
+
const ioredis = require(`ioredis`);
|
|
10
|
+
|
|
11
|
+
const env = process.env;
|
|
12
|
+
|
|
13
|
+
async function bootstrap() {
|
|
14
|
+
const connection = new ioredis.Redis({
|
|
15
|
+
host: env.REDIS_HOST,
|
|
16
|
+
port: parseInt(env.REDIS_PORT),
|
|
17
|
+
});
|
|
18
|
+
console.log(`Redis Connection Status ${connection.status}`);
|
|
19
|
+
await waitForSeconds(0.3);
|
|
20
|
+
console.log(`Redis Connection Status ${connection.status}`);
|
|
21
|
+
const instanceUniqueId = env.INSTANCE_ID;
|
|
22
|
+
if (!instanceUniqueId) {
|
|
23
|
+
console.log(`Unique instance ID is not available`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
console.log(`Instance Unique ID : ${instanceUniqueId}`);
|
|
27
|
+
const consumerGroupName = await getConsumerGroupName(connection, instanceUniqueId);
|
|
28
|
+
if (!consumerGroupName) {
|
|
29
|
+
console.log(`Consumer is not available, so graceful shutdown is not necessary.`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`Consumer Group Name : ${consumerGroupName}`);
|
|
33
|
+
const instanceId = getInstanceId(consumerGroupName.slice(3), instanceUniqueId);
|
|
34
|
+
console.log(`Instance ID : ${instanceId}`);
|
|
35
|
+
const subscribedEvents = await getAllEventsForInstance(connection, instanceId);
|
|
36
|
+
console.log(`Subscribed Events : ${JSON.stringify(subscribedEvents)}`);
|
|
37
|
+
await clearSubscribedEvents(connection, consumerGroupName, instanceId, subscribedEvents);
|
|
38
|
+
await deleteConsumerGroupNameForInstance(connection, instanceId);
|
|
39
|
+
await deleteAllEventsFroInstance(connection, instanceId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
*
|
|
44
|
+
* @param {ioredis.Redis|ioredis.Cluster} connection
|
|
45
|
+
* @param {string} consumerGroupName
|
|
46
|
+
* @param {string} instanceId
|
|
47
|
+
* @param {Array<string>} events
|
|
48
|
+
*/
|
|
49
|
+
async function clearSubscribedEvents(connection, consumerGroupName, instanceId, events) {
|
|
50
|
+
return Promise.all(
|
|
51
|
+
events.map(async (eventName) => {
|
|
52
|
+
console.log(`${eventName} is being cleared in publisher`);
|
|
53
|
+
const streamName = `${eventName}:${consumerGroupName}`;
|
|
54
|
+
console.log(`${streamName} is being removed.`);
|
|
55
|
+
await connection.srem(`${eventName}`, consumerGroupName);
|
|
56
|
+
console.log(`${eventName} is removed from ${consumerGroupName}`);
|
|
57
|
+
// Releasing all claims based on info from: https://redis.io/commands/xgroup-delconsumer/
|
|
58
|
+
await releaseAllClaims(connection, streamName, consumerGroupName, instanceId);
|
|
59
|
+
console.log(`${eventName} removes all claims`);
|
|
60
|
+
await connection.xgroup(`DELCONSUMER`, streamName, consumerGroupName, instanceId);
|
|
61
|
+
console.log(`${eventName} is deleted as a consumer from ${consumerGroupName}, ${instanceId}`);
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
*
|
|
68
|
+
* @param {ioredis.Redis|ioredis.Cluster} connection
|
|
69
|
+
* @param {string} streamName
|
|
70
|
+
* @param {string} consumerGroupName
|
|
71
|
+
* @param {string} instanceId
|
|
72
|
+
*/
|
|
73
|
+
async function releaseAllClaims(connection, streamName, consumerGroupName, instanceId) {
|
|
74
|
+
/**
|
|
75
|
+
* Retrieve the pending messages for the consumer. Note this only fetches the last
|
|
76
|
+
* 10000 events assigned to this consumer. This function has been modified to make sure
|
|
77
|
+
* that there is a temp instance that claims all this messages
|
|
78
|
+
*/
|
|
79
|
+
const pendingMessages = await connection.xpending(streamName, consumerGroupName, `-`, `+`, 10000, instanceId);
|
|
80
|
+
|
|
81
|
+
if (pendingMessages && pendingMessages.length > 0) {
|
|
82
|
+
console.log(`${pendingMessages.length} messages to clean up.`);
|
|
83
|
+
const transaction = connection.multi({ pipeline: true });
|
|
84
|
+
const tempConsumerId = `${consumerGroupName}-temp`;
|
|
85
|
+
for (const [messageId] of pendingMessages) {
|
|
86
|
+
transaction.xclaim(streamName, consumerGroupName, tempConsumerId, 10, messageId);
|
|
87
|
+
}
|
|
88
|
+
await transaction.exec();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
*
|
|
94
|
+
* @param {string} serviceName
|
|
95
|
+
* @param {string} instanceUniqueId
|
|
96
|
+
*/
|
|
97
|
+
function getInstanceId(serviceName, instanceUniqueId) {
|
|
98
|
+
const instanceId = `${serviceName}:${instanceUniqueId}`;
|
|
99
|
+
console.log(`Generated Instance ID : ${instanceId}`);
|
|
100
|
+
return instanceId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
*
|
|
105
|
+
* @param {ioredis.Redis|ioredis.Cluster} serviceName
|
|
106
|
+
* @param {string} instanceId
|
|
107
|
+
* @returns {Promise<string>} consumer group name
|
|
108
|
+
*/
|
|
109
|
+
async function getConsumerGroupName(connection, instanceId) {
|
|
110
|
+
const key = `instance:${instanceId}:consumerGroupName`;
|
|
111
|
+
console.log(`Get consumer group name called for key : ${key}`);
|
|
112
|
+
return await connection.get(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
*
|
|
117
|
+
* @param {ioredis.Redis|ioredis.Cluster} connection
|
|
118
|
+
* @param {string} instanceId
|
|
119
|
+
* @returns
|
|
120
|
+
*/
|
|
121
|
+
async function deleteConsumerGroupNameForInstance(connection, instanceId) {
|
|
122
|
+
return await connection.del(`instance:${instanceId}:consumerGroupName`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
*
|
|
127
|
+
* @param {ioredis.Redis|ioredis.Cluster} connection
|
|
128
|
+
* @param {string} instanceId
|
|
129
|
+
* @returns {Promise<Array<events>>} subscribed events for this instance
|
|
130
|
+
*/
|
|
131
|
+
async function getAllEventsForInstance(connection, instanceId) {
|
|
132
|
+
const key = `instance:${instanceId}:subscribedEvents`;
|
|
133
|
+
console.log(`Get consumer group events : ${key}`);
|
|
134
|
+
return (await connection.sscan(key, 0))[1];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
*
|
|
139
|
+
* @param {ioredis.Redis|ioredis.Cluster} connection
|
|
140
|
+
* @param {string} instanceId
|
|
141
|
+
*/
|
|
142
|
+
async function deleteAllEventsFroInstance(connection, instanceId) {
|
|
143
|
+
return await connection.del(`instance:${instanceId}:subscribedEvents`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function waitForSeconds(seconds = 10) {
|
|
147
|
+
return new Promise((res, _) => setTimeout(() => res(), seconds * 1000));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Start
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
bootstrap()
|
|
155
|
+
.then(() => process.exit(0))
|
|
156
|
+
.catch((e) => {
|
|
157
|
+
console.error(e);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
|
|
5
163
|
## Simple Example
|
|
6
164
|
|
|
7
165
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jetit/publisher",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@jetit/id": "0.0.11",
|
|
7
7
|
"ioredis": "^5.3.0",
|
|
8
|
-
"rxjs": "^7.
|
|
8
|
+
"rxjs": "^7.8.0"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
11
|
"tslib": "2.5.0"
|
package/src/lib/redis/streams.js
CHANGED
|
@@ -223,10 +223,20 @@ class Streams {
|
|
|
223
223
|
const observable = bs.asObservable().pipe((0, rxjs_1.skip)(1));
|
|
224
224
|
/** This gets called the first time the stream is registered to pickup any messages from the previous subscription */
|
|
225
225
|
const streamName = `${eventName}:${this.consumerGroupName}`;
|
|
226
|
-
const processMessage = () => tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
226
|
+
const processMessage = (redisClient) => tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
227
|
+
const racePr = new Promise((resolve, _) => {
|
|
228
|
+
setTimeout(resolve, 1100, 'RACE');
|
|
229
|
+
});
|
|
227
230
|
console.log(`PUBLISHER: processMessage called for ${streamName} cgn: ${this.consumerGroupName} inst: ${this.instanceId}`);
|
|
228
231
|
try {
|
|
229
|
-
const
|
|
232
|
+
const prResult = yield Promise.race([
|
|
233
|
+
redisClient.xreadgroup('GROUP', this.consumerGroupName, this.instanceId, 'COUNT', 1, 'BLOCK', 1000, 'STREAMS', streamName, '>'),
|
|
234
|
+
racePr,
|
|
235
|
+
]);
|
|
236
|
+
console.log(`PUBLISHER: Promise race resolved with ${JSON.stringify(prResult)}`);
|
|
237
|
+
if (prResult && prResult === 'RACE')
|
|
238
|
+
return;
|
|
239
|
+
const result = prResult;
|
|
230
240
|
console.log(`PUBLISHER: XREADGROUP returned with ${JSON.stringify(result[0])}`);
|
|
231
241
|
if (result) {
|
|
232
242
|
const [, streamMessages] = result[0];
|
|
@@ -236,13 +246,13 @@ class Streams {
|
|
|
236
246
|
const isDuplicate = yield this.isDuplicateMessage(streamName, messageId);
|
|
237
247
|
if (isDuplicate) {
|
|
238
248
|
console.warn(`Duplicate message detected: ${messageId}`);
|
|
239
|
-
yield
|
|
249
|
+
yield redisClient.xack(streamName, this.consumerGroupName, id);
|
|
240
250
|
continue;
|
|
241
251
|
}
|
|
242
252
|
bs.next(eventData);
|
|
243
253
|
const pmKey = `pm:${this.consumerGroupName}:${streamName}`;
|
|
244
254
|
const currentTime = Date.now();
|
|
245
|
-
const transaction =
|
|
255
|
+
const transaction = redisClient.multi({ pipeline: true });
|
|
246
256
|
transaction.zadd(pmKey, currentTime, messageId);
|
|
247
257
|
transaction.xack(streamName, this.consumerGroupName, id);
|
|
248
258
|
transaction.zadd(`ack:${streamName}`, Date.now(), id);
|
|
@@ -270,7 +280,7 @@ class Streams {
|
|
|
270
280
|
});
|
|
271
281
|
eventStreamClient.on('message', () => tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
272
282
|
console.log(`PUBLISHER: Stream Notification Recieved for event ${eventName}`);
|
|
273
|
-
yield processMessage();
|
|
283
|
+
yield processMessage(this.redisGroups);
|
|
274
284
|
}));
|
|
275
285
|
this.scanAndClaimAUnclaimedMessage(streamName)
|
|
276
286
|
.then()
|