comq 0.15.4 → 0.15.6
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 +16 -0
- package/package.json +1 -1
- package/readme.md +8 -0
- package/source/connection.js +96 -15
- package/source/io.js +3 -0
- package/source/shards/channel.js +6 -2
- package/source/shards/connection.js +5 -0
- package/test/amqplib.mock.js +4 -1
- package/test/connection.mock.js +1 -0
- package/test/connection.test.js +130 -2
- package/test/io.diagnostics.test.js +7 -0
- package/test/shards/channel.test.js +35 -1
- package/types/connection.d.ts +1 -0
- package/types/io.d.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [0.15.6](https://github.com/toa-io/comq/compare/v0.15.5...v0.15.6) (2026-08-26)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* do not report shards as lost on shutdown ([abbe0e8](https://github.com/toa-io/comq/commit/abbe0e8e1048509ff2c981b7aa33659c0829e821))
|
|
7
|
+
* 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))
|
|
8
|
+
|
|
9
|
+
## [0.15.5](https://github.com/toa-io/comq/compare/v0.15.4...v0.15.5) (2026-08-25)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* recover from a lost connection reliably ([59b1fab](https://github.com/toa-io/comq/commit/59b1fab61d4f27ab6eb608693da6493b5b1f4ae5))
|
|
15
|
+
* recover from a lost connection reliably ([#247](https://github.com/toa-io/comq/issues/247)) ([e4701cf](https://github.com/toa-io/comq/commit/e4701cfa384d5451c15d0a6bf53c82ce1da91ac5))
|
|
16
|
+
|
|
1
17
|
## [0.15.4](https://github.com/toa-io/comq/compare/v0.15.3...v0.15.4) (2026-08-16)
|
|
2
18
|
|
|
3
19
|
|
package/package.json
CHANGED
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
|
package/source/connection.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -50,7 +60,13 @@ class Connection {
|
|
|
50
60
|
return this.#connection !== undefined
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
get closed () {
|
|
64
|
+
return this.#closed
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
async open () {
|
|
68
|
+
this.#closed = false
|
|
69
|
+
|
|
54
70
|
if (this.#opening !== null) return this.#opening
|
|
55
71
|
|
|
56
72
|
this.#opening = retry(this.#open).finally(() => { this.#opening = null })
|
|
@@ -61,9 +77,12 @@ class Connection {
|
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
async close () {
|
|
64
|
-
|
|
80
|
+
this.#closed = true
|
|
65
81
|
|
|
66
|
-
|
|
82
|
+
// a connection that is about to be established must not be left open, yet an
|
|
83
|
+
// attempt that is being retried must not hold up the shutdown
|
|
84
|
+
if (this.#opening !== null) await Promise.race([this.#opening.catch(noop), expiration()])
|
|
85
|
+
if (this.#connection !== undefined) await this.#shutdown(this.#connection)
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
createChannel = failsafe(this, this.#recover,
|
|
@@ -88,16 +107,25 @@ class Connection {
|
|
|
88
107
|
}
|
|
89
108
|
|
|
90
109
|
#open = async (retry) => {
|
|
110
|
+
if (this.#closed) return
|
|
111
|
+
|
|
91
112
|
/** @type {comq.amqp.Connection} */
|
|
92
113
|
let connection
|
|
93
114
|
|
|
94
115
|
try {
|
|
95
|
-
connection = await amqp.connect(this.#url)
|
|
116
|
+
connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
|
|
96
117
|
} catch (exception) {
|
|
97
|
-
if (this.#
|
|
98
|
-
|
|
118
|
+
if (this.#closed) return
|
|
119
|
+
if (!this.#transient(exception)) throw exception
|
|
120
|
+
|
|
121
|
+
// attempts are made until one succeeds, so an outage is observable only here
|
|
122
|
+
this.#diagnostics.emit('error', exception)
|
|
123
|
+
|
|
124
|
+
return retry
|
|
99
125
|
}
|
|
100
126
|
|
|
127
|
+
if (this.#closed) return await this.#shutdown(connection)
|
|
128
|
+
|
|
101
129
|
// This prevents the process from crashing; 'close' will be emitted next.
|
|
102
130
|
// https://amqp-node.github.io/amqplib/channel_api.html#model_events
|
|
103
131
|
connection.on('error', noop)
|
|
@@ -110,17 +138,16 @@ class Connection {
|
|
|
110
138
|
try {
|
|
111
139
|
for (const channel of this.#channels) await channel.recover(connection)
|
|
112
140
|
} catch (exception) {
|
|
113
|
-
this.#disarmWatchdog()
|
|
114
141
|
this.#diagnostics.emit('error', exception)
|
|
115
|
-
connection
|
|
116
|
-
|
|
117
|
-
await connection.close().catch(noop)
|
|
118
|
-
|
|
119
|
-
if (this.#connection === connection) this.#connection = undefined
|
|
142
|
+
this.#drop(connection)
|
|
120
143
|
|
|
121
144
|
return retry
|
|
122
145
|
}
|
|
123
146
|
|
|
147
|
+
// the connection may have been lost while the topology was being recovered,
|
|
148
|
+
// in which case 'close' has left the reconnection to this very attempt
|
|
149
|
+
if (this.#connection !== connection) return retry
|
|
150
|
+
|
|
124
151
|
this.#recovery.resolve()
|
|
125
152
|
this.#recovery = new Promex()
|
|
126
153
|
}
|
|
@@ -130,19 +157,45 @@ class Connection {
|
|
|
130
157
|
* @param {Error} [error]
|
|
131
158
|
*/
|
|
132
159
|
#close = (connection, error) => {
|
|
133
|
-
this.#disarmWatchdog()
|
|
134
|
-
|
|
135
160
|
if (this.#connection !== connection) return
|
|
136
161
|
|
|
162
|
+
this.#disarmWatchdog()
|
|
137
163
|
this.#diagnostics.emit('close', error)
|
|
138
164
|
connection.removeAllListeners()
|
|
139
165
|
this.#connection = undefined
|
|
140
166
|
|
|
141
|
-
if (error !== undefined) {
|
|
167
|
+
if (error !== undefined && !this.#closed) {
|
|
142
168
|
this.open().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
143
169
|
}
|
|
144
170
|
}
|
|
145
171
|
|
|
172
|
+
/**
|
|
173
|
+
* An AMQP connection is only closed once the broker has replied with Close-Ok,
|
|
174
|
+
* which never happens on a connection that has already been lost.
|
|
175
|
+
*
|
|
176
|
+
* @param {comq.amqp.Connection} connection
|
|
177
|
+
*/
|
|
178
|
+
async #shutdown (connection) {
|
|
179
|
+
const closing = connection.close().catch(noop)
|
|
180
|
+
|
|
181
|
+
await Promise.race([closing, expiration()])
|
|
182
|
+
|
|
183
|
+
this.#drop(connection)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {comq.amqp.Connection} connection
|
|
188
|
+
*/
|
|
189
|
+
#drop (connection) {
|
|
190
|
+
if (this.#connection === connection) {
|
|
191
|
+
this.#disarmWatchdog()
|
|
192
|
+
this.#connection = undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
connection.removeAllListeners()
|
|
196
|
+
connection.connection?.stream?.destroy()
|
|
197
|
+
}
|
|
198
|
+
|
|
146
199
|
#recover () {
|
|
147
200
|
return this.#recovery
|
|
148
201
|
}
|
|
@@ -162,6 +215,11 @@ class Connection {
|
|
|
162
215
|
this.#heartbeatTimer.unref()
|
|
163
216
|
}
|
|
164
217
|
|
|
218
|
+
this.#disarmWatchdog()
|
|
219
|
+
|
|
220
|
+
this.#heartbeatSocket = socket
|
|
221
|
+
this.#heartbeatReset = reset
|
|
222
|
+
|
|
165
223
|
reset()
|
|
166
224
|
socket.on('data', reset)
|
|
167
225
|
}
|
|
@@ -169,6 +227,13 @@ class Connection {
|
|
|
169
227
|
#disarmWatchdog () {
|
|
170
228
|
clearTimeout(this.#heartbeatTimer)
|
|
171
229
|
this.#heartbeatTimer = null
|
|
230
|
+
|
|
231
|
+
// otherwise a byte arriving on a replaced socket rearms the watchdog of a
|
|
232
|
+
// connection that is already gone, leaving the current one unguarded
|
|
233
|
+
this.#heartbeatSocket?.off('data', this.#heartbeatReset)
|
|
234
|
+
|
|
235
|
+
this.#heartbeatSocket = null
|
|
236
|
+
this.#heartbeatReset = null
|
|
172
237
|
}
|
|
173
238
|
|
|
174
239
|
#transient (exception) {
|
|
@@ -183,6 +248,12 @@ class Connection {
|
|
|
183
248
|
/** @type {number} */
|
|
184
249
|
const WATCHDOG_MS = 60_000
|
|
185
250
|
|
|
251
|
+
/** @type {number} */
|
|
252
|
+
const CONNECT_MS = 30_000
|
|
253
|
+
|
|
254
|
+
/** @type {number} */
|
|
255
|
+
const SHUTDOWN_MS = 5_000
|
|
256
|
+
|
|
186
257
|
const TRANSIENT_CODES = new Set([
|
|
187
258
|
'ECONNREFUSED',
|
|
188
259
|
'EAI_AGAIN',
|
|
@@ -195,9 +266,19 @@ const TRANSIENT_CODES = new Set([
|
|
|
195
266
|
|
|
196
267
|
const TRANSIENT_MESSAGES = new Set([
|
|
197
268
|
'Socket closed abruptly during opening handshake',
|
|
198
|
-
'Client network socket disconnected before secure TLS connection was established'
|
|
269
|
+
'Client network socket disconnected before secure TLS connection was established',
|
|
270
|
+
'connect ETIMEDOUT' // amqplib reports the `timeout` option without a code
|
|
199
271
|
])
|
|
200
272
|
|
|
273
|
+
/**
|
|
274
|
+
* @return {Promise<void>}
|
|
275
|
+
*/
|
|
276
|
+
function expiration () {
|
|
277
|
+
const timeoutMs = global.COMQ_TESTING_SHUTDOWN_TIMEOUT ?? SHUTDOWN_MS
|
|
278
|
+
|
|
279
|
+
return delay(timeoutMs, undefined, { ref: false })
|
|
280
|
+
}
|
|
281
|
+
|
|
201
282
|
function noop () {}
|
|
202
283
|
|
|
203
284
|
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
|
}
|
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', () => {
|
|
@@ -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
|
package/test/amqplib.mock.js
CHANGED
|
@@ -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(
|
|
46
|
+
connect: jest.fn(connect)
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
exports.amqplib = amqplib
|
|
50
|
+
exports.connect = connect
|
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
|
@@ -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()
|
|
@@ -318,6 +402,7 @@ describe('close', () => {
|
|
|
318
402
|
const amqp = await amqplib.connect.mock.results[0].value
|
|
319
403
|
|
|
320
404
|
expect(amqp.close).toHaveBeenCalled()
|
|
405
|
+
expect(connection.closed).toStrictEqual(true)
|
|
321
406
|
})
|
|
322
407
|
|
|
323
408
|
it('should close after connection is (re)established', async () => {
|
|
@@ -331,6 +416,49 @@ describe('close', () => {
|
|
|
331
416
|
|
|
332
417
|
expect(conn.close).toHaveBeenCalled()
|
|
333
418
|
})
|
|
419
|
+
|
|
420
|
+
it('should not wait for Close-Ok that never arrives', async () => {
|
|
421
|
+
await connection.open()
|
|
422
|
+
|
|
423
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
424
|
+
const conn = await amqplib.connect.mock.results[0].value
|
|
425
|
+
|
|
426
|
+
conn.close.mockImplementation(() => new Promise(() => undefined))
|
|
427
|
+
|
|
428
|
+
await connection.close()
|
|
429
|
+
|
|
430
|
+
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('should open again after close', async () => {
|
|
434
|
+
await connection.open()
|
|
435
|
+
await connection.close()
|
|
436
|
+
await connection.open()
|
|
437
|
+
|
|
438
|
+
await expect(connection.createChannel('request')).resolves.toBeDefined()
|
|
439
|
+
|
|
440
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(2)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('should stop reconnecting', async () => {
|
|
444
|
+
await connection.open()
|
|
445
|
+
|
|
446
|
+
const conn = await amqplib.connect.mock.results[0].value
|
|
447
|
+
const attempts = []
|
|
448
|
+
|
|
449
|
+
connection.diagnose('error', (exception) => attempts.push(exception))
|
|
450
|
+
amqplib.connect.mockImplementation(async () => { throw { code: 'ECONNREFUSED' } }) // eslint-disable-line
|
|
451
|
+
|
|
452
|
+
conn.emit('close', new Error('lost'))
|
|
453
|
+
|
|
454
|
+
await connection.close()
|
|
455
|
+
|
|
456
|
+
const failed = attempts.length
|
|
457
|
+
|
|
458
|
+
await timeout(1500)
|
|
459
|
+
|
|
460
|
+
expect(attempts.length).toStrictEqual(failed)
|
|
461
|
+
}, 10000)
|
|
334
462
|
})
|
|
335
463
|
|
|
336
464
|
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
|
|
@@ -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
|
|
package/types/connection.d.ts
CHANGED
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
|