comq 0.15.5 → 0.15.7

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,20 @@
1
+ ## [0.15.7](https://github.com/toa-io/comq/compare/v0.15.6...v0.15.7) (2026-08-26)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * wait instead of publishing when every shard is down ([41cd715](https://github.com/toa-io/comq/commit/41cd715ad07c1b141f0eabd23b2d444c60855b65))
7
+ * wait instead of publishing when every shard is down ([#250](https://github.com/toa-io/comq/issues/250)) ([968882c](https://github.com/toa-io/comq/commit/968882c342c51b129133ad27d761cfcbfb622fce))
8
+ * wait instead of publishing when every shard is down ([#251](https://github.com/toa-io/comq/issues/251)) ([a6cfe8a](https://github.com/toa-io/comq/commit/a6cfe8a75128cb63ad1bbbbf0e1c57cb2fbfdc37)), closes [#250](https://github.com/toa-io/comq/issues/250) [#250](https://github.com/toa-io/comq/issues/250)
9
+
10
+ ## [0.15.6](https://github.com/toa-io/comq/compare/v0.15.5...v0.15.6) (2026-08-26)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * do not report shards as lost on shutdown ([abbe0e8](https://github.com/toa-io/comq/commit/abbe0e8e1048509ff2c981b7aa33659c0829e821))
16
+ * do not report shards as lost on shutdown ([#248](https://github.com/toa-io/comq/issues/248)) ([b269a5c](https://github.com/toa-io/comq/commit/b269a5cc78d8ca74036290abec234a1e87056e9c))
17
+
1
18
  ## [0.15.5](https://github.com/toa-io/comq/compare/v0.15.4...v0.15.5) (2026-08-25)
2
19
 
3
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -491,7 +491,8 @@ Subscribe to one of the diagnostic events:
491
491
  Optional [`error`](https://amqp-node.github.io/amqplib/channel_api.html#model_events) is passed
492
492
  as an argument.
493
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.
494
+ Attempts continue until one succeeds.
495
+ - `reconnect`: an attempt to restore the connection has started. Followed by `open` or `error`.
495
496
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
496
497
  an argument.
497
498
  - `drain`: back pressure is removed from a channel. Channel type is passed.
@@ -520,9 +521,12 @@ established, there is no way to capture the initial `open` event.
520
521
  ```javascript
521
522
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
522
523
 
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))
524
+ io.diagnose('reconnect', (shard) => console.log('AMQP reconnecting', { shard }))
525
+ io.diagnose('open', (shard) => console.log('AMQP connection established', { shard }))
526
+ io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { message: error?.message, shard }))
527
+ io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
528
+ io.diagnose('lost', (type, shard) => console.log('AMQP shard lost', { type, shard }))
529
+ io.diagnose('recover', (type, shard) => console.log('AMQP channel recovered', { type, shard }))
526
530
  ```
527
531
 
528
532
  # Gratitude
@@ -60,6 +60,10 @@ class Connection {
60
60
  return this.#connection !== undefined
61
61
  }
62
62
 
63
+ get closed () {
64
+ return this.#closed
65
+ }
66
+
63
67
  async open () {
64
68
  this.#closed = false
65
69
 
@@ -69,6 +73,11 @@ class Connection {
69
73
 
70
74
  await this.#opening
71
75
 
76
+ if (this.#closed) return
77
+
78
+ // close may have landed after this attempt succeeded but before `#opening` was cleared
79
+ if (this.#connection === undefined) return this.open()
80
+
72
81
  this.#running = true
73
82
  }
74
83
 
@@ -105,16 +114,18 @@ class Connection {
105
114
  #open = async (retry) => {
106
115
  if (this.#closed) return
107
116
 
117
+ // the initial connect finishes before the caller can subscribe
118
+ if (this.#running) this.#diagnostics.emit('reconnect')
119
+
108
120
  /** @type {comq.amqp.Connection} */
109
121
  let connection
110
122
 
111
123
  try {
112
- connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
124
+ connection = await this.#connect()
113
125
  } catch (exception) {
114
126
  if (this.#closed) return
115
127
  if (!this.#transient(exception)) throw exception
116
128
 
117
- // attempts are made until one succeeds, so an outage is observable only here
118
129
  this.#diagnostics.emit('error', exception)
119
130
 
120
131
  return retry
@@ -196,6 +207,43 @@ class Connection {
196
207
  return this.#recovery
197
208
  }
198
209
 
210
+ /**
211
+ * amqplib's `timeout` is a socket idle timer and starts only after DNS.
212
+ * After a machine wakes, `getaddrinfo` itself can hang, so the attempt is
213
+ * also bounded here.
214
+ *
215
+ * @return {Promise<comq.amqp.Connection>}
216
+ */
217
+ async #connect () {
218
+ const connecting = amqp.connect(this.#url, { timeout: CONNECT_MS })
219
+
220
+ let timer
221
+
222
+ const expired = new Promise((_resolve, reject) => {
223
+ timer = setTimeout(() => {
224
+ const exception = new Error('connect ETIMEDOUT')
225
+
226
+ exception.code = 'ETIMEDOUT'
227
+
228
+ reject(exception)
229
+ }, CONNECT_MS)
230
+
231
+ timer.unref()
232
+ })
233
+
234
+ expired.catch(noop)
235
+
236
+ try {
237
+ return await Promise.race([connecting, expired])
238
+ } catch (exception) {
239
+ connecting.then((connection) => this.#shutdown(connection), noop)
240
+
241
+ throw exception
242
+ } finally {
243
+ clearTimeout(timer)
244
+ }
245
+ }
246
+
199
247
  /**
200
248
  * @param {comq.amqp.Connection} connection
201
249
  * @param {number} [timeoutMs]
package/source/events.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  /** @type {comq.diagnostics.Event[]} */
4
- exports.connection = ['open', 'close', 'error']
4
+ exports.connection = ['open', 'close', 'error', 'reconnect']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
7
  exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost']
@@ -111,6 +111,7 @@ class Channel {
111
111
  * when it actually fails to publish, otherwise a lost connection would
112
112
  * interrupt the streams it is carrying. Losing a shard is reported instead,
113
113
  * since a request awaiting its reply on it will never be answered.
114
+ * A connection closed on purpose is not a loss.
114
115
  *
115
116
  * @param {comq.Connection} connection
116
117
  * @param {number} index
@@ -121,10 +122,13 @@ class Channel {
121
122
 
122
123
  if (connection.connected === false) this.#down[index].resolve()
123
124
 
124
- connection.diagnose('close', () => {
125
+ connection.diagnose('close', (error) => {
125
126
  this.#alive[index] = false
126
127
  this.#down[index].resolve()
127
- this.#diagnostics.emit(LOST, index)
128
+
129
+ if (error !== undefined && connection.closed !== true) {
130
+ this.#diagnostics.emit(LOST, index)
131
+ }
128
132
  })
129
133
 
130
134
  connection.diagnose('open', () => {
@@ -314,26 +318,30 @@ class Channel {
314
318
  }
315
319
 
316
320
  /**
317
- * The channels of the shards that are known to be connected, falling back to the
318
- * whole pool while none is, so that a publish is attempted rather than dropped.
321
+ * The channels of the shards that are known to be connected.
322
+ * An empty result means every shard is down or benched — wait rather than
323
+ * publish: a destroyed socket accepts a write without complaining.
319
324
  *
320
325
  * @return {comq.Channel[]}
321
326
  */
322
327
  #reachable () {
323
- const reachable = this.#pool.filter((channel) => this.#alive[channel.index] !== false)
324
-
325
- return reachable.length === 0 ? this.#pool : reachable
328
+ return this.#pool.filter((channel) => this.#alive[channel.index] !== false)
326
329
  }
327
330
 
328
331
  /**
329
332
  * @param {(channel: comq.Channel) => void} fn
330
333
  */
331
334
  async #one (fn) {
332
- if (this.#pool.length === 0) await this.#recovery
333
-
334
- // publishing to a lost shard is silently accepted by the destroyed socket, so
335
- // it must be avoided rather than retried: nothing would report the loss twice
335
+ // capture before the check: recover may replace `#recovery` in between
336
+ const waiting = this.#recovery
336
337
  const pool = this.#reachable()
338
+
339
+ if (pool.length === 0) {
340
+ await waiting
341
+
342
+ return this.#one(fn)
343
+ }
344
+
337
345
  const channel = pool[Math.floor(Math.random() * pool.length)]
338
346
 
339
347
  try {
@@ -341,7 +349,7 @@ class Channel {
341
349
  } catch {
342
350
  this.#remove(channel)
343
351
 
344
- return await this.#one(fn)
352
+ return this.#one(fn)
345
353
  }
346
354
  }
347
355
  }
@@ -20,6 +20,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
20
20
  */
21
21
  const connection = (sharded = false) => (/** @type {jest.MockedObject<comq.Connection>} */ {
22
22
  connected: true,
23
+ closed: false,
23
24
  createChannel: jest.fn(async (type, index) => channel(sharded, index)),
24
25
  open: jest.fn(async () => undefined),
25
26
  close: jest.fn(async () => undefined),
@@ -211,6 +211,41 @@ describe('reconnection', () => {
211
211
  expect(errors).toContain(exception)
212
212
  }, 10000)
213
213
 
214
+ it('should emit reconnect when restoring a lost connection', async () => {
215
+ const reconnect = jest.fn()
216
+
217
+ connection.diagnose('reconnect', reconnect)
218
+ conn.emit('close', new Error('lost'))
219
+
220
+ await connection.createChannel('event')
221
+
222
+ expect(reconnect).toHaveBeenCalled()
223
+ }, 10000)
224
+
225
+ it('should bound a connect that never settles', async () => {
226
+ jest.useFakeTimers()
227
+
228
+ try {
229
+ const errors = []
230
+
231
+ connection.diagnose('error', (exception) => errors.push(exception))
232
+ amqplib.connect.mockImplementation(() => new Promise(() => undefined))
233
+
234
+ conn.emit('close', new Error('lost'))
235
+
236
+ await jest.advanceTimersByTimeAsync(0)
237
+ await jest.advanceTimersByTimeAsync(30_000)
238
+
239
+ expect(errors).toEqual([expect.objectContaining({ code: 'ETIMEDOUT' })])
240
+
241
+ await jest.advanceTimersByTimeAsync(2_000)
242
+
243
+ expect(amqplib.connect.mock.calls.length).toBeGreaterThan(2)
244
+ } finally {
245
+ jest.useRealTimers()
246
+ }
247
+ })
248
+
214
249
  it('should ignore close from a stale connection', async () => {
215
250
  const stale = conn
216
251
  const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
@@ -402,6 +437,7 @@ describe('close', () => {
402
437
  const amqp = await amqplib.connect.mock.results[0].value
403
438
 
404
439
  expect(amqp.close).toHaveBeenCalled()
440
+ expect(connection.closed).toStrictEqual(true)
405
441
  })
406
442
 
407
443
  it('should close after connection is (re)established', async () => {
@@ -475,6 +511,21 @@ describe('diagnostics', () => {
475
511
  expect(captured).toStrictEqual(true)
476
512
  })
477
513
 
514
+ it('should not emit reconnect on the initial open', async () => {
515
+ const reconnect = jest.fn()
516
+
517
+ connection.diagnose('reconnect', reconnect)
518
+
519
+ jest.clearAllMocks()
520
+
521
+ connection = new Connection(url)
522
+ connection.diagnose('reconnect', reconnect)
523
+
524
+ await connection.open()
525
+
526
+ expect(reconnect).not.toHaveBeenCalled()
527
+ })
528
+
478
529
  it('should re-emit `close` event', async () => {
479
530
  let captured
480
531
 
@@ -61,7 +61,7 @@ it('should not throw on error without listeners', async () => {
61
61
  expect(() => emit(new Error(generate()))).not.toThrow()
62
62
  })
63
63
 
64
- it.each(['open', 'close'])('should re-emit %s from connection',
64
+ it.each(['open', 'close', 'error', 'reconnect'])('should re-emit %s from connection',
65
65
  /**
66
66
  * @param {comq.diagnostics.Event} event
67
67
  */
@@ -40,11 +40,45 @@ it('should report a lost shard', async () => {
40
40
 
41
41
  expect(calls.length).toBeGreaterThan(0)
42
42
 
43
- for (const call of calls) call[1]()
43
+ for (const call of calls) call[1](new Error('gone'))
44
44
 
45
45
  expect(lost).toHaveBeenCalledWith(1)
46
46
  })
47
47
 
48
+ it('should not report a lost shard when the connection is closed', async () => {
49
+ channel = await create(connections, type)
50
+
51
+ const lost = /** @type {jest.MockedFunction} */ jest.fn()
52
+
53
+ channel.diagnose('lost', lost)
54
+
55
+ connections[1].closed = true
56
+
57
+ const calls = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
58
+
59
+ expect(calls.length).toBeGreaterThan(0)
60
+
61
+ for (const call of calls) call[1](new Error('gone'))
62
+
63
+ expect(lost).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it('should not report a lost shard when the connection is closed cleanly', async () => {
67
+ channel = await create(connections, type)
68
+
69
+ const lost = /** @type {jest.MockedFunction} */ jest.fn()
70
+
71
+ channel.diagnose('lost', lost)
72
+
73
+ const calls = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
74
+
75
+ expect(calls.length).toBeGreaterThan(0)
76
+
77
+ for (const call of calls) call[1]()
78
+
79
+ expect(lost).not.toHaveBeenCalled()
80
+ })
81
+
48
82
  it('should not publish to a lost shard', async () => {
49
83
  channel = await create(connections, type)
50
84
 
@@ -62,6 +96,40 @@ it('should not publish to a lost shard', async () => {
62
96
  expect(channels[1].send).not.toHaveBeenCalled()
63
97
  })
64
98
 
99
+ it('should wait for a shard to recover when all are lost', async () => {
100
+ channel = await create(connections, type)
101
+
102
+ const channels = await getCreatedChannels()
103
+ const buffer = randomBytes(8)
104
+ const label = generate()
105
+ let sent = false
106
+
107
+ for (const connection of connections) {
108
+ const calls = connection.diagnose.mock.calls.filter((call) => call[0] === 'close')
109
+
110
+ for (const call of calls) call[1](new Error('gone'))
111
+ }
112
+
113
+ const sending = channel.send(label, buffer).then(() => { sent = true })
114
+
115
+ await immediate()
116
+
117
+ expect(sent).toStrictEqual(false)
118
+
119
+ for (const chan of channels) expect(chan.send).not.toHaveBeenCalled()
120
+
121
+ const open = connections[0].diagnose.mock.calls.filter((call) => call[0] === 'open')
122
+ const recover = channels[0].diagnose.mock.calls.filter((call) => call[0] === 'recover')
123
+
124
+ for (const call of open) call[1]()
125
+ for (const call of recover) call[1]()
126
+
127
+ await sending
128
+
129
+ expect(channels[0].send).toHaveBeenCalled()
130
+ expect(channels[1].send).not.toHaveBeenCalled()
131
+ })
132
+
65
133
  // a lost shard is not benched, so it is used again as soon as it reconnects
66
134
  it('should publish to a recovered shard', async () => {
67
135
  channel = await create(connections, type)
@@ -7,6 +7,7 @@ declare namespace comq {
7
7
 
8
8
  interface Connection {
9
9
  readonly connected?: boolean
10
+ readonly closed?: boolean
10
11
 
11
12
  open(): Promise<void>
12
13
 
@@ -1,7 +1,7 @@
1
1
  declare namespace comq.diagnostics {
2
2
 
3
- type Event = 'open' | 'close' | 'error' | 'flow' | 'drain' | 'remove' | 'recover' | 'discard' |
4
- 'pause' | 'resume' | 'return'
3
+ type Event = 'open' | 'close' | 'error' | 'reconnect' | 'flow' | 'drain' | 'remove' | 'lost' |
4
+ 'recover' | 'discard' | 'pause' | 'resume' | 'return'
5
5
 
6
6
  interface Diagnosable {
7
7
  diagnose(event: Event, listener: Function): void
package/types/io.d.ts CHANGED
@@ -63,6 +63,8 @@ declare namespace comq {
63
63
 
64
64
  diagnose(event: 'error', listener: (error: Error, index?: number) => void): void
65
65
 
66
+ diagnose(event: 'reconnect', listener: (index?: number) => void): void
67
+
66
68
  diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
67
69
 
68
70
  diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void