@codefresh-io/eventbus 2.4.0 → 3.0.0-alpha.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 CHANGED
@@ -1,5 +1,6 @@
1
1
  ## cf eventbus
2
2
 
3
+ ```TS
3
4
  const eventbus = require('cf-eventbus');
4
5
 
5
6
  eventbus.init({
@@ -14,4 +15,41 @@ eventbus.init({
14
15
  password: 'postgres' // postgres password
15
16
  },
16
17
  microServiceName: 'service-name' // client name
17
- });
18
+ });
19
+ ```
20
+
21
+ ## Graceful shutdown
22
+
23
+ `quit()` method can be used to gracefully terminate the bus connection by cancelling all consumers, waiting for all consumers in progress to finish, and then closing the connection.
24
+ If it's not desired, use `quit({ force: true })` to terminate the connection immediately.
25
+
26
+ ```TS
27
+ const eventbus = require('cf-eventbus');
28
+
29
+ process.on('SIGTERM', async () => {
30
+ try {
31
+ await eventbus.quit();
32
+ // ...
33
+ } catch (err) {
34
+ // ...
35
+ }
36
+ });
37
+ ```
38
+
39
+ It's may be required to perform some additional cleanup after all consumers are cancelled and before the connection is closed. In that case, use the following pattern:
40
+
41
+ ```TS
42
+ const eventbus = require('cf-eventbus');
43
+
44
+ process.on('SIGTERM', async () => {
45
+ try {
46
+ await eventbus.cancelAllConsumers();
47
+ await eventbus.waitForConsumersToFinish();
48
+ // perform additional cleanup here, such as waiting for HTTP requests to finish
49
+ await eventbus.quit();
50
+ // ...
51
+ } catch (err) {
52
+ // ...
53
+ }
54
+ });
55
+ ```
package/lib/Bus.js CHANGED
@@ -1,27 +1,49 @@
1
1
  "use strict";
2
2
 
3
- const _ = require('lodash');
4
- const EventEmitter = require('events');
5
- const Promise = require('bluebird');
6
- const debug = require('debug')('eventbus:bus');
3
+ const EventEmitter = require('node:events');
4
+ const timers = require('node:timers/promises');
7
5
  const amqplib = require('amqplib');
8
6
  const isFatalError = require('amqplib/lib/connection').isFatalError;
9
- const domain = require('domain');
10
- const uuid = require('uuid');
7
+ const { logger } = require('./logger');
11
8
 
12
- const RECONNET_INTERVAL_DEFAULT = 1; // in seconds
9
+
10
+ /**
11
+ * @typedef {(...args: any[]) => Promise<void>} Handler
12
+ *
13
+ * @typedef {object} Subscriber
14
+ * @property {string} eventName
15
+ * @property {Handler} handler
16
+ * @property {object} options
17
+ */
18
+
19
+ const RECONNECT_INTERVAL_DEFAULT_SEC = 1;
13
20
 
14
21
  class Bus extends EventEmitter {
22
+ _logger = logger.child(this.constructor.name);
23
+ _consumersInProgressCount = 0;
24
+ _terminating = false;
25
+ _terminated = false;
15
26
 
27
+ /**
28
+ * @param {string} microServiceName
29
+ * @param {object} options
30
+ * @param {string} options.url RabbitMQ connection url
31
+ * @param {number} [options.reconnectInterval] Reconnect interval in seconds. Default is 1 second.
32
+ */
16
33
  constructor(microServiceName, options) {
17
34
  super();
18
- this.url = options.url;
19
- this.microServiceName = microServiceName;
20
- this.reconnectInterval = (options.reconnectInterval || RECONNET_INTERVAL_DEFAULT) * 1000;
35
+ this.url = options.url;
36
+ this.microServiceName = microServiceName;
37
+ this.reconnectIntervalMs = (options.reconnectInterval || RECONNECT_INTERVAL_DEFAULT_SEC) * 1000;
38
+ /** @type {Record<string, Subscriber[]>} */
21
39
  this.subscribers = {};
22
40
  this.initializing = false;
41
+ /** @type {boolean} */
42
+ this._forbidNewConsumers = false;
23
43
  this._reSubscribe = this._reSubscribe.bind(this);
24
44
  this._reconnect = this._reconnect.bind(this);
45
+ /** @type {Map<amqplib.Channel, string[]>} */
46
+ this.consumerTagsByChannel = new Map();
25
47
  }
26
48
 
27
49
  _createErrorHandler() {
@@ -37,28 +59,94 @@ class Bus extends EventEmitter {
37
59
  });
38
60
  }
39
61
 
40
- async quit() {
41
- await this.connection.close();
62
+ /**
63
+ * Unsubscribes all consumers and forbids new consumers from being subscribed.
64
+ * This should be used when you want to gracefully terminate the connection and ensure that no new messages are consumed while terminating.
65
+ * @returns {Promise<void>}
66
+ */
67
+ async cancelAllConsumers() {
68
+ this._logger.info('Cancelling all consumers on all channels. New subscriptions will be forbidden from now on.');
69
+ this._forbidNewConsumers = true;
70
+
71
+ for (const ch of this.consumerTagsByChannel.keys()) {
72
+ const consumerTags = this.consumerTagsByChannel.get(ch) ?? [];
73
+ const results = await Promise.allSettled(
74
+ consumerTags.map((consumerTag) => ch.cancel(consumerTag))
75
+ );
76
+ for (let i = 0; i < results.length; i++) {
77
+ const result = results[i];
78
+ const consumerTag = consumerTags[i];
79
+ if (result.status === 'rejected') {
80
+ this._logger.error(`Failed to cancel consumer with tag "${consumerTag}". Ignoring error`, { error: result.reason });
81
+ continue;
82
+ }
83
+ this._logger.debug(`Consumer with tag "${consumerTag}" was cancelled successfully`);
84
+ }
85
+ }
86
+ this.consumerTagsByChannel.clear();
87
+ this._logger.info('All consumers were cancelled');
88
+ }
89
+
90
+ /**
91
+ * Waits for all consumers in progress to finish.
92
+ * This should be used before terminating the connection
93
+ * to ensure that all messages are processed before the connection is closed.
94
+ * @returns {Promise<void>}
95
+ */
96
+ async waitForConsumersToFinish() {
97
+ while (this._consumersInProgressCount > 0) {
98
+ this._logger.info(`Waiting for ${this._consumersInProgressCount} consumers in progress to finish...`);
99
+ await timers.setTimeout(1000);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Terminates the connection gracefully by cancelling all consumers, waiting for all consumers in progress to finish, and then closing the connection.
105
+ * @param {object} [options]
106
+ * @param {boolean} [options.force] If `true`, closes connection immediately.
107
+ */
108
+ async quit(options = {}) {
109
+ this._terminating = true;
110
+ if (options.force) {
111
+ this._logger.warn('Forcefully terminating eventbus connection. All consumers in progress will be interrupted and may not finish processing messages');
112
+ await this._closeConnection();
113
+ this._terminated = true;
114
+ return;
115
+ }
116
+ this._logger.info('Gracefully terminating eventbus connection');
117
+ await this.cancelAllConsumers();
118
+ await this.waitForConsumersToFinish();
119
+ await this._closeConnection();
120
+ this._terminated = true;
121
+ }
122
+
123
+ async _closeConnection() {
124
+ this._logger.info('Closing eventbus connection');
125
+ await this.connection?.close();
42
126
  this.subscribers = {};
43
- debug('bus closed');
127
+ this._logger.info('Eventbus connection closed');
44
128
  }
45
129
 
130
+ /**
131
+ * @returns {void}
132
+ */
46
133
  init() {
47
134
  this.initializing = true;
48
135
  this._createErrorHandler();
49
136
 
137
+ this._logger.debug('Eventbus connecting...');
50
138
  this.openPromise = amqplib.connect(this.url);
51
139
  this.openPromise
52
140
  .then((newConnection) => {
53
- debug('new connection created');
141
+ this._logger.debug('Eventbus connected');
54
142
  if (this.reconnect && this.connection) {
55
143
  this.publishChannel = undefined;
56
- debug('reconnected');
144
+ this._logger.debug('Eventbus reconnected');
57
145
  }
58
146
  this.reconnect = false;
59
147
  this.connection = newConnection;
60
148
 
61
- debug(`ready`);
149
+ this._logger.debug('Eventbus ready');
62
150
  this.emit('ready');
63
151
 
64
152
  this.connection.on('error', (err) => {
@@ -66,17 +154,17 @@ class Bus extends EventEmitter {
66
154
  });
67
155
 
68
156
  this.connection.on('close', () => {
69
- debug(`connection close event`);
157
+ this._logger.debug('Eventbus connection closed');
70
158
  this.emit('close');
71
159
  this._reconnect();
72
160
  });
73
161
 
74
162
  this.connection.on('blocked', () => {
75
- debug('connection blocked');
163
+ this._logger.debug('Eventbus connection blocked');
76
164
  });
77
165
 
78
166
  this.connection.on('unblocked', () => {
79
- debug('connection unblocked');
167
+ this._logger.debug('Eventbus connection unblocked');
80
168
  });
81
169
 
82
170
  return this._reSubscribe();
@@ -90,165 +178,198 @@ class Bus extends EventEmitter {
90
178
  }
91
179
 
92
180
  _reconnect() {
93
- if (this.initializing) {
94
- debug('not reconnecting because initialization is still in process');
181
+ if (this.initializing || this._terminating || this._terminated) {
182
+ this._logger.debug('Skipping eventbus reconnect because eventbus is initializing or terminating');
95
183
  return;
96
184
  }
97
185
 
98
- debug(`reconnecting in: ${this.reconnectInterval / 1000} seconds because of fatal error`);
186
+ this._logger.debug(`Eventbus reconnecting in ${this.reconnectIntervalMs / 1000} seconds`);
99
187
  this.reconnect = true;
100
188
  setTimeout(() => {
101
- debug(`reconnecting`);
189
+ this._logger.debug('Eventbus reconnecting...');
102
190
  this.init();
103
- }, this.reconnectInterval);
191
+ }, this.reconnectIntervalMs);
104
192
  }
105
193
 
106
- _reSubscribe() {
107
- return Promise.map(_.values(this.subscribers), (eventSubscribers) => {
108
- // `Promise.each` for guarantee of order for subscribers
109
- return Promise.each(eventSubscribers, ({eventName, handler, options}) => {
110
- return this.subscribe(eventName, handler, options, true);
111
- });
112
- });
194
+ async _reSubscribe() {
195
+ for (const [eventName, subscribers] of Object.entries(this.subscribers)) {
196
+ this._logger.debug(`Re-subscribing ${subscribers.length} subscribers to event "${eventName}"`);
197
+ for (const { eventName, handler, options } of subscribers) {
198
+ this.subscribe(eventName, handler, options, true);
199
+ }
200
+ this._logger.debug(`Finished re-subscribing ${subscribers.length} subscribers to event "${eventName}"`);
201
+ }
113
202
  }
114
203
 
115
- publish(eventName, msg) {
116
- return Promise.resolve()
117
- .then(() => {
118
- if (!this.publishChannel) {
119
- return this._createPublishChannel();
120
- }
121
- })
122
- .then(() => {
123
- return this.publishChannel.assertExchange(eventName, 'fanout', { durable: true });
124
- })
125
- .then(() => {
126
- return this.publishChannel.publish(eventName, 'my-routing-key', new Buffer(this._serialize(msg)));
127
- });
204
+ /**
205
+ *
206
+ * @param {string} eventName
207
+ * @param {any} msg
208
+ * @param {string} [routingKey]
209
+ * @returns {Promise<boolean>} Returns true if the message was published successfully, false otherwise.
210
+ */
211
+ async publish(eventName, msg, routingKey = 'default') {
212
+ if (!this.publishChannel) {
213
+ await this._createPublishChannel();
214
+ }
215
+ await this.publishChannel?.assertExchange(eventName, 'fanout', { durable: true });
216
+ return !!(this.publishChannel?.publish(
217
+ eventName,
218
+ routingKey,
219
+ Buffer.from(this._serialize(msg)),
220
+ ));
128
221
  }
129
222
 
223
+ /**
224
+ * @param {string} eventName
225
+ * @param {Handler} handler
226
+ * @param {object} [options]
227
+ * @param {boolean} [options.pubSub]
228
+ * @param {boolean} [options.waitForAck]
229
+ * @param {number} [options.concurrencyLimit]
230
+ * @param {number} [options.ttl]
231
+ * @param {number} [options.timeout]
232
+ * @param {boolean} [options.ackOnTimeout]
233
+ * @param {string} [options.serviceRole]
234
+ * @param {string} [options.additionalIdentifier]
235
+ * @param {boolean} [restarting]
236
+ * @returns {EventEmitter} An EventEmitter that emits 'error' event when an error occurs in the handler or during subscription process.
237
+ */
130
238
  subscribe(eventName, handler, options = {}, restarting) {
131
- if (!restarting) {
132
- this.subscribers[eventName] = this.subscribers[eventName] || [];
133
- this.subscribers[eventName].push({eventName, handler, options});
134
- }
239
+ this._logger.debug(`Subscribing to event "${eventName}", options:`, options);
135
240
  const subscriberEmitter = new EventEmitter();
136
241
  subscriberEmitter.on('error', () => {});
242
+
243
+ if (this._forbidNewConsumers || this._terminating || this._terminated) {
244
+ this._logger.warn(`Skipping new consumer subscription to "${eventName}" because eventbus is terminating or terminated or new consumers are forbidden`);
245
+ return subscriberEmitter;
246
+ }
247
+
248
+ if (!restarting) {
249
+ this.subscribers[eventName] ??= [];
250
+ this.subscribers[eventName].push({ eventName, handler, options });
251
+ }
252
+
137
253
  const pubSub = options.pubSub || false;
138
254
  const waitForAck = options.waitForAck || false;
139
- const concurrencyLimit = options.concurrencyLimit || false;
255
+ const concurrencyLimit = options.concurrencyLimit || 0; // 0 means no limit
140
256
  const messageTtl = options.ttl || false;
141
257
  const timeout = options.timeout;
142
258
  const ackOnTimeout = options.ackOnTimeout;
143
259
 
144
- const serviceRole = options.serviceRole ? `${options.serviceRole}_` : '';
145
- const additionalIdentifier = options.additionalIdentifier ? `${options.additionalIdentifier}_` : '';
146
- const pubSubIdentifier = options.pubSub ? `${uuid()}_` : '';
260
+ const serviceRole = options.serviceRole
261
+ ? `${options.serviceRole}_`
262
+ : '';
263
+ const additionalIdentifier = options.additionalIdentifier
264
+ ? `${options.additionalIdentifier}_`
265
+ : '';
266
+ const pubSubIdentifier = options.pubSub
267
+ ? `${globalThis.crypto.randomUUID()}_`
268
+ : '';
147
269
  const queueName = `${this.microServiceName}_${serviceRole}${additionalIdentifier}${pubSubIdentifier}${eventName}`;
148
270
  Promise.resolve()
149
- .then(() => {
150
- return this._getSubscribeChannel();
271
+ .then(() => this._getSubscribeChannel())
272
+ .then(async (ch) => {
273
+ if (!ch) {
274
+ throw new Error('Channel was not created. Perhaps initial connection failed or not established yet');
275
+ }
276
+ await ch.assertExchange(eventName, 'fanout', { durable: true });
277
+ this._logger.debug(`Durable exchange "${eventName}" of type "fanout" asserted`);
278
+ return ch;
279
+ })
280
+ .then(async (ch) => {
281
+ const queueOptions = {
282
+ ...(!pubSub && { durable: true }),
283
+ ...(pubSub && { exclusive: true }),
284
+ ...(messageTtl && { messageTtl }),
285
+ };
286
+ await ch.assertQueue(queueName, queueOptions);
287
+ this._logger.debug(`Queue "${queueName}" asserted`, { queueOptions });
288
+ return ch;
151
289
  })
152
- .then((ch) => {
153
- return ch.assertExchange(eventName, 'fanout', { durable: true })
154
- .then(() => {
155
- const queueOptions = {
156
- ...(!pubSub && { durable: true }),
157
- ...(pubSub && { exclusive: true })
158
- };
159
- if (messageTtl) {
160
- queueOptions.messageTtl = messageTtl;
290
+ .then(async (ch) => {
291
+ await ch.bindQueue(queueName, eventName, '');
292
+ this._logger.debug(`Queue "${queueName}" bound to exchange "${eventName}", pattern ""`);
293
+ return ch;
294
+ })
295
+ .then(async (ch) => {
296
+ this._logger.debug(`Setting concurrency limit "${concurrencyLimit}" for queue "${queueName}"`);
297
+ await ch.prefetch(concurrencyLimit);
298
+ return ch;
299
+ })
300
+ .then(async (ch) => {
301
+ const { consumerTag } = await ch.consume(queueName, async (msg) => {
302
+ let finished = false;
303
+ try {
304
+ if (waitForAck && timeout) {
305
+ setTimeout(() => {
306
+ if (finished) return;
307
+ finished = true;
308
+ subscriberEmitter.emit('error', new Error('Handler timedout'));
309
+ if (ackOnTimeout) {
310
+ ch.ack(msg);
311
+ } else {
312
+ ch.nack(msg);
313
+ }
314
+ }, timeout);
315
+ } else {
316
+ ch.ack(msg);
317
+ }
318
+ const content = this._deserialize(msg.content.toString());
319
+ this._consumersInProgressCount++;
320
+ await handler(content).finally(() => this._consumersInProgressCount--);
321
+ } catch (error) {
322
+ subscriberEmitter.emit('error', error);
323
+ if (!finished && waitForAck) {
324
+ finished = true;
325
+ ch.nack(msg);
326
+ } else {
327
+ finished = true;
161
328
  }
162
- return ch.assertQueue(queueName, queueOptions)
163
- .then(() => {
164
- debug(`queue: ${queueName} asserted`);
165
- return ch.bindQueue(queueName, eventName);
166
- })
167
- .then(() => {
168
- return ch.prefetch(concurrencyLimit);
169
- })
170
- .then(() => {
171
- debug(`queue: ${queueName} concurrencyLimit: ${concurrencyLimit} was set`);
172
- return ch.consume(queueName, (msg) => {
173
- const d = domain.create();
174
- d.on('error', (err) => {
175
- subscriberEmitter.emit('error', err);
176
- });
177
- d.run(() => {
178
- let finished = false;
179
- if (waitForAck) {
180
- if (timeout) {
181
- setTimeout(() => {
182
- if (!finished) {
183
- finished = true;
184
- subscriberEmitter.emit('error', new Error('Handler timedout'));
185
- if (ackOnTimeout) {
186
- ch.ack(msg);
187
- } else {
188
- ch.nack(msg);
189
- }
190
- }
191
- }, timeout);
192
- }
193
- } else {
194
- ch.ack(msg);
195
- }
196
- handler(this._deserialize(msg.content.toString()))
197
- .then(() => {
198
- if (!finished && waitForAck) {
199
- finished = true;
200
- ch.ack(msg);
201
- } else {
202
- finished = true;
203
- }
204
- }, (err) => {
205
- subscriberEmitter.emit('error', err);
206
- if (!finished && waitForAck) {
207
- finished = true;
208
- ch.nack(msg);
209
- } else {
210
- finished = true;
211
- }
212
- });
213
- });
214
- });
215
- });
216
- });
329
+ }
330
+ });
331
+ const consumerTags = this.consumerTagsByChannel.get(ch) ?? [];
332
+ consumerTags.push(consumerTag);
333
+ this.consumerTagsByChannel.set(ch, consumerTags);
217
334
  })
218
335
  .catch((err) => {
219
336
  subscriberEmitter.emit('error', { connection: err });
220
- })
221
- .done();
337
+ });
222
338
 
223
339
  return subscriberEmitter;
224
340
  }
225
341
 
226
- _createPublishChannel() {
227
- return this.openPromise
228
- .then((conn) => {
229
- return conn.createChannel();
230
- })
231
- .then((ch) => {
232
- this.publishChannel = ch;
233
- });
342
+ /**
343
+ * @returns {Promise<void>}
344
+ */
345
+ async _createPublishChannel() {
346
+ const model = await this.openPromise;
347
+ this.publishChannel = await model?.createChannel();
234
348
  }
235
349
 
236
- _getSubscribeChannel() {
237
- return this.openPromise
238
- .then((conn) => {
239
- return conn.createChannel();
240
- });
350
+ /**
351
+ * @returns {Promise<amqplib.Channel | undefined>}
352
+ */
353
+ async _getSubscribeChannel() {
354
+ const model = await this.openPromise;
355
+ return model?.createChannel();
241
356
  }
242
357
 
358
+ /**
359
+ * @param {unknown} body
360
+ * @returns {string}
361
+ */
243
362
  _serialize(body) {
244
363
  return JSON.stringify(body);
245
364
  }
246
365
 
366
+ /**
367
+ * @param {string} content
368
+ * @returns {unknown}
369
+ */
247
370
  _deserialize(content) {
248
371
  return JSON.parse(content);
249
372
  }
250
-
251
-
252
373
  }
253
374
 
254
375