comq 0.15.8 → 0.16.0
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/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 +20 -1
- 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 +100 -0
- 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
|
|
@@ -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.0",
|
|
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
|
@@ -99,6 +99,7 @@ class Connection {
|
|
|
99
99
|
const topology = presets[type]
|
|
100
100
|
const channel = await channels.create(this.#connection, topology, index)
|
|
101
101
|
|
|
102
|
+
this.#channels = this.#channels.filter((channel) => !channel.closed)
|
|
102
103
|
this.#channels.push(channel)
|
|
103
104
|
|
|
104
105
|
return channel
|
|
@@ -140,6 +141,8 @@ class Connection {
|
|
|
140
141
|
this.#diagnostics.emit('open')
|
|
141
142
|
|
|
142
143
|
try {
|
|
144
|
+
this.#channels = this.#channels.filter((channel) => !channel.closed)
|
|
145
|
+
|
|
143
146
|
for (const channel of this.#channels) await channel.recover(connection)
|
|
144
147
|
} catch (exception) {
|
|
145
148
|
this.#diagnostics.emit('error', exception)
|
|
@@ -207,7 +210,20 @@ class Connection {
|
|
|
207
210
|
connection.connection?.stream?.destroy(silence())
|
|
208
211
|
}
|
|
209
212
|
|
|
210
|
-
|
|
213
|
+
/**
|
|
214
|
+
* @param {Error} [exception]
|
|
215
|
+
* @return {Promise<void> | false}
|
|
216
|
+
*/
|
|
217
|
+
#recover (exception) {
|
|
218
|
+
// a connection that has no channel left to give is not something reconnecting
|
|
219
|
+
// fixes, and `#recovery` on a connection that is perfectly well is a promise
|
|
220
|
+
// nobody ever resolves — returning `false` lets the caller see the refusal
|
|
221
|
+
if (exception?.message === EXHAUSTED) {
|
|
222
|
+
this.#diagnostics.emit('exhausted', this.#connection?.connection?.channelMax)
|
|
223
|
+
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
|
|
211
227
|
return this.#recovery
|
|
212
228
|
}
|
|
213
229
|
|
|
@@ -333,6 +349,9 @@ const SOCKET_OPTIONS = {
|
|
|
333
349
|
|
|
334
350
|
const HEARTBEAT_SET = /[?&]heartbeat=/
|
|
335
351
|
|
|
352
|
+
/** What amqplib says when the negotiated channel limit leaves no identifier free. */
|
|
353
|
+
const EXHAUSTED = 'No channels left to allocate'
|
|
354
|
+
|
|
336
355
|
const TRANSIENT_CODES = new Set([
|
|
337
356
|
'ECONNREFUSED',
|
|
338
357
|
'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
|
@@ -398,6 +398,106 @@ describe('create channel', () => {
|
|
|
398
398
|
})
|
|
399
399
|
})
|
|
400
400
|
|
|
401
|
+
describe('closed channels', () => {
|
|
402
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
403
|
+
let conn
|
|
404
|
+
|
|
405
|
+
beforeEach(async () => {
|
|
406
|
+
// an earlier case leaves an implementation of its own behind
|
|
407
|
+
create.mockImplementation(async () => ({ recover: jest.fn(async () => undefined), closed: false }))
|
|
408
|
+
|
|
409
|
+
await connection.open()
|
|
410
|
+
|
|
411
|
+
conn = await amqplib.connect.mock.results[0].value
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
afterEach(async () => {
|
|
415
|
+
await connection.close()
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// a channel given back is not one this connection has any more
|
|
419
|
+
it('should not recover one that was given back', async () => {
|
|
420
|
+
const channel = await connection.createChannel('event')
|
|
421
|
+
|
|
422
|
+
channel.closed = true
|
|
423
|
+
|
|
424
|
+
conn.emit('close', new Error())
|
|
425
|
+
|
|
426
|
+
await timeout(50)
|
|
427
|
+
|
|
428
|
+
expect(channel.recover).not.toHaveBeenCalled()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('should recover one that is still in use', async () => {
|
|
432
|
+
const channel = await connection.createChannel('event')
|
|
433
|
+
|
|
434
|
+
conn.emit('close', new Error())
|
|
435
|
+
|
|
436
|
+
await timeout(50)
|
|
437
|
+
|
|
438
|
+
expect(channel.recover).toHaveBeenCalled()
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
describe('channel exhaustion', () => {
|
|
443
|
+
const EXHAUSTED = 'No channels left to allocate'
|
|
444
|
+
|
|
445
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
446
|
+
let conn
|
|
447
|
+
|
|
448
|
+
beforeEach(async () => {
|
|
449
|
+
await connection.open()
|
|
450
|
+
|
|
451
|
+
conn = await amqplib.connect.mock.results[0].value
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('should reject rather than wait for a recovery that is not coming', async () => {
|
|
455
|
+
create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
|
|
456
|
+
|
|
457
|
+
// the regression is a hang, so the assertion is that it settles at all
|
|
458
|
+
const settled = await Promise.race([
|
|
459
|
+
connection.createChannel('event').then(() => 'resolved', (e) => e.message),
|
|
460
|
+
timeout(100).then(() => 'pending')
|
|
461
|
+
])
|
|
462
|
+
|
|
463
|
+
expect(settled).toStrictEqual(EXHAUSTED)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('should not retry', async () => {
|
|
467
|
+
create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
|
|
468
|
+
|
|
469
|
+
await connection.createChannel('event').catch(() => undefined)
|
|
470
|
+
|
|
471
|
+
expect(create).toHaveBeenCalledTimes(1)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('should report the limit the broker negotiated', async () => {
|
|
475
|
+
const listener = jest.fn()
|
|
476
|
+
|
|
477
|
+
await connection.diagnose('exhausted', listener)
|
|
478
|
+
|
|
479
|
+
create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
|
|
480
|
+
|
|
481
|
+
await connection.createChannel('event').catch(() => undefined)
|
|
482
|
+
|
|
483
|
+
expect(listener).toHaveBeenCalledWith(conn.connection.channelMax)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('should leave every other failure its recovery', async () => {
|
|
487
|
+
create.mockImplementation(async () => { throw new Error() })
|
|
488
|
+
|
|
489
|
+
setTimeout(() => {
|
|
490
|
+
create.mockImplementation(async () => generate())
|
|
491
|
+
|
|
492
|
+
conn.emit('close', new Error())
|
|
493
|
+
}, 1)
|
|
494
|
+
|
|
495
|
+
const channel = await connection.createChannel('request')
|
|
496
|
+
|
|
497
|
+
expect(channel).toStrictEqual(await create.mock.results[1].value)
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
401
501
|
describe('watchdog', () => {
|
|
402
502
|
afterEach(() => {
|
|
403
503
|
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
|