@codefresh-io/eventbus 2.4.0 → 3.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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, { queueName: string, consumerTag: 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((tagInfo) => ch.cancel(tagInfo.consumerTag))
75
+ );
76
+ for (let i = 0; i < results.length; i++) {
77
+ const result = results[i];
78
+ const tagInfo = consumerTags[i];
79
+ if (result.status === 'rejected') {
80
+ this._logger.error(`Failed to cancel consumer with tag "${tagInfo.consumerTag}" on queue "${tagInfo.queueName}". Ignoring error`, { error: result.reason });
81
+ continue;
82
+ }
83
+ this._logger.debug(`Consumer with tag "${tagInfo.consumerTag}" on queue "${tagInfo.queueName}" 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 bus 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 bus 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 bus connection');
125
+ await this.connection?.close();
42
126
  this.subscribers = {};
43
- debug('bus closed');
127
+ this._logger.info('Bus connection is 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('Bus 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('Bus connected');
54
142
  if (this.reconnect && this.connection) {
55
143
  this.publishChannel = undefined;
56
- debug('reconnected');
144
+ this._logger.debug('Bus reconnected');
57
145
  }
58
146
  this.reconnect = false;
59
147
  this.connection = newConnection;
60
148
 
61
- debug(`ready`);
149
+ this._logger.debug('Bus is 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('Bus 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('Bus connection blocked');
76
164
  });
77
165
 
78
166
  this.connection.on('unblocked', () => {
79
- debug('connection unblocked');
167
+ this._logger.debug('Bus connection unblocked');
80
168
  });
81
169
 
82
170
  return this._reSubscribe();
@@ -90,165 +178,197 @@ 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 bus reconnect because it 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(`Bus reconnecting in ${this.reconnectIntervalMs / 1000} seconds...`);
99
187
  this.reconnect = true;
100
188
  setTimeout(() => {
101
- debug(`reconnecting`);
102
189
  this.init();
103
- }, this.reconnectInterval);
190
+ }, this.reconnectIntervalMs);
104
191
  }
105
192
 
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
- });
193
+ async _reSubscribe() {
194
+ for (const [eventName, subscribers] of Object.entries(this.subscribers)) {
195
+ this._logger.debug(`Re-subscribing ${subscribers.length} subscribers to event "${eventName}"`);
196
+ for (const { eventName, handler, options } of subscribers) {
197
+ this.subscribe(eventName, handler, options, true);
198
+ }
199
+ this._logger.debug(`Finished re-subscribing ${subscribers.length} subscribers to event "${eventName}"`);
200
+ }
113
201
  }
114
202
 
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
- });
203
+ /**
204
+ *
205
+ * @param {string} eventName
206
+ * @param {any} msg
207
+ * @param {string} [routingKey]
208
+ * @returns {Promise<boolean>} Returns true if the message was published successfully, false otherwise.
209
+ */
210
+ async publish(eventName, msg, routingKey = 'default') {
211
+ if (!this.publishChannel) {
212
+ await this._createPublishChannel();
213
+ }
214
+ await this.publishChannel?.assertExchange(eventName, 'fanout', { durable: true });
215
+ return !!(this.publishChannel?.publish(
216
+ eventName,
217
+ routingKey,
218
+ Buffer.from(this._serialize(msg)),
219
+ ));
128
220
  }
129
221
 
222
+ /**
223
+ * @param {string} eventName
224
+ * @param {Handler} handler
225
+ * @param {object} [options]
226
+ * @param {boolean} [options.pubSub]
227
+ * @param {boolean} [options.waitForAck]
228
+ * @param {number} [options.concurrencyLimit]
229
+ * @param {number} [options.ttl]
230
+ * @param {number} [options.timeout]
231
+ * @param {boolean} [options.ackOnTimeout]
232
+ * @param {string} [options.serviceRole]
233
+ * @param {string} [options.additionalIdentifier]
234
+ * @param {boolean} [restarting]
235
+ * @returns {EventEmitter} An EventEmitter that emits 'error' event when an error occurs in the handler or during subscription process.
236
+ */
130
237
  subscribe(eventName, handler, options = {}, restarting) {
131
- if (!restarting) {
132
- this.subscribers[eventName] = this.subscribers[eventName] || [];
133
- this.subscribers[eventName].push({eventName, handler, options});
134
- }
238
+ this._logger.debug(`Subscribing to event "${eventName}", options:`, { options });
135
239
  const subscriberEmitter = new EventEmitter();
136
240
  subscriberEmitter.on('error', () => {});
241
+
242
+ if (this._forbidNewConsumers || this._terminating || this._terminated) {
243
+ this._logger.warn(`Skipping new consumer subscription to "${eventName}" because bus is terminating or terminated or new consumers are forbidden`);
244
+ return subscriberEmitter;
245
+ }
246
+
247
+ if (!restarting) {
248
+ this.subscribers[eventName] ??= [];
249
+ this.subscribers[eventName].push({ eventName, handler, options });
250
+ }
251
+
137
252
  const pubSub = options.pubSub || false;
138
253
  const waitForAck = options.waitForAck || false;
139
- const concurrencyLimit = options.concurrencyLimit || false;
254
+ const concurrencyLimit = options.concurrencyLimit || 0; // 0 means no limit
140
255
  const messageTtl = options.ttl || false;
141
256
  const timeout = options.timeout;
142
257
  const ackOnTimeout = options.ackOnTimeout;
143
258
 
144
- const serviceRole = options.serviceRole ? `${options.serviceRole}_` : '';
145
- const additionalIdentifier = options.additionalIdentifier ? `${options.additionalIdentifier}_` : '';
146
- const pubSubIdentifier = options.pubSub ? `${uuid()}_` : '';
259
+ const serviceRole = options.serviceRole
260
+ ? `${options.serviceRole}_`
261
+ : '';
262
+ const additionalIdentifier = options.additionalIdentifier
263
+ ? `${options.additionalIdentifier}_`
264
+ : '';
265
+ const pubSubIdentifier = options.pubSub
266
+ ? `${globalThis.crypto.randomUUID()}_`
267
+ : '';
147
268
  const queueName = `${this.microServiceName}_${serviceRole}${additionalIdentifier}${pubSubIdentifier}${eventName}`;
148
269
  Promise.resolve()
149
- .then(() => {
150
- return this._getSubscribeChannel();
270
+ .then(() => this._getSubscribeChannel())
271
+ .then(async (ch) => {
272
+ if (!ch) {
273
+ throw new Error('Channel was not created. Perhaps initial connection failed or not established yet');
274
+ }
275
+ await ch.assertExchange(eventName, 'fanout', { durable: true });
276
+ this._logger.debug(`Durable exchange "${eventName}" of type "fanout" asserted`);
277
+ return ch;
278
+ })
279
+ .then(async (ch) => {
280
+ const queueOptions = {
281
+ ...(!pubSub && { durable: true }),
282
+ ...(pubSub && { exclusive: true }),
283
+ ...(messageTtl && { messageTtl }),
284
+ };
285
+ await ch.assertQueue(queueName, queueOptions);
286
+ this._logger.debug(`Queue "${queueName}" asserted`, { queueOptions });
287
+ return ch;
151
288
  })
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;
289
+ .then(async (ch) => {
290
+ await ch.bindQueue(queueName, eventName, '');
291
+ this._logger.debug(`Queue "${queueName}" bound to exchange "${eventName}", pattern ""`);
292
+ return ch;
293
+ })
294
+ .then(async (ch) => {
295
+ this._logger.debug(`Setting concurrency limit "${concurrencyLimit}" for queue "${queueName}"`);
296
+ await ch.prefetch(concurrencyLimit);
297
+ return ch;
298
+ })
299
+ .then(async (ch) => {
300
+ const { consumerTag } = await ch.consume(queueName, async (msg) => {
301
+ let finished = false;
302
+ try {
303
+ if (waitForAck && timeout) {
304
+ setTimeout(() => {
305
+ if (finished) return;
306
+ finished = true;
307
+ subscriberEmitter.emit('error', new Error('Handler timedout'));
308
+ if (ackOnTimeout) {
309
+ ch.ack(msg);
310
+ } else {
311
+ ch.nack(msg);
312
+ }
313
+ }, timeout);
314
+ } else {
315
+ ch.ack(msg);
316
+ }
317
+ const content = this._deserialize(msg.content.toString());
318
+ this._consumersInProgressCount++;
319
+ await handler(content).finally(() => this._consumersInProgressCount--);
320
+ } catch (error) {
321
+ subscriberEmitter.emit('error', error);
322
+ if (!finished && waitForAck) {
323
+ finished = true;
324
+ ch.nack(msg);
325
+ } else {
326
+ finished = true;
161
327
  }
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
- });
328
+ }
329
+ });
330
+ const consumerTags = this.consumerTagsByChannel.get(ch) ?? [];
331
+ consumerTags.push({ queueName, consumerTag });
332
+ this.consumerTagsByChannel.set(ch, consumerTags);
217
333
  })
218
334
  .catch((err) => {
219
335
  subscriberEmitter.emit('error', { connection: err });
220
- })
221
- .done();
336
+ });
222
337
 
223
338
  return subscriberEmitter;
224
339
  }
225
340
 
226
- _createPublishChannel() {
227
- return this.openPromise
228
- .then((conn) => {
229
- return conn.createChannel();
230
- })
231
- .then((ch) => {
232
- this.publishChannel = ch;
233
- });
341
+ /**
342
+ * @returns {Promise<void>}
343
+ */
344
+ async _createPublishChannel() {
345
+ const model = await this.openPromise;
346
+ this.publishChannel = await model?.createChannel();
234
347
  }
235
348
 
236
- _getSubscribeChannel() {
237
- return this.openPromise
238
- .then((conn) => {
239
- return conn.createChannel();
240
- });
349
+ /**
350
+ * @returns {Promise<amqplib.Channel | undefined>}
351
+ */
352
+ async _getSubscribeChannel() {
353
+ const model = await this.openPromise;
354
+ return await model?.createChannel();
241
355
  }
242
356
 
357
+ /**
358
+ * @param {unknown} body
359
+ * @returns {string}
360
+ */
243
361
  _serialize(body) {
244
362
  return JSON.stringify(body);
245
363
  }
246
364
 
365
+ /**
366
+ * @param {string} content
367
+ * @returns {unknown}
368
+ */
247
369
  _deserialize(content) {
248
370
  return JSON.parse(content);
249
371
  }
250
-
251
-
252
372
  }
253
373
 
254
374