comq 0.15.3 → 0.15.5
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 +15 -0
- package/package.json +1 -1
- package/readme.md +11 -6
- package/source/connection.js +92 -15
- package/source/io.js +11 -42
- package/source/shards/connection.js +5 -0
- package/test/amqplib.mock.js +4 -1
- package/test/connection.test.js +129 -2
- package/test/io.diagnostics.test.js +7 -0
- package/types/io.d.ts +5 -5
- package/test/io.request.timeout.test.js +0 -61
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.15.5](https://github.com/toa-io/comq/compare/v0.15.4...v0.15.5) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* recover from a lost connection reliably ([59b1fab](https://github.com/toa-io/comq/commit/59b1fab61d4f27ab6eb608693da6493b5b1f4ae5))
|
|
7
|
+
* recover from a lost connection reliably ([#247](https://github.com/toa-io/comq/issues/247)) ([e4701cf](https://github.com/toa-io/comq/commit/e4701cfa384d5451c15d0a6bf53c82ce1da91ac5))
|
|
8
|
+
|
|
9
|
+
## [0.15.4](https://github.com/toa-io/comq/compare/v0.15.3...v0.15.4) (2026-08-16)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* remove request() timeout ([#246](https://github.com/toa-io/comq/issues/246)) ([d432388](https://github.com/toa-io/comq/commit/d4323889bcad867a870d0ed351b92098f6aad551))
|
|
15
|
+
|
|
1
16
|
## [0.15.3](https://github.com/toa-io/comq/compare/v0.15.2...v0.15.3) (2026-08-04)
|
|
2
17
|
|
|
3
18
|
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -103,11 +103,10 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
|
|
|
103
103
|
|
|
104
104
|
## Request
|
|
105
105
|
|
|
106
|
-
`async IO.request(queue: string, payload: any, encoding?: string
|
|
106
|
+
`async IO.request(queue: string, payload: any, encoding?: string): any`
|
|
107
107
|
|
|
108
108
|
Send encoded Request message with `replyTo` and `correlationId` properties set and
|
|
109
|
-
return decoded Reply content.
|
|
110
|
-
Reply arrives in time; encoding may be omitted when only `timeout` is passed.
|
|
109
|
+
return decoded Reply content. The promise stays pending until the Reply arrives.
|
|
111
110
|
|
|
112
111
|
On the initial call, queues for Requests and Replies are asserted.
|
|
113
112
|
|
|
@@ -115,7 +114,6 @@ On the initial call, queues for Requests and Replies are asserted.
|
|
|
115
114
|
|
|
116
115
|
```javascript
|
|
117
116
|
const sum = await io.request('add_numbers', { a: 1, b: 2 })
|
|
118
|
-
const sumOrTimeout = await io.request('add_numbers', { a: 1, b: 2 }, 5000)
|
|
119
117
|
```
|
|
120
118
|
|
|
121
119
|
## Consumption
|
|
@@ -332,8 +330,7 @@ Outgoing messages are sent to a single connection chosen at random from the shar
|
|
|
332
330
|
underlying connection or experience channel [back pressure](#flow-control) on a corresponding channel are removed from
|
|
333
331
|
the pool until the issue is resolved. Pending messages meeting these conditions are immediately routed among the
|
|
334
332
|
remaining shards in the pool. If no shards are available, `send` / `publish` / `request` wait until a shard's
|
|
335
|
-
connection is re-established
|
|
336
|
-
`ETIMEDOUT` instead of hanging while the pool is empty or a Reply never arrives.
|
|
333
|
+
connection is re-established.
|
|
337
334
|
|
|
338
335
|
Incoming messages are consumed from all shards.
|
|
339
336
|
|
|
@@ -493,10 +490,14 @@ Subscribe to one of the diagnostic events:
|
|
|
493
490
|
- `close`: connection is closed.
|
|
494
491
|
Optional [`error`](https://amqp-node.github.io/amqplib/channel_api.html#model_events) is passed
|
|
495
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.
|
|
496
495
|
- `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
|
|
497
496
|
an argument.
|
|
498
497
|
- `drain`: back pressure is removed from a channel. Channel type is passed.
|
|
499
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.
|
|
500
501
|
- `recover`: channel's topology is recovered. Channel type is passed.
|
|
501
502
|
- `discard`: message is [discarded](#messages) as it repeatedly caused
|
|
502
503
|
exceptions. Channel type,
|
|
@@ -518,6 +519,10 @@ established, there is no way to capture the initial `open` event.
|
|
|
518
519
|
|
|
519
520
|
```javascript
|
|
520
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))
|
|
521
526
|
```
|
|
522
527
|
|
|
523
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
|
/**
|
|
@@ -51,6 +61,8 @@ class Connection {
|
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
async open () {
|
|
64
|
+
this.#closed = false
|
|
65
|
+
|
|
54
66
|
if (this.#opening !== null) return this.#opening
|
|
55
67
|
|
|
56
68
|
this.#opening = retry(this.#open).finally(() => { this.#opening = null })
|
|
@@ -61,9 +73,12 @@ class Connection {
|
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
async close () {
|
|
64
|
-
|
|
76
|
+
this.#closed = true
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
// a connection that is about to be established must not be left open, yet an
|
|
79
|
+
// attempt that is being retried must not hold up the shutdown
|
|
80
|
+
if (this.#opening !== null) await Promise.race([this.#opening.catch(noop), expiration()])
|
|
81
|
+
if (this.#connection !== undefined) await this.#shutdown(this.#connection)
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
createChannel = failsafe(this, this.#recover,
|
|
@@ -88,16 +103,25 @@ class Connection {
|
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
#open = async (retry) => {
|
|
106
|
+
if (this.#closed) return
|
|
107
|
+
|
|
91
108
|
/** @type {comq.amqp.Connection} */
|
|
92
109
|
let connection
|
|
93
110
|
|
|
94
111
|
try {
|
|
95
|
-
connection = await amqp.connect(this.#url)
|
|
112
|
+
connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
|
|
96
113
|
} catch (exception) {
|
|
97
|
-
if (this.#
|
|
98
|
-
|
|
114
|
+
if (this.#closed) return
|
|
115
|
+
if (!this.#transient(exception)) throw exception
|
|
116
|
+
|
|
117
|
+
// attempts are made until one succeeds, so an outage is observable only here
|
|
118
|
+
this.#diagnostics.emit('error', exception)
|
|
119
|
+
|
|
120
|
+
return retry
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
if (this.#closed) return await this.#shutdown(connection)
|
|
124
|
+
|
|
101
125
|
// This prevents the process from crashing; 'close' will be emitted next.
|
|
102
126
|
// https://amqp-node.github.io/amqplib/channel_api.html#model_events
|
|
103
127
|
connection.on('error', noop)
|
|
@@ -110,17 +134,16 @@ class Connection {
|
|
|
110
134
|
try {
|
|
111
135
|
for (const channel of this.#channels) await channel.recover(connection)
|
|
112
136
|
} catch (exception) {
|
|
113
|
-
this.#disarmWatchdog()
|
|
114
137
|
this.#diagnostics.emit('error', exception)
|
|
115
|
-
connection
|
|
116
|
-
|
|
117
|
-
await connection.close().catch(noop)
|
|
118
|
-
|
|
119
|
-
if (this.#connection === connection) this.#connection = undefined
|
|
138
|
+
this.#drop(connection)
|
|
120
139
|
|
|
121
140
|
return retry
|
|
122
141
|
}
|
|
123
142
|
|
|
143
|
+
// the connection may have been lost while the topology was being recovered,
|
|
144
|
+
// in which case 'close' has left the reconnection to this very attempt
|
|
145
|
+
if (this.#connection !== connection) return retry
|
|
146
|
+
|
|
124
147
|
this.#recovery.resolve()
|
|
125
148
|
this.#recovery = new Promex()
|
|
126
149
|
}
|
|
@@ -130,19 +153,45 @@ class Connection {
|
|
|
130
153
|
* @param {Error} [error]
|
|
131
154
|
*/
|
|
132
155
|
#close = (connection, error) => {
|
|
133
|
-
this.#disarmWatchdog()
|
|
134
|
-
|
|
135
156
|
if (this.#connection !== connection) return
|
|
136
157
|
|
|
158
|
+
this.#disarmWatchdog()
|
|
137
159
|
this.#diagnostics.emit('close', error)
|
|
138
160
|
connection.removeAllListeners()
|
|
139
161
|
this.#connection = undefined
|
|
140
162
|
|
|
141
|
-
if (error !== undefined) {
|
|
163
|
+
if (error !== undefined && !this.#closed) {
|
|
142
164
|
this.open().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
167
|
|
|
168
|
+
/**
|
|
169
|
+
* An AMQP connection is only closed once the broker has replied with Close-Ok,
|
|
170
|
+
* which never happens on a connection that has already been lost.
|
|
171
|
+
*
|
|
172
|
+
* @param {comq.amqp.Connection} connection
|
|
173
|
+
*/
|
|
174
|
+
async #shutdown (connection) {
|
|
175
|
+
const closing = connection.close().catch(noop)
|
|
176
|
+
|
|
177
|
+
await Promise.race([closing, expiration()])
|
|
178
|
+
|
|
179
|
+
this.#drop(connection)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {comq.amqp.Connection} connection
|
|
184
|
+
*/
|
|
185
|
+
#drop (connection) {
|
|
186
|
+
if (this.#connection === connection) {
|
|
187
|
+
this.#disarmWatchdog()
|
|
188
|
+
this.#connection = undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
connection.removeAllListeners()
|
|
192
|
+
connection.connection?.stream?.destroy()
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
#recover () {
|
|
147
196
|
return this.#recovery
|
|
148
197
|
}
|
|
@@ -162,6 +211,11 @@ class Connection {
|
|
|
162
211
|
this.#heartbeatTimer.unref()
|
|
163
212
|
}
|
|
164
213
|
|
|
214
|
+
this.#disarmWatchdog()
|
|
215
|
+
|
|
216
|
+
this.#heartbeatSocket = socket
|
|
217
|
+
this.#heartbeatReset = reset
|
|
218
|
+
|
|
165
219
|
reset()
|
|
166
220
|
socket.on('data', reset)
|
|
167
221
|
}
|
|
@@ -169,6 +223,13 @@ class Connection {
|
|
|
169
223
|
#disarmWatchdog () {
|
|
170
224
|
clearTimeout(this.#heartbeatTimer)
|
|
171
225
|
this.#heartbeatTimer = null
|
|
226
|
+
|
|
227
|
+
// otherwise a byte arriving on a replaced socket rearms the watchdog of a
|
|
228
|
+
// connection that is already gone, leaving the current one unguarded
|
|
229
|
+
this.#heartbeatSocket?.off('data', this.#heartbeatReset)
|
|
230
|
+
|
|
231
|
+
this.#heartbeatSocket = null
|
|
232
|
+
this.#heartbeatReset = null
|
|
172
233
|
}
|
|
173
234
|
|
|
174
235
|
#transient (exception) {
|
|
@@ -183,6 +244,12 @@ class Connection {
|
|
|
183
244
|
/** @type {number} */
|
|
184
245
|
const WATCHDOG_MS = 60_000
|
|
185
246
|
|
|
247
|
+
/** @type {number} */
|
|
248
|
+
const CONNECT_MS = 30_000
|
|
249
|
+
|
|
250
|
+
/** @type {number} */
|
|
251
|
+
const SHUTDOWN_MS = 5_000
|
|
252
|
+
|
|
186
253
|
const TRANSIENT_CODES = new Set([
|
|
187
254
|
'ECONNREFUSED',
|
|
188
255
|
'EAI_AGAIN',
|
|
@@ -195,9 +262,19 @@ const TRANSIENT_CODES = new Set([
|
|
|
195
262
|
|
|
196
263
|
const TRANSIENT_MESSAGES = new Set([
|
|
197
264
|
'Socket closed abruptly during opening handshake',
|
|
198
|
-
'Client network socket disconnected before secure TLS connection was established'
|
|
265
|
+
'Client network socket disconnected before secure TLS connection was established',
|
|
266
|
+
'connect ETIMEDOUT' // amqplib reports the `timeout` option without a code
|
|
199
267
|
])
|
|
200
268
|
|
|
269
|
+
/**
|
|
270
|
+
* @return {Promise<void>}
|
|
271
|
+
*/
|
|
272
|
+
function expiration () {
|
|
273
|
+
const timeoutMs = global.COMQ_TESTING_SHUTDOWN_TIMEOUT ?? SHUTDOWN_MS
|
|
274
|
+
|
|
275
|
+
return delay(timeoutMs, undefined, { ref: false })
|
|
276
|
+
}
|
|
277
|
+
|
|
201
278
|
function noop () {}
|
|
202
279
|
|
|
203
280
|
exports.Connection = Connection
|
package/source/io.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const stream = require('node:stream')
|
|
4
4
|
const { EventEmitter } = require('node:events')
|
|
5
5
|
const { randomBytes } = require('node:crypto')
|
|
6
|
-
const { setTimeout
|
|
6
|
+
const { setTimeout } = require('node:timers/promises')
|
|
7
7
|
const { Promex } = require('promex')
|
|
8
8
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
9
9
|
|
|
@@ -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
|
}
|
|
@@ -75,45 +78,24 @@ class IO {
|
|
|
75
78
|
/**
|
|
76
79
|
* @param {string} queue
|
|
77
80
|
* @param {any | Readable} payload
|
|
78
|
-
* @param {comq.Encoding
|
|
79
|
-
* @param {number} [timeout]
|
|
81
|
+
* @param {comq.Encoding} [encoding]
|
|
80
82
|
* @returns {Promise<any | Readable>}
|
|
81
83
|
*/
|
|
82
|
-
async (queue, payload, encoding
|
|
83
|
-
if (typeof encoding === 'number') {
|
|
84
|
-
timeout = encoding
|
|
85
|
-
encoding = undefined
|
|
86
|
-
}
|
|
87
|
-
|
|
84
|
+
async (queue, payload, encoding) => {
|
|
88
85
|
if (payload instanceof stream.Readable) {
|
|
89
86
|
return pipeline(
|
|
90
87
|
payload,
|
|
91
|
-
(payload) => this.request(queue, payload, encoding
|
|
88
|
+
(payload) => this.request(queue, payload, encoding),
|
|
92
89
|
this.#requests
|
|
93
90
|
)
|
|
94
91
|
}
|
|
95
92
|
|
|
96
|
-
const request = this.#createRequest(queue, payload,
|
|
93
|
+
const request = this.#createRequest(queue, payload, encoding)
|
|
97
94
|
const reply = this.#createReply(request)
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
.then(() => reply)
|
|
96
|
+
await this.#requests.send(queue, request.buffer, request.properties)
|
|
101
97
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
return await Promise.race([
|
|
106
|
-
done,
|
|
107
|
-
delay(timeout).then(() => Promise.reject(timeoutError(timeout)))
|
|
108
|
-
])
|
|
109
|
-
} catch (exception) {
|
|
110
|
-
if (exception.code === 'ETIMEDOUT') {
|
|
111
|
-
request.emitter.removeAllListeners(request.properties.correlationId)
|
|
112
|
-
reply.reject(exception)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
throw exception
|
|
116
|
-
}
|
|
98
|
+
return reply
|
|
117
99
|
}))
|
|
118
100
|
|
|
119
101
|
consume = lazy(this, this.#createEventChannel,
|
|
@@ -428,7 +410,7 @@ class IO {
|
|
|
428
410
|
Even if these messages are lost, the reply stream will be closed anyway,
|
|
429
411
|
either due to missing heartbeat or the deletion of the stream queue.
|
|
430
412
|
*/
|
|
431
|
-
await
|
|
413
|
+
await setTimeout(50)
|
|
432
414
|
}
|
|
433
415
|
|
|
434
416
|
/**
|
|
@@ -495,19 +477,6 @@ const DEFAULT = 'application/json'
|
|
|
495
477
|
|
|
496
478
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
497
479
|
|
|
498
|
-
/**
|
|
499
|
-
* @param {number} timeout
|
|
500
|
-
* @returns {Error}
|
|
501
|
-
*/
|
|
502
|
-
function timeoutError (timeout) {
|
|
503
|
-
const error = /** @type {Error & { code: string }} */ new Error(
|
|
504
|
-
`Request timed out after ${timeout}ms`)
|
|
505
|
-
|
|
506
|
-
error.code = 'ETIMEDOUT'
|
|
507
|
-
|
|
508
|
-
return error
|
|
509
|
-
}
|
|
510
|
-
|
|
511
480
|
function noop () {}
|
|
512
481
|
|
|
513
482
|
exports.IO = IO
|
|
@@ -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.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()
|
|
@@ -331,6 +415,49 @@ describe('close', () => {
|
|
|
331
415
|
|
|
332
416
|
expect(conn.close).toHaveBeenCalled()
|
|
333
417
|
})
|
|
418
|
+
|
|
419
|
+
it('should not wait for Close-Ok that never arrives', async () => {
|
|
420
|
+
await connection.open()
|
|
421
|
+
|
|
422
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
423
|
+
const conn = await amqplib.connect.mock.results[0].value
|
|
424
|
+
|
|
425
|
+
conn.close.mockImplementation(() => new Promise(() => undefined))
|
|
426
|
+
|
|
427
|
+
await connection.close()
|
|
428
|
+
|
|
429
|
+
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('should open again after close', async () => {
|
|
433
|
+
await connection.open()
|
|
434
|
+
await connection.close()
|
|
435
|
+
await connection.open()
|
|
436
|
+
|
|
437
|
+
await expect(connection.createChannel('request')).resolves.toBeDefined()
|
|
438
|
+
|
|
439
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(2)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('should stop reconnecting', async () => {
|
|
443
|
+
await connection.open()
|
|
444
|
+
|
|
445
|
+
const conn = await amqplib.connect.mock.results[0].value
|
|
446
|
+
const attempts = []
|
|
447
|
+
|
|
448
|
+
connection.diagnose('error', (exception) => attempts.push(exception))
|
|
449
|
+
amqplib.connect.mockImplementation(async () => { throw { code: 'ECONNREFUSED' } }) // eslint-disable-line
|
|
450
|
+
|
|
451
|
+
conn.emit('close', new Error('lost'))
|
|
452
|
+
|
|
453
|
+
await connection.close()
|
|
454
|
+
|
|
455
|
+
const failed = attempts.length
|
|
456
|
+
|
|
457
|
+
await timeout(1500)
|
|
458
|
+
|
|
459
|
+
expect(attempts.length).toStrictEqual(failed)
|
|
460
|
+
}, 10000)
|
|
334
461
|
})
|
|
335
462
|
|
|
336
463
|
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
|
package/types/io.d.ts
CHANGED
|
@@ -27,11 +27,9 @@ declare namespace comq {
|
|
|
27
27
|
interface IO extends _diagnostics.Diagnosable {
|
|
28
28
|
reply(queue: string, produce: Producer): Promise<void>
|
|
29
29
|
|
|
30
|
-
request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding
|
|
30
|
+
request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
|
|
31
31
|
|
|
32
|
-
request
|
|
33
|
-
|
|
34
|
-
request(queue: string, stream: Readable, encoding?: _encoding.Encoding, timeout?: number): Promise<Readable>
|
|
32
|
+
request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
|
|
35
33
|
|
|
36
34
|
consume<T = any>(exchange: string, group: string, consumer: Consumer<T>): Promise<void>
|
|
37
35
|
|
|
@@ -61,7 +59,7 @@ declare namespace comq {
|
|
|
61
59
|
|
|
62
60
|
diagnose(event: 'open', listener: (index?: number) => void): void
|
|
63
61
|
|
|
64
|
-
diagnose(event: 'close', listener: (index?: number) => void): void
|
|
62
|
+
diagnose(event: 'close', listener: (error?: Error, index?: number) => void): void
|
|
65
63
|
|
|
66
64
|
diagnose(event: 'error', listener: (error: Error, index?: number) => void): void
|
|
67
65
|
|
|
@@ -71,6 +69,8 @@ declare namespace comq {
|
|
|
71
69
|
|
|
72
70
|
diagnose(event: 'remove', listener: (index?: number) => void): void
|
|
73
71
|
|
|
72
|
+
diagnose(event: 'lost', listener: (channel: _topology.type, index?: number) => void): void
|
|
73
|
+
|
|
74
74
|
diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
|
|
75
75
|
|
|
76
76
|
diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const { generate } = require('randomstring')
|
|
4
|
-
|
|
5
|
-
const mock = require('./connection.mock')
|
|
6
|
-
const { IO } = require('../source/io')
|
|
7
|
-
|
|
8
|
-
/** @type {comq.IO} */
|
|
9
|
-
let io
|
|
10
|
-
|
|
11
|
-
/** @type {jest.MockedObject<comq.Connection>} */
|
|
12
|
-
let connection
|
|
13
|
-
|
|
14
|
-
const queue = generate()
|
|
15
|
-
const payload = { [generate()]: generate() }
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
jest.clearAllMocks()
|
|
19
|
-
|
|
20
|
-
connection = mock.connection()
|
|
21
|
-
io = new IO(connection)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('should reject when request times out', async () => {
|
|
25
|
-
await expect(io.request(queue, payload, 50)).rejects.toMatchObject({
|
|
26
|
-
message: expect.stringContaining('timed out'),
|
|
27
|
-
code: 'ETIMEDOUT'
|
|
28
|
-
})
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('should reject when request times out with encoding', async () => {
|
|
32
|
-
await expect(io.request(queue, payload, 'application/json', 50)).rejects.toMatchObject({
|
|
33
|
-
code: 'ETIMEDOUT'
|
|
34
|
-
})
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('should time out while send is still pending', async () => {
|
|
38
|
-
connection.createChannel.mockImplementation(async () => hangingChannel())
|
|
39
|
-
|
|
40
|
-
await expect(io.request(queue, payload, 50)).rejects.toMatchObject({
|
|
41
|
-
code: 'ETIMEDOUT'
|
|
42
|
-
})
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* @returns {jest.MockedObject<comq.Channel>}
|
|
47
|
-
*/
|
|
48
|
-
function hangingChannel () {
|
|
49
|
-
return /** @type {jest.MockedObject<comq.Channel>} */ ({
|
|
50
|
-
sharded: false,
|
|
51
|
-
consume: jest.fn(async () => undefined),
|
|
52
|
-
send: jest.fn(() => new Promise(() => {})),
|
|
53
|
-
deliver: jest.fn(async () => undefined),
|
|
54
|
-
fire: jest.fn(async () => undefined),
|
|
55
|
-
subscribe: jest.fn(async () => undefined),
|
|
56
|
-
publish: jest.fn(async () => undefined),
|
|
57
|
-
diagnose: jest.fn(),
|
|
58
|
-
seal: jest.fn(async () => undefined),
|
|
59
|
-
recover: jest.fn(async () => undefined)
|
|
60
|
-
})
|
|
61
|
-
}
|