comq 0.15.8 → 0.16.1
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/.releaserc +18 -2
- package/features/channels.feature +14 -0
- package/features/connection.feature +14 -0
- package/features/silence.feature +16 -0
- package/features/steps/channels.js +45 -0
- package/features/steps/context.js +13 -3
- package/features/steps/hooks.js +1 -0
- package/package.json +6 -4
- package/readme.md +4 -0
- package/source/channel.js +21 -0
- package/source/connection.js +41 -10
- package/source/events.js +1 -1
- package/source/io.js +6 -0
- package/source/shards/channel.js +8 -0
- package/test/amqplib.mock.js +5 -1
- package/test/channel.test.js +43 -0
- package/test/connection.mock.js +2 -0
- package/test/connection.test.js +131 -1
- package/test/io.close.test.js +21 -0
- package/test/io.diagnostics.test.js +1 -1
- package/types/channel.d.ts +4 -0
- package/types/diagnostic.d.ts +2 -2
- package/types/io.d.ts +2 -0
package/.releaserc
CHANGED
|
@@ -3,8 +3,24 @@
|
|
|
3
3
|
"release"
|
|
4
4
|
],
|
|
5
5
|
"plugins": [
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
[
|
|
7
|
+
"@semantic-release/commit-analyzer",
|
|
8
|
+
{
|
|
9
|
+
"preset": "conventionalcommits",
|
|
10
|
+
"releaseRules": [
|
|
11
|
+
{
|
|
12
|
+
"breaking": true,
|
|
13
|
+
"release": "minor"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
"@semantic-release/release-notes-generator",
|
|
20
|
+
{
|
|
21
|
+
"preset": "conventionalcommits"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
8
24
|
[
|
|
9
25
|
"@semantic-release/npm"
|
|
10
26
|
],
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Feature: Channel exhaustion
|
|
2
|
+
|
|
3
|
+
A connection allocates channels from a range the broker negotiates, and once the range
|
|
4
|
+
is spent there is nothing a reconnection can do about it. The refusal is reported and
|
|
5
|
+
passed on rather than retried, so that whoever asked for a channel is told instead of
|
|
6
|
+
waiting for a recovery that is not coming.
|
|
7
|
+
|
|
8
|
+
Scenario: Reporting a connection with no channel left to allocate
|
|
9
|
+
Given the AMQP channel limit is set to 2
|
|
10
|
+
And an active connection to the broker
|
|
11
|
+
And a producer replying `echo` queue
|
|
12
|
+
When emitting an event to the `exhaustion` exchange is attempted
|
|
13
|
+
Then the exception is thrown: "No channels left to allocate"
|
|
14
|
+
And the `exhausted` event is emitted
|
|
@@ -18,6 +18,20 @@ Feature: Connection Tolerance
|
|
|
18
18
|
When the broker is up
|
|
19
19
|
Then the connection is restored
|
|
20
20
|
|
|
21
|
+
Scenario: Restoring singleton connection after broker restart
|
|
22
|
+
Given an active singleton connection
|
|
23
|
+
When the broker is down
|
|
24
|
+
Then the connection is lost
|
|
25
|
+
When the broker is up
|
|
26
|
+
Then the connection is restored
|
|
27
|
+
|
|
28
|
+
Scenario: Restoring sharded singleton connection after broker crash
|
|
29
|
+
Given an active sharded singleton connection
|
|
30
|
+
When the broker has crashed
|
|
31
|
+
Then the connection is lost
|
|
32
|
+
When the broker is up
|
|
33
|
+
Then the connection is restored
|
|
34
|
+
|
|
21
35
|
Scenario: Connecting with wrong credentials
|
|
22
36
|
When I attempt to connect to the broker as "someone" with password "whatever"
|
|
23
37
|
Then an exception is thrown: "Handshake terminated by server: 403 (ACCESS-REFUSED)"
|
package/features/silence.feature
CHANGED
|
@@ -24,6 +24,22 @@ Feature: Silent Connection Tolerance
|
|
|
24
24
|
When the consumer sends a request to the `echo` queue
|
|
25
25
|
Then the consumer receives the reply
|
|
26
26
|
|
|
27
|
+
Scenario: Restoring a singleton connection that has gone silent
|
|
28
|
+
Given an active singleton connection
|
|
29
|
+
And a producer replying `echo` queue
|
|
30
|
+
When the network goes silent
|
|
31
|
+
Then the connection is lost within 10 seconds
|
|
32
|
+
When the consumer sends a request to the `echo` queue
|
|
33
|
+
Then the consumer receives the reply
|
|
34
|
+
|
|
35
|
+
Scenario: Restoring a sharded singleton connection whose every shard has gone silent
|
|
36
|
+
Given an active sharded singleton connection
|
|
37
|
+
And a producer replying `echo` queue
|
|
38
|
+
When the network goes silent
|
|
39
|
+
Then the connection is lost within 10 seconds
|
|
40
|
+
When the consumer sends a request to the `echo` queue
|
|
41
|
+
Then the consumer receives the reply
|
|
42
|
+
|
|
27
43
|
Scenario: Answering a request sent as the connection goes silent
|
|
28
44
|
Given an active connection to the broker
|
|
29
45
|
And a producer replying `echo` queue
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const { randomBytes } = require('node:crypto')
|
|
5
|
+
|
|
6
|
+
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
7
|
+
|
|
8
|
+
Given('the AMQP channel limit is set to {number}',
|
|
9
|
+
/**
|
|
10
|
+
* @param {number} limit
|
|
11
|
+
*/
|
|
12
|
+
function (limit) {
|
|
13
|
+
global.COMQ_TESTING_AMQP_CHANNEL_MAX = limit
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
When('emitting an event to the {token} exchange is attempted',
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} exchange
|
|
19
|
+
* @this {comq.features.Context}
|
|
20
|
+
*/
|
|
21
|
+
async function (exchange) {
|
|
22
|
+
this.exception = undefined
|
|
23
|
+
|
|
24
|
+
await this.io.emit(exchange, randomBytes(8))
|
|
25
|
+
.catch((exception) => { this.exception = exception })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
Then('the exception is thrown: {string}',
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} message
|
|
31
|
+
* @this {comq.features.Context}
|
|
32
|
+
*/
|
|
33
|
+
function (message) {
|
|
34
|
+
assert.notEqual(this.exception, undefined, 'no exception was thrown')
|
|
35
|
+
assert.equal(this.exception.message, message)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
Then('the {token} event is emitted',
|
|
39
|
+
/**
|
|
40
|
+
* @param {comq.diagnostics.Event} event
|
|
41
|
+
* @this {comq.features.Context}
|
|
42
|
+
*/
|
|
43
|
+
function (event) {
|
|
44
|
+
assert.equal(this.events[event], true, `the '${event}' event was not emitted`)
|
|
45
|
+
})
|
|
@@ -100,17 +100,27 @@ class Context extends World {
|
|
|
100
100
|
#url (i, user, password) {
|
|
101
101
|
const address = this.networks[i]?.address ?? getAddress(i)
|
|
102
102
|
const url = PROTOCOL + user + ':' + password + '@' + address
|
|
103
|
-
const
|
|
103
|
+
const query = []
|
|
104
104
|
|
|
105
105
|
// the watchdog measures silence, so it may only be shortened along with the
|
|
106
106
|
// interval at which a healthy broker is expected to say something
|
|
107
|
-
|
|
107
|
+
if (global.COMQ_TESTING_AMQP_HEARTBEAT !== undefined) {
|
|
108
|
+
query.push('heartbeat=' + global.COMQ_TESTING_AMQP_HEARTBEAT)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// a broker negotiates the channel limit down to what the client asks for,
|
|
112
|
+
// which is how exhaustion is reached without opening two thousand channels
|
|
113
|
+
if (global.COMQ_TESTING_AMQP_CHANNEL_MAX !== undefined) {
|
|
114
|
+
query.push('channelMax=' + global.COMQ_TESTING_AMQP_CHANNEL_MAX)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return query.length === 0 ? url : url + '?' + query.join('&')
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
const PROTOCOL = 'amqp://'
|
|
112
122
|
|
|
113
123
|
/** @type {comq.diagnostics.Event[]} */
|
|
114
|
-
const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume']
|
|
124
|
+
const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume', 'exhausted']
|
|
115
125
|
|
|
116
126
|
exports.Context = Context
|
package/features/steps/hooks.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -33,15 +33,17 @@
|
|
|
33
33
|
"@semantic-release/github": "^12.0.2",
|
|
34
34
|
"@semantic-release/npm": "^13.1.2",
|
|
35
35
|
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
36
|
-
"@testcontainers/rabbitmq": "^12.0
|
|
36
|
+
"@testcontainers/rabbitmq": "^12.1.0",
|
|
37
37
|
"@types/amqplib": "^0.10.8",
|
|
38
38
|
"@types/jest": "^30.0.0",
|
|
39
|
+
"conventional-changelog-conventionalcommits": "^8.0.0",
|
|
39
40
|
"fast-glob": "^3.3.3",
|
|
40
|
-
"fuzzysort": "
|
|
41
|
+
"fuzzysort": "4.0.2",
|
|
41
42
|
"husky": "^9.1.7",
|
|
42
43
|
"jest": "^30.4.2",
|
|
43
|
-
"js-yaml": "^5.
|
|
44
|
+
"js-yaml": "^5.4.1",
|
|
44
45
|
"randomstring": "^1.3.1",
|
|
46
|
+
"semantic-release": "^25.0.9",
|
|
45
47
|
"snazzy": "^9.0.0",
|
|
46
48
|
"standard": "^17.1.2",
|
|
47
49
|
"testcontainers": "^12.0.4"
|
package/readme.md
CHANGED
|
@@ -498,6 +498,9 @@ Subscribe to one of the diagnostic events:
|
|
|
498
498
|
- `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
|
|
499
499
|
Attempts continue until one succeeds.
|
|
500
500
|
- `reconnect`: an attempt to restore the connection has started. Followed by `open` or `error`.
|
|
501
|
+
- `exhausted`: the connection has no channel left to allocate. The negotiated channel limit is
|
|
502
|
+
passed as an argument. Reconnecting does not free a channel, so the operation that asked for
|
|
503
|
+
one is rejected rather than retried.
|
|
501
504
|
- `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
|
|
502
505
|
an argument.
|
|
503
506
|
- `drain`: back pressure is removed from a channel. Channel type is passed.
|
|
@@ -533,6 +536,7 @@ established, there is no way to capture the initial `open` event.
|
|
|
533
536
|
io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
|
|
534
537
|
|
|
535
538
|
io.diagnose('reconnect', (shard) => console.log('AMQP reconnecting', { shard }))
|
|
539
|
+
io.diagnose('exhausted', (limit, shard) => console.log('AMQP channels exhausted', { limit, shard }))
|
|
536
540
|
io.diagnose('open', (shard) => console.log('AMQP connection established', { shard }))
|
|
537
541
|
io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { message: error?.message, shard }))
|
|
538
542
|
io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
|
package/source/channel.js
CHANGED
|
@@ -42,6 +42,8 @@ class Channel {
|
|
|
42
42
|
|
|
43
43
|
#diagnostics = emitter.create()
|
|
44
44
|
|
|
45
|
+
#closed = false
|
|
46
|
+
|
|
45
47
|
/**
|
|
46
48
|
* @param {comq.amqp.Connection} connection
|
|
47
49
|
* @param {comq.Topology} topology
|
|
@@ -126,6 +128,21 @@ class Channel {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Gives the channel back. A connection has a limited number of them, and one whose
|
|
133
|
+
* IO is done with it would otherwise be held until the whole connection goes.
|
|
134
|
+
*/
|
|
135
|
+
async close () {
|
|
136
|
+
if (this.#closed) return
|
|
137
|
+
|
|
138
|
+
this.#closed = true
|
|
139
|
+
|
|
140
|
+
await this.seal()
|
|
141
|
+
|
|
142
|
+
// a channel that went down with its connection is the outcome this asks for
|
|
143
|
+
await this.#channel?.close().catch(noop)
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
async seal () {
|
|
130
147
|
this.#sealed = true
|
|
131
148
|
|
|
@@ -142,6 +159,10 @@ class Channel {
|
|
|
142
159
|
this.#diagnostics.off(event, listener)
|
|
143
160
|
}
|
|
144
161
|
|
|
162
|
+
get closed () {
|
|
163
|
+
return this.#closed
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
async recover (connection) {
|
|
146
167
|
this.#connection = connection
|
|
147
168
|
|
package/source/connection.js
CHANGED
|
@@ -64,17 +64,10 @@ class Connection {
|
|
|
64
64
|
async open () {
|
|
65
65
|
this.#closed = false
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.#opening = retry(this.#open).finally(() => { this.#opening = null })
|
|
70
|
-
|
|
71
|
-
await this.#opening
|
|
67
|
+
await this.#reopen()
|
|
72
68
|
|
|
73
69
|
if (this.#closed) return
|
|
74
70
|
|
|
75
|
-
// close may have landed after this attempt succeeded but before `#opening` was cleared
|
|
76
|
-
if (this.#connection === undefined) return this.open()
|
|
77
|
-
|
|
78
71
|
this.#running = true
|
|
79
72
|
}
|
|
80
73
|
|
|
@@ -99,6 +92,7 @@ class Connection {
|
|
|
99
92
|
const topology = presets[type]
|
|
100
93
|
const channel = await channels.create(this.#connection, topology, index)
|
|
101
94
|
|
|
95
|
+
this.#channels = this.#channels.filter((channel) => !channel.closed)
|
|
102
96
|
this.#channels.push(channel)
|
|
103
97
|
|
|
104
98
|
return channel
|
|
@@ -140,6 +134,8 @@ class Connection {
|
|
|
140
134
|
this.#diagnostics.emit('open')
|
|
141
135
|
|
|
142
136
|
try {
|
|
137
|
+
this.#channels = this.#channels.filter((channel) => !channel.closed)
|
|
138
|
+
|
|
143
139
|
for (const channel of this.#channels) await channel.recover(connection)
|
|
144
140
|
} catch (exception) {
|
|
145
141
|
this.#diagnostics.emit('error', exception)
|
|
@@ -169,10 +165,29 @@ class Connection {
|
|
|
169
165
|
this.#connection = undefined
|
|
170
166
|
|
|
171
167
|
if (error !== undefined && !this.#closed) {
|
|
172
|
-
this
|
|
168
|
+
this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
173
169
|
}
|
|
174
170
|
}
|
|
175
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Establishes the connection, or joins the attempt under way. A lost connection is
|
|
174
|
+
* restored through here and not through `open()`: what a subclass makes of `open()` is
|
|
175
|
+
* its own — the singleton answers it with the first opening, made once and remembered,
|
|
176
|
+
* which is no way to make a second one.
|
|
177
|
+
*
|
|
178
|
+
* @return {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
async #reopen () {
|
|
181
|
+
this.#opening ??= retry(this.#open).finally(() => { this.#opening = null })
|
|
182
|
+
|
|
183
|
+
await this.#opening
|
|
184
|
+
|
|
185
|
+
if (this.#closed) return
|
|
186
|
+
|
|
187
|
+
// close may have landed after this attempt succeeded but before `#opening` was cleared
|
|
188
|
+
if (this.#connection === undefined) return this.#reopen()
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
/**
|
|
177
192
|
* An AMQP connection is only closed once the broker has replied with Close-Ok,
|
|
178
193
|
* which never happens on a connection that has already been lost.
|
|
@@ -207,7 +222,20 @@ class Connection {
|
|
|
207
222
|
connection.connection?.stream?.destroy(silence())
|
|
208
223
|
}
|
|
209
224
|
|
|
210
|
-
|
|
225
|
+
/**
|
|
226
|
+
* @param {Error} [exception]
|
|
227
|
+
* @return {Promise<void> | false}
|
|
228
|
+
*/
|
|
229
|
+
#recover (exception) {
|
|
230
|
+
// a connection that has no channel left to give is not something reconnecting
|
|
231
|
+
// fixes, and `#recovery` on a connection that is perfectly well is a promise
|
|
232
|
+
// nobody ever resolves — returning `false` lets the caller see the refusal
|
|
233
|
+
if (exception?.message === EXHAUSTED) {
|
|
234
|
+
this.#diagnostics.emit('exhausted', this.#connection?.connection?.channelMax)
|
|
235
|
+
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
211
239
|
return this.#recovery
|
|
212
240
|
}
|
|
213
241
|
|
|
@@ -333,6 +361,9 @@ const SOCKET_OPTIONS = {
|
|
|
333
361
|
|
|
334
362
|
const HEARTBEAT_SET = /[?&]heartbeat=/
|
|
335
363
|
|
|
364
|
+
/** What amqplib says when the negotiated channel limit leaves no identifier free. */
|
|
365
|
+
const EXHAUSTED = 'No channels left to allocate'
|
|
366
|
+
|
|
336
367
|
const TRANSIENT_CODES = new Set([
|
|
337
368
|
'ECONNREFUSED',
|
|
338
369
|
'EAI_AGAIN',
|
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', 'reconnect']
|
|
4
|
+
exports.connection = ['open', 'close', 'error', 'reconnect', 'exhausted']
|
|
5
5
|
|
|
6
6
|
/** @type {comq.diagnostics.Event[]} */
|
|
7
7
|
exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
|
package/source/io.js
CHANGED
|
@@ -163,6 +163,12 @@ class IO {
|
|
|
163
163
|
await this.seal()
|
|
164
164
|
await this.#destroyStreams(this.#replyPipes)
|
|
165
165
|
await track(this)
|
|
166
|
+
|
|
167
|
+
// a connection is shared and outlives its IOs, so the channels are given back
|
|
168
|
+
// here — held to the end of the connection, they would run it out of them
|
|
169
|
+
await Promise.all([this.#requests, this.#replies, this.#events]
|
|
170
|
+
.map((channel) => channel?.close()))
|
|
171
|
+
|
|
166
172
|
await this.#connection.close()
|
|
167
173
|
})
|
|
168
174
|
|
package/source/shards/channel.js
CHANGED
|
@@ -77,6 +77,14 @@ class Channel {
|
|
|
77
77
|
return await this.#one((channel) => channel.fire(queue, buffer, options))
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
async close () {
|
|
81
|
+
await this.#all((channel) => channel.close())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get closed () {
|
|
85
|
+
return [...this.#channels].every((channel) => channel.closed)
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
async seal () {
|
|
81
89
|
await this.#all((channel) => channel.seal())
|
|
82
90
|
}
|
package/test/amqplib.mock.js
CHANGED
|
@@ -15,6 +15,7 @@ class Channel extends EventEmitter {
|
|
|
15
15
|
assertExchange = jest.fn(async () => undefined)
|
|
16
16
|
bindQueue = jest.fn(async () => undefined)
|
|
17
17
|
publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
|
|
18
|
+
close = jest.fn(async () => undefined)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
class Connection extends EventEmitter {
|
|
@@ -37,7 +38,10 @@ class Connection extends EventEmitter {
|
|
|
37
38
|
// a broker accepts the heartbeat the client asks for
|
|
38
39
|
const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
// and negotiates the channel limit down to whatever the client asked for
|
|
42
|
+
const channelMax = Number(/[?&]channelMax=(\d+)/.exec(url)?.[1] ?? 2047)
|
|
43
|
+
|
|
44
|
+
this.connection = { stream, heartbeat, channelMax }
|
|
41
45
|
|
|
42
46
|
// noinspection JSValidateTypes
|
|
43
47
|
this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
|
package/test/channel.test.js
CHANGED
|
@@ -503,6 +503,49 @@ describe('seal', () => {
|
|
|
503
503
|
})
|
|
504
504
|
})
|
|
505
505
|
|
|
506
|
+
describe('close', () => {
|
|
507
|
+
const queue = generate()
|
|
508
|
+
const consumer = jest.fn()
|
|
509
|
+
|
|
510
|
+
beforeEach(async () => {
|
|
511
|
+
channel = await create(connection, topology)
|
|
512
|
+
chan = await getCreatedChannel()
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('should give the channel back', async () => {
|
|
516
|
+
await channel.close()
|
|
517
|
+
|
|
518
|
+
expect(chan.close).toHaveBeenCalled()
|
|
519
|
+
expect(channel.closed).toStrictEqual(true)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('should stop consuming before giving it back', async () => {
|
|
523
|
+
await channel.consume(queue, consumer)
|
|
524
|
+
|
|
525
|
+
const { consumerTag: tag } = await chan.consume.mock.results[0].value
|
|
526
|
+
|
|
527
|
+
await channel.close()
|
|
528
|
+
|
|
529
|
+
expect(chan.cancel).toHaveBeenCalledWith(tag)
|
|
530
|
+
expect(chan.cancel.mock.invocationCallOrder[0])
|
|
531
|
+
.toBeLessThan(chan.close.mock.invocationCallOrder[0])
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('should give it back once', async () => {
|
|
535
|
+
await channel.close()
|
|
536
|
+
await channel.close()
|
|
537
|
+
|
|
538
|
+
expect(chan.close).toHaveBeenCalledTimes(1)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// one that went down with its connection is the outcome this asks for
|
|
542
|
+
it('should ignore one that is already gone', async () => {
|
|
543
|
+
chan.close.mockImplementationOnce(async () => { throw new Error() })
|
|
544
|
+
|
|
545
|
+
await expect(channel.close()).resolves.not.toThrow()
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
|
|
506
549
|
describe('back pressure', () => {
|
|
507
550
|
const exchange = generate()
|
|
508
551
|
const queue = generate()
|
package/test/connection.mock.js
CHANGED
|
@@ -12,6 +12,8 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
|
|
|
12
12
|
publish: jest.fn(async () => undefined),
|
|
13
13
|
diagnose: jest.fn(async () => undefined),
|
|
14
14
|
seal: jest.fn(async () => undefined),
|
|
15
|
+
close: jest.fn(async () => undefined),
|
|
16
|
+
closed: false,
|
|
15
17
|
recover: jest.fn(async () => undefined)
|
|
16
18
|
}))
|
|
17
19
|
|
package/test/connection.test.js
CHANGED
|
@@ -184,7 +184,7 @@ describe('reconnection', () => {
|
|
|
184
184
|
|
|
185
185
|
const boom = new Error('reconnect failed')
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
amqplib.connect.mockRejectedValueOnce(boom)
|
|
188
188
|
|
|
189
189
|
conn.emit('close', new Error('broker down'))
|
|
190
190
|
|
|
@@ -194,8 +194,38 @@ describe('reconnection', () => {
|
|
|
194
194
|
|
|
195
195
|
expect(errors).toContain(boom)
|
|
196
196
|
expect(unhandled).not.toHaveBeenCalled()
|
|
197
|
+
|
|
198
|
+
// the attempt is retried in the background, and would connect in a later case
|
|
199
|
+
await connection.close()
|
|
197
200
|
})
|
|
198
201
|
|
|
202
|
+
// the singleton answers `open()` with the first opening, made once and remembered
|
|
203
|
+
it('should reconnect a connection whose open() is remembered', async () => {
|
|
204
|
+
class Remembered extends Connection {
|
|
205
|
+
#opened = null
|
|
206
|
+
|
|
207
|
+
async open () {
|
|
208
|
+
this.#opened ??= super.open()
|
|
209
|
+
|
|
210
|
+
await this.#opened
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const remembered = new Remembered(generate())
|
|
215
|
+
|
|
216
|
+
await remembered.open()
|
|
217
|
+
|
|
218
|
+
const first = await amqplib.connect.mock.results.at(-1).value
|
|
219
|
+
|
|
220
|
+
first.emit('close', new Error('lost'))
|
|
221
|
+
|
|
222
|
+
await expect(remembered.createChannel('event')).resolves.toBeDefined()
|
|
223
|
+
|
|
224
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(3)
|
|
225
|
+
|
|
226
|
+
await remembered.close()
|
|
227
|
+
}, 10000)
|
|
228
|
+
|
|
199
229
|
it('should reconnect when connection is lost while channels recover', async () => {
|
|
200
230
|
const channel = await connection.createChannel('request')
|
|
201
231
|
|
|
@@ -398,6 +428,106 @@ describe('create channel', () => {
|
|
|
398
428
|
})
|
|
399
429
|
})
|
|
400
430
|
|
|
431
|
+
describe('closed channels', () => {
|
|
432
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
433
|
+
let conn
|
|
434
|
+
|
|
435
|
+
beforeEach(async () => {
|
|
436
|
+
// an earlier case leaves an implementation of its own behind
|
|
437
|
+
create.mockImplementation(async () => ({ recover: jest.fn(async () => undefined), closed: false }))
|
|
438
|
+
|
|
439
|
+
await connection.open()
|
|
440
|
+
|
|
441
|
+
conn = await amqplib.connect.mock.results[0].value
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
afterEach(async () => {
|
|
445
|
+
await connection.close()
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// a channel given back is not one this connection has any more
|
|
449
|
+
it('should not recover one that was given back', async () => {
|
|
450
|
+
const channel = await connection.createChannel('event')
|
|
451
|
+
|
|
452
|
+
channel.closed = true
|
|
453
|
+
|
|
454
|
+
conn.emit('close', new Error())
|
|
455
|
+
|
|
456
|
+
await timeout(50)
|
|
457
|
+
|
|
458
|
+
expect(channel.recover).not.toHaveBeenCalled()
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('should recover one that is still in use', async () => {
|
|
462
|
+
const channel = await connection.createChannel('event')
|
|
463
|
+
|
|
464
|
+
conn.emit('close', new Error())
|
|
465
|
+
|
|
466
|
+
await timeout(50)
|
|
467
|
+
|
|
468
|
+
expect(channel.recover).toHaveBeenCalled()
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
describe('channel exhaustion', () => {
|
|
473
|
+
const EXHAUSTED = 'No channels left to allocate'
|
|
474
|
+
|
|
475
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
476
|
+
let conn
|
|
477
|
+
|
|
478
|
+
beforeEach(async () => {
|
|
479
|
+
await connection.open()
|
|
480
|
+
|
|
481
|
+
conn = await amqplib.connect.mock.results[0].value
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('should reject rather than wait for a recovery that is not coming', async () => {
|
|
485
|
+
create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
|
|
486
|
+
|
|
487
|
+
// the regression is a hang, so the assertion is that it settles at all
|
|
488
|
+
const settled = await Promise.race([
|
|
489
|
+
connection.createChannel('event').then(() => 'resolved', (e) => e.message),
|
|
490
|
+
timeout(100).then(() => 'pending')
|
|
491
|
+
])
|
|
492
|
+
|
|
493
|
+
expect(settled).toStrictEqual(EXHAUSTED)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('should not retry', async () => {
|
|
497
|
+
create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
|
|
498
|
+
|
|
499
|
+
await connection.createChannel('event').catch(() => undefined)
|
|
500
|
+
|
|
501
|
+
expect(create).toHaveBeenCalledTimes(1)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('should report the limit the broker negotiated', async () => {
|
|
505
|
+
const listener = jest.fn()
|
|
506
|
+
|
|
507
|
+
await connection.diagnose('exhausted', listener)
|
|
508
|
+
|
|
509
|
+
create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
|
|
510
|
+
|
|
511
|
+
await connection.createChannel('event').catch(() => undefined)
|
|
512
|
+
|
|
513
|
+
expect(listener).toHaveBeenCalledWith(conn.connection.channelMax)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('should leave every other failure its recovery', async () => {
|
|
517
|
+
create.mockImplementation(async () => { throw new Error() })
|
|
518
|
+
|
|
519
|
+
setTimeout(() => {
|
|
520
|
+
create.mockImplementation(async () => generate())
|
|
521
|
+
|
|
522
|
+
conn.emit('close', new Error())
|
|
523
|
+
}, 1)
|
|
524
|
+
|
|
525
|
+
const channel = await connection.createChannel('request')
|
|
526
|
+
|
|
527
|
+
expect(channel).toStrictEqual(await create.mock.results[1].value)
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
401
531
|
describe('watchdog', () => {
|
|
402
532
|
afterEach(() => {
|
|
403
533
|
jest.useRealTimers()
|
package/test/io.close.test.js
CHANGED
|
@@ -93,6 +93,27 @@ describe('close', () => {
|
|
|
93
93
|
await expect(io.close()).resolves.not.toThrow()
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
+
// a connection is shared and outlives its IOs, and it has a limited number of channels
|
|
97
|
+
it('should give the channels back', async () => {
|
|
98
|
+
await reply()
|
|
99
|
+
await io.close()
|
|
100
|
+
|
|
101
|
+
expect(requests.close).toHaveBeenCalled()
|
|
102
|
+
expect(events.close).toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should give them back before letting go of the connection', async () => {
|
|
106
|
+
await reply()
|
|
107
|
+
|
|
108
|
+
connection.close.mockImplementation(async () => {
|
|
109
|
+
expect(requests.close).toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
await io.close()
|
|
113
|
+
|
|
114
|
+
expect.assertions(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
96
117
|
it('should wait for event processing completion', async () => {
|
|
97
118
|
const promise = /** @type {Promise<void>} */ new Promex()
|
|
98
119
|
const queue = generate()
|
|
@@ -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', 'error', 'reconnect'])('should re-emit %s from connection',
|
|
64
|
+
it.each(['open', 'close', 'error', 'reconnect', 'exhausted'])('should re-emit %s from connection',
|
|
65
65
|
/**
|
|
66
66
|
* @param {comq.diagnostics.Event} event
|
|
67
67
|
*/
|
package/types/channel.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' | 'reconnect' | '
|
|
4
|
-
'recover' | 'discard' | 'pause' | 'resume' | 'return'
|
|
3
|
+
type Event = 'open' | 'close' | 'error' | 'reconnect' | 'exhausted' | 'flow' | 'drain' |
|
|
4
|
+
'remove' | 'lost' | '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
|
@@ -65,6 +65,8 @@ declare namespace comq {
|
|
|
65
65
|
|
|
66
66
|
diagnose(event: 'reconnect', listener: (index?: number) => void): void
|
|
67
67
|
|
|
68
|
+
diagnose(event: 'exhausted', listener: (limit?: number, index?: number) => void): void
|
|
69
|
+
|
|
68
70
|
diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
|
|
69
71
|
|
|
70
72
|
diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void
|