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 +17 -0
- package/package.json +1 -1
- package/readme.md +8 -4
- package/source/connection.js +50 -2
- package/source/events.js +1 -1
- package/source/shards/channel.js +20 -12
- package/test/connection.mock.js +1 -0
- package/test/connection.test.js +51 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/shards/channel.test.js +69 -1
- package/types/connection.d.ts +1 -0
- package/types/diagnostic.d.ts +2 -2
- package/types/io.d.ts +2 -0
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
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
|
@@ -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
|
|
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']
|
package/source/shards/channel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
318
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
352
|
+
return this.#one(fn)
|
|
345
353
|
}
|
|
346
354
|
}
|
|
347
355
|
}
|
package/test/connection.mock.js
CHANGED
|
@@ -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),
|
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]
|
|
@@ -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)
|
package/types/connection.d.ts
CHANGED
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
|