comq 0.15.4 → 0.15.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.15.5](https://github.com/toa-io/comq/compare/v0.15.4...v0.15.5) (2026-08-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * recover from a lost connection reliably ([59b1fab](https://github.com/toa-io/comq/commit/59b1fab61d4f27ab6eb608693da6493b5b1f4ae5))
7
+ * recover from a lost connection reliably ([#247](https://github.com/toa-io/comq/issues/247)) ([e4701cf](https://github.com/toa-io/comq/commit/e4701cfa384d5451c15d0a6bf53c82ce1da91ac5))
8
+
1
9
  ## [0.15.4](https://github.com/toa-io/comq/compare/v0.15.3...v0.15.4) (2026-08-16)
2
10
 
3
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -490,10 +490,14 @@ Subscribe to one of the diagnostic events:
490
490
  - `close`: connection is closed.
491
491
  Optional [`error`](https://amqp-node.github.io/amqplib/channel_api.html#model_events) is passed
492
492
  as an argument.
493
+ - `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
494
+ Attempts continue, so this event is the only way to observe an ongoing outage.
493
495
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
494
496
  an argument.
495
497
  - `drain`: back pressure is removed from a channel. Channel type is passed.
496
498
  - `remove`: channel is removed from the [pool](#sharded-connection).
499
+ - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
500
+ re-sent. Channel type is passed.
497
501
  - `recover`: channel's topology is recovered. Channel type is passed.
498
502
  - `discard`: message is [discarded](#messages) as it repeatedly caused
499
503
  exceptions. Channel type,
@@ -515,6 +519,10 @@ established, there is no way to capture the initial `open` event.
515
519
 
516
520
  ```javascript
517
521
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
522
+
523
+ io.diagnose('open', () => console.log('AMQP connection established'))
524
+ io.diagnose('close', (error) => console.log('AMQP connection closed', error?.message))
525
+ io.diagnose('error', (error) => console.log('AMQP connection failed', error.message))
518
526
  ```
519
527
 
520
528
  # Gratitude
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { setTimeout: delay } = require('node:timers/promises')
3
4
  const amqp = require('amqplib')
4
5
  const { Promex } = require('promex')
5
6
  const { retry } = require('reretry')
@@ -31,9 +32,18 @@ class Connection {
31
32
  /** @type {boolean} */
32
33
  #running = false
33
34
 
35
+ /** @type {boolean} */
36
+ #closed = false
37
+
34
38
  /** @type {NodeJS.Timeout | null} */
35
39
  #heartbeatTimer = null
36
40
 
41
+ /** @type {import('node:net').Socket | null} */
42
+ #heartbeatSocket = null
43
+
44
+ /** @type {(() => void) | null} */
45
+ #heartbeatReset = null
46
+
37
47
  #diagnostics = emitter.create()
38
48
 
39
49
  /**
@@ -51,6 +61,8 @@ class Connection {
51
61
  }
52
62
 
53
63
  async open () {
64
+ this.#closed = false
65
+
54
66
  if (this.#opening !== null) return this.#opening
55
67
 
56
68
  this.#opening = retry(this.#open).finally(() => { this.#opening = null })
@@ -61,9 +73,12 @@ class Connection {
61
73
  }
62
74
 
63
75
  async close () {
64
- if (this.#connection === undefined) await this.#recovery
76
+ this.#closed = true
65
77
 
66
- await this.#connection.close()
78
+ // a connection that is about to be established must not be left open, yet an
79
+ // attempt that is being retried must not hold up the shutdown
80
+ if (this.#opening !== null) await Promise.race([this.#opening.catch(noop), expiration()])
81
+ if (this.#connection !== undefined) await this.#shutdown(this.#connection)
67
82
  }
68
83
 
69
84
  createChannel = failsafe(this, this.#recover,
@@ -88,16 +103,25 @@ class Connection {
88
103
  }
89
104
 
90
105
  #open = async (retry) => {
106
+ if (this.#closed) return
107
+
91
108
  /** @type {comq.amqp.Connection} */
92
109
  let connection
93
110
 
94
111
  try {
95
- connection = await amqp.connect(this.#url)
112
+ connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
96
113
  } catch (exception) {
97
- if (this.#transient(exception)) return retry
98
- else throw exception
114
+ if (this.#closed) return
115
+ if (!this.#transient(exception)) throw exception
116
+
117
+ // attempts are made until one succeeds, so an outage is observable only here
118
+ this.#diagnostics.emit('error', exception)
119
+
120
+ return retry
99
121
  }
100
122
 
123
+ if (this.#closed) return await this.#shutdown(connection)
124
+
101
125
  // This prevents the process from crashing; 'close' will be emitted next.
102
126
  // https://amqp-node.github.io/amqplib/channel_api.html#model_events
103
127
  connection.on('error', noop)
@@ -110,17 +134,16 @@ class Connection {
110
134
  try {
111
135
  for (const channel of this.#channels) await channel.recover(connection)
112
136
  } catch (exception) {
113
- this.#disarmWatchdog()
114
137
  this.#diagnostics.emit('error', exception)
115
- connection.removeAllListeners()
116
-
117
- await connection.close().catch(noop)
118
-
119
- if (this.#connection === connection) this.#connection = undefined
138
+ this.#drop(connection)
120
139
 
121
140
  return retry
122
141
  }
123
142
 
143
+ // the connection may have been lost while the topology was being recovered,
144
+ // in which case 'close' has left the reconnection to this very attempt
145
+ if (this.#connection !== connection) return retry
146
+
124
147
  this.#recovery.resolve()
125
148
  this.#recovery = new Promex()
126
149
  }
@@ -130,19 +153,45 @@ class Connection {
130
153
  * @param {Error} [error]
131
154
  */
132
155
  #close = (connection, error) => {
133
- this.#disarmWatchdog()
134
-
135
156
  if (this.#connection !== connection) return
136
157
 
158
+ this.#disarmWatchdog()
137
159
  this.#diagnostics.emit('close', error)
138
160
  connection.removeAllListeners()
139
161
  this.#connection = undefined
140
162
 
141
- if (error !== undefined) {
163
+ if (error !== undefined && !this.#closed) {
142
164
  this.open().catch((exception) => this.#diagnostics.emit('error', exception))
143
165
  }
144
166
  }
145
167
 
168
+ /**
169
+ * An AMQP connection is only closed once the broker has replied with Close-Ok,
170
+ * which never happens on a connection that has already been lost.
171
+ *
172
+ * @param {comq.amqp.Connection} connection
173
+ */
174
+ async #shutdown (connection) {
175
+ const closing = connection.close().catch(noop)
176
+
177
+ await Promise.race([closing, expiration()])
178
+
179
+ this.#drop(connection)
180
+ }
181
+
182
+ /**
183
+ * @param {comq.amqp.Connection} connection
184
+ */
185
+ #drop (connection) {
186
+ if (this.#connection === connection) {
187
+ this.#disarmWatchdog()
188
+ this.#connection = undefined
189
+ }
190
+
191
+ connection.removeAllListeners()
192
+ connection.connection?.stream?.destroy()
193
+ }
194
+
146
195
  #recover () {
147
196
  return this.#recovery
148
197
  }
@@ -162,6 +211,11 @@ class Connection {
162
211
  this.#heartbeatTimer.unref()
163
212
  }
164
213
 
214
+ this.#disarmWatchdog()
215
+
216
+ this.#heartbeatSocket = socket
217
+ this.#heartbeatReset = reset
218
+
165
219
  reset()
166
220
  socket.on('data', reset)
167
221
  }
@@ -169,6 +223,13 @@ class Connection {
169
223
  #disarmWatchdog () {
170
224
  clearTimeout(this.#heartbeatTimer)
171
225
  this.#heartbeatTimer = null
226
+
227
+ // otherwise a byte arriving on a replaced socket rearms the watchdog of a
228
+ // connection that is already gone, leaving the current one unguarded
229
+ this.#heartbeatSocket?.off('data', this.#heartbeatReset)
230
+
231
+ this.#heartbeatSocket = null
232
+ this.#heartbeatReset = null
172
233
  }
173
234
 
174
235
  #transient (exception) {
@@ -183,6 +244,12 @@ class Connection {
183
244
  /** @type {number} */
184
245
  const WATCHDOG_MS = 60_000
185
246
 
247
+ /** @type {number} */
248
+ const CONNECT_MS = 30_000
249
+
250
+ /** @type {number} */
251
+ const SHUTDOWN_MS = 5_000
252
+
186
253
  const TRANSIENT_CODES = new Set([
187
254
  'ECONNREFUSED',
188
255
  'EAI_AGAIN',
@@ -195,9 +262,19 @@ const TRANSIENT_CODES = new Set([
195
262
 
196
263
  const TRANSIENT_MESSAGES = new Set([
197
264
  'Socket closed abruptly during opening handshake',
198
- 'Client network socket disconnected before secure TLS connection was established'
265
+ 'Client network socket disconnected before secure TLS connection was established',
266
+ 'connect ETIMEDOUT' // amqplib reports the `timeout` option without a code
199
267
  ])
200
268
 
269
+ /**
270
+ * @return {Promise<void>}
271
+ */
272
+ function expiration () {
273
+ const timeoutMs = global.COMQ_TESTING_SHUTDOWN_TIMEOUT ?? SHUTDOWN_MS
274
+
275
+ return delay(timeoutMs, undefined, { ref: false })
276
+ }
277
+
201
278
  function noop () {}
202
279
 
203
280
  exports.Connection = Connection
package/source/io.js CHANGED
@@ -52,6 +52,9 @@ class IO {
52
52
  constructor (connection) {
53
53
  this.#connection = connection
54
54
 
55
+ // EventEmitter throws on 'error' with no listeners
56
+ this.#diagnostics.on('error', noop)
57
+
55
58
  for (const event of events.connection) {
56
59
  this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
57
60
  }
@@ -19,6 +19,9 @@ class Connection {
19
19
  constructor (connections) {
20
20
  this.#connections = connections
21
21
 
22
+ // EventEmitter throws on 'error' with no listeners
23
+ this.#diagnostics.on('error', noop)
24
+
22
25
  connections.map(this.#pipe)
23
26
  }
24
27
 
@@ -72,4 +75,6 @@ class Connection {
72
75
  }
73
76
  }
74
77
 
78
+ function noop () {}
79
+
75
80
  exports.Connection = Connection
@@ -39,9 +39,12 @@ class Connection extends EventEmitter {
39
39
  close = jest.fn(async () => undefined)
40
40
  }
41
41
 
42
+ const connect = async () => new Connection()
43
+
42
44
  /** @type {jest.MockedObject<import('amqplib')>} */
43
45
  const amqplib = {
44
- connect: jest.fn(async () => new Connection())
46
+ connect: jest.fn(connect)
45
47
  }
46
48
 
47
49
  exports.amqplib = amqplib
50
+ exports.connect = connect
@@ -5,7 +5,7 @@
5
5
  const { generate } = require('randomstring')
6
6
 
7
7
  const { timeout, promex, random } = require('@toa.io/generic')
8
- const { amqplib } = require('./amqplib.mock')
8
+ const { amqplib, connect } = require('./amqplib.mock')
9
9
  const { channel: create } = require('./connection.mock')
10
10
  const mock = { amqplib, channel: { create } }
11
11
 
@@ -26,6 +26,7 @@ const url = generate()
26
26
 
27
27
  beforeEach(() => {
28
28
  jest.clearAllMocks()
29
+ amqplib.connect.mockImplementation(connect)
29
30
 
30
31
  connection = new Connection(url)
31
32
  })
@@ -36,12 +37,13 @@ describe('initial connection', () => {
36
37
  it('should connect', async () => {
37
38
  await connection.open()
38
39
 
39
- expect(amqplib.connect).toHaveBeenCalledWith(url)
40
+ expect(amqplib.connect).toHaveBeenCalledWith(url, { timeout: expect.any(Number) })
40
41
  })
41
42
 
42
43
  it.each(/** @type {[string, Partial<Error>][]} */[
43
44
  ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }],
44
45
  ['TLS disconnect', { message: 'Client network socket disconnected before secure TLS connection was established' }],
46
+ ['connect timeout', { message: 'connect ETIMEDOUT' }],
45
47
  ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
46
48
  ['EAI_AGAIN', { code: 'EAI_AGAIN' }],
47
49
  ['ENOTFOUND', { code: 'ENOTFOUND' }],
@@ -157,6 +159,58 @@ describe('reconnection', () => {
157
159
  expect(unhandled).not.toHaveBeenCalled()
158
160
  })
159
161
 
162
+ it('should reconnect when connection is lost while channels recover', async () => {
163
+ const channel = await connection.createChannel('request')
164
+
165
+ // the connection is lost right after the topology has been recovered on it
166
+ channel.recover.mockImplementationOnce(async (connection) => {
167
+ connection.emit('close', new Error('lost again'))
168
+ })
169
+
170
+ conn.emit('close', new Error('lost'))
171
+
172
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
173
+
174
+ expect(amqplib.connect).toHaveBeenCalledTimes(3)
175
+ }, 10000)
176
+
177
+ it('should not wait for a connection that failed to recover to close', async () => {
178
+ const channel = await connection.createChannel('request')
179
+
180
+ channel.recover
181
+ .mockRejectedValueOnce(new Error('Channel closed'))
182
+ .mockResolvedValue(undefined)
183
+
184
+ // Close-Ok never arrives on a connection that has already been lost
185
+ conn.close.mockImplementation(() => new Promise(() => undefined))
186
+ conn.emit('close', new Error('lost'))
187
+
188
+ await timeout(0)
189
+
190
+ const failed = await amqplib.connect.mock.results[1].value
191
+
192
+ failed.close.mockImplementation(() => new Promise(() => undefined))
193
+
194
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
195
+
196
+ expect(failed.connection.stream.destroy).toHaveBeenCalled()
197
+ }, 10000)
198
+
199
+ it('should emit error on a failed connection attempt', async () => {
200
+ const errors = []
201
+ const exception = { code: 'ECONNREFUSED' }
202
+
203
+ connection.diagnose('error', (error) => errors.push(error))
204
+ amqplib.connect.mockImplementationOnce(async () => { throw exception })
205
+
206
+ conn.emit('close', new Error('lost'))
207
+
208
+ // the attempt is retried, hence the channel is created on the next connection
209
+ await connection.createChannel('event')
210
+
211
+ expect(errors).toContain(exception)
212
+ }, 10000)
213
+
160
214
  it('should ignore close from a stale connection', async () => {
161
215
  const stale = conn
162
216
  const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
@@ -289,6 +343,28 @@ describe('watchdog', () => {
289
343
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
290
344
  })
291
345
 
346
+ it('should not let a replaced socket disarm the watchdog', async () => {
347
+ jest.useFakeTimers()
348
+
349
+ await connection.open()
350
+
351
+ const stale = await amqplib.connect.mock.results[0].value
352
+
353
+ stale.emit('close', new Error('lost'))
354
+
355
+ await jest.advanceTimersByTimeAsync(1)
356
+
357
+ const live = await amqplib.connect.mock.results[1].value
358
+
359
+ await jest.advanceTimersByTimeAsync(30_000)
360
+
361
+ stale.connection.stream.emit('data', Buffer.alloc(0))
362
+
363
+ await jest.advanceTimersByTimeAsync(30_000)
364
+
365
+ expect(live.connection.stream.destroy).toHaveBeenCalled()
366
+ })
367
+
292
368
  it('should reset watchdog on socket data', async () => {
293
369
  jest.useFakeTimers()
294
370
 
@@ -311,6 +387,14 @@ describe('watchdog', () => {
311
387
  })
312
388
 
313
389
  describe('close', () => {
390
+ beforeEach(() => {
391
+ global.COMQ_TESTING_SHUTDOWN_TIMEOUT = 10
392
+ })
393
+
394
+ afterEach(() => {
395
+ delete global.COMQ_TESTING_SHUTDOWN_TIMEOUT
396
+ })
397
+
314
398
  it('should close connection', async () => {
315
399
  await connection.open()
316
400
  await connection.close()
@@ -331,6 +415,49 @@ describe('close', () => {
331
415
 
332
416
  expect(conn.close).toHaveBeenCalled()
333
417
  })
418
+
419
+ it('should not wait for Close-Ok that never arrives', async () => {
420
+ await connection.open()
421
+
422
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
423
+ const conn = await amqplib.connect.mock.results[0].value
424
+
425
+ conn.close.mockImplementation(() => new Promise(() => undefined))
426
+
427
+ await connection.close()
428
+
429
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
430
+ })
431
+
432
+ it('should open again after close', async () => {
433
+ await connection.open()
434
+ await connection.close()
435
+ await connection.open()
436
+
437
+ await expect(connection.createChannel('request')).resolves.toBeDefined()
438
+
439
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
440
+ })
441
+
442
+ it('should stop reconnecting', async () => {
443
+ await connection.open()
444
+
445
+ const conn = await amqplib.connect.mock.results[0].value
446
+ const attempts = []
447
+
448
+ connection.diagnose('error', (exception) => attempts.push(exception))
449
+ amqplib.connect.mockImplementation(async () => { throw { code: 'ECONNREFUSED' } }) // eslint-disable-line
450
+
451
+ conn.emit('close', new Error('lost'))
452
+
453
+ await connection.close()
454
+
455
+ const failed = attempts.length
456
+
457
+ await timeout(1500)
458
+
459
+ expect(attempts.length).toStrictEqual(failed)
460
+ }, 10000)
334
461
  })
335
462
 
336
463
  describe('diagnostics', () => {
@@ -54,6 +54,13 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
54
54
  })
55
55
  })
56
56
 
57
+ it('should not throw on error without listeners', async () => {
58
+ const call = connection.diagnose.mock.calls.find(([event]) => event === 'error')
59
+ const emit = call[1]
60
+
61
+ expect(() => emit(new Error(generate()))).not.toThrow()
62
+ })
63
+
57
64
  it.each(['open', 'close'])('should re-emit %s from connection',
58
65
  /**
59
66
  * @param {comq.diagnostics.Event} event
package/types/io.d.ts CHANGED
@@ -59,7 +59,7 @@ declare namespace comq {
59
59
 
60
60
  diagnose(event: 'open', listener: (index?: number) => void): void
61
61
 
62
- diagnose(event: 'close', listener: (index?: number) => void): void
62
+ diagnose(event: 'close', listener: (error?: Error, index?: number) => void): void
63
63
 
64
64
  diagnose(event: 'error', listener: (error: Error, index?: number) => void): void
65
65
 
@@ -69,6 +69,8 @@ declare namespace comq {
69
69
 
70
70
  diagnose(event: 'remove', listener: (index?: number) => void): void
71
71
 
72
+ diagnose(event: 'lost', listener: (channel: _topology.type, index?: number) => void): void
73
+
72
74
  diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
73
75
 
74
76
  diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void