comq 0.15.6 → 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,12 @@
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
+
1
10
  ## [0.15.6](https://github.com/toa-io/comq/compare/v0.15.5...v0.15.6) (2026-08-26)
2
11
 
3
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.6",
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
@@ -73,6 +73,11 @@ class Connection {
73
73
 
74
74
  await this.#opening
75
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
+
76
81
  this.#running = true
77
82
  }
78
83
 
@@ -109,16 +114,18 @@ class Connection {
109
114
  #open = async (retry) => {
110
115
  if (this.#closed) return
111
116
 
117
+ // the initial connect finishes before the caller can subscribe
118
+ if (this.#running) this.#diagnostics.emit('reconnect')
119
+
112
120
  /** @type {comq.amqp.Connection} */
113
121
  let connection
114
122
 
115
123
  try {
116
- connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
124
+ connection = await this.#connect()
117
125
  } catch (exception) {
118
126
  if (this.#closed) return
119
127
  if (!this.#transient(exception)) throw exception
120
128
 
121
- // attempts are made until one succeeds, so an outage is observable only here
122
129
  this.#diagnostics.emit('error', exception)
123
130
 
124
131
  return retry
@@ -200,6 +207,43 @@ class Connection {
200
207
  return this.#recovery
201
208
  }
202
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
+
203
247
  /**
204
248
  * @param {comq.amqp.Connection} connection
205
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']
@@ -318,26 +318,30 @@ class Channel {
318
318
  }
319
319
 
320
320
  /**
321
- * The channels of the shards that are known to be connected, falling back to the
322
- * 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.
323
324
  *
324
325
  * @return {comq.Channel[]}
325
326
  */
326
327
  #reachable () {
327
- const reachable = this.#pool.filter((channel) => this.#alive[channel.index] !== false)
328
-
329
- return reachable.length === 0 ? this.#pool : reachable
328
+ return this.#pool.filter((channel) => this.#alive[channel.index] !== false)
330
329
  }
331
330
 
332
331
  /**
333
332
  * @param {(channel: comq.Channel) => void} fn
334
333
  */
335
334
  async #one (fn) {
336
- if (this.#pool.length === 0) await this.#recovery
337
-
338
- // publishing to a lost shard is silently accepted by the destroyed socket, so
339
- // 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
340
337
  const pool = this.#reachable()
338
+
339
+ if (pool.length === 0) {
340
+ await waiting
341
+
342
+ return this.#one(fn)
343
+ }
344
+
341
345
  const channel = pool[Math.floor(Math.random() * pool.length)]
342
346
 
343
347
  try {
@@ -345,7 +349,7 @@ class Channel {
345
349
  } catch {
346
350
  this.#remove(channel)
347
351
 
348
- return await this.#one(fn)
352
+ return this.#one(fn)
349
353
  }
350
354
  }
351
355
  }
@@ -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]
@@ -476,6 +511,21 @@ describe('diagnostics', () => {
476
511
  expect(captured).toStrictEqual(true)
477
512
  })
478
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
+
479
529
  it('should re-emit `close` event', async () => {
480
530
  let captured
481
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
  */
@@ -96,6 +96,40 @@ it('should not publish to a lost shard', async () => {
96
96
  expect(channels[1].send).not.toHaveBeenCalled()
97
97
  })
98
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
+
99
133
  // a lost shard is not benched, so it is used again as soon as it reconnects
100
134
  it('should publish to a recovered shard', async () => {
101
135
  channel = await create(connections, type)
@@ -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