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 +9 -0
- package/package.json +1 -1
- package/readme.md +8 -4
- package/source/connection.js +46 -2
- package/source/events.js +1 -1
- package/source/shards/channel.js +14 -10
- package/test/connection.test.js +50 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/shards/channel.test.js +34 -0
- package/types/diagnostic.d.ts +2 -2
- package/types/io.d.ts +2 -0
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
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
|
|
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('
|
|
524
|
-
io.diagnose('
|
|
525
|
-
io.diagnose('
|
|
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
|
package/source/connection.js
CHANGED
|
@@ -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
|
|
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']
|
package/source/shards/channel.js
CHANGED
|
@@ -318,26 +318,30 @@ class Channel {
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
/**
|
|
321
|
-
* The channels of the shards that are known to be connected
|
|
322
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
352
|
+
return this.#one(fn)
|
|
349
353
|
}
|
|
350
354
|
}
|
|
351
355
|
}
|
package/test/connection.test.js
CHANGED
|
@@ -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)
|
package/types/diagnostic.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
declare namespace comq.diagnostics {
|
|
2
2
|
|
|
3
|
-
type Event = 'open' | 'close' | 'error' | '
|
|
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
|