comq 0.15.7 → 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 +15 -6
- package/features/channels.feature +14 -0
- package/features/recovery.feature +30 -0
- package/features/silence.feature +46 -0
- package/features/steps/.types/context.d.ts +17 -0
- package/features/steps/backpressure.js +1 -1
- package/features/steps/broker.js +1 -1
- package/features/steps/brokers.js +1 -1
- package/features/steps/channels.js +45 -0
- package/features/steps/common.js +1 -1
- package/features/steps/connection.js +41 -1
- package/features/steps/context.js +24 -4
- package/features/steps/events.js +1 -1
- package/features/steps/hooks.js +2 -0
- package/features/steps/network.js +50 -0
- package/features/steps/networks.js +90 -0
- package/features/steps/parameters.js +6 -0
- package/features/steps/poison.js +1 -1
- package/features/steps/rpc.js +1 -1
- package/features/steps/tasks.js +1 -1
- package/package.json +8 -9
- package/readme.md +17 -2
- package/source/attributes/lazy.test.js +1 -1
- package/source/attributes/track.test.js +3 -3
- package/source/channel.js +21 -0
- package/source/connection.js +112 -12
- package/source/emitter.js +23 -1
- package/source/encoders/index.js +0 -2
- package/source/events.js +2 -2
- package/source/io.js +12 -5
- package/source/shards/channel.js +28 -7
- package/source/shards/connection.js +0 -5
- package/test/amqplib.mock.js +20 -4
- package/test/channel.fixtures.js +1 -1
- package/test/channel.test.js +44 -1
- package/test/connection.mock.js +2 -0
- package/test/connection.test.js +226 -6
- package/test/decode.test.js +0 -13
- package/test/emitter.test.js +71 -0
- package/test/encode.test.js +0 -9
- package/test/encodings.js +1 -1
- package/test/helpers.js +77 -0
- package/test/io.close.test.js +25 -3
- package/test/io.diagnostics.test.js +2 -2
- package/test/io.reply.test.js +2 -2
- package/test/io.request.test.js +16 -2
- package/test/pipeline.test.js +1 -1
- package/test/shards/channel.test.js +63 -9
- package/test/shards/connection.test.js +4 -3
- package/types/channel.d.ts +4 -0
- package/types/diagnostic.d.ts +2 -2
- package/types/encoding.d.ts +1 -2
- package/types/io.d.ts +2 -0
- package/AGENTS.md +0 -35
- package/CHANGELOG.md +0 -85
- package/source/encoders/msgpack.js +0 -6
package/readme.md
CHANGED
|
@@ -299,7 +299,6 @@ If the specified encoding format is not supported, an exception will be thrown.
|
|
|
299
299
|
The following encoding formats are supported:
|
|
300
300
|
|
|
301
301
|
- `application/json`
|
|
302
|
-
- `application/msgpack`
|
|
303
302
|
- `application/octet-stream`
|
|
304
303
|
- `text/plain`
|
|
305
304
|
|
|
@@ -314,6 +313,12 @@ the connection is restored.
|
|
|
314
313
|
|
|
315
314
|
When the established connection is lost, it will be automatically restored.
|
|
316
315
|
Reconnection attempts will be made indefinitely, with intervals increasing up to 30 seconds.
|
|
316
|
+
Unless the URL sets one, a 15 second heartbeat is requested, so that a connection that is gone
|
|
317
|
+
without a word, such as after a machine wakes from sleep, is noticed within a minute instead of
|
|
318
|
+
being left to whatever the broker suggests.
|
|
319
|
+
A connection that stays silent for three heartbeats is destroyed regardless of what the broker and
|
|
320
|
+
the operating system have reported, since neither is guaranteed to report anything at all.
|
|
321
|
+
Requesting `heartbeat=0` disables both.
|
|
317
322
|
If the broker rejects the connection, for example, due to access being denied, an exception will be thrown.
|
|
318
323
|
Once reconnected, the topology will be recovered, and any unanswered requests and unconfirmed events will be
|
|
319
324
|
retransmitted.
|
|
@@ -493,10 +498,13 @@ Subscribe to one of the diagnostic events:
|
|
|
493
498
|
- `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
|
|
494
499
|
Attempts continue until one succeeds.
|
|
495
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.
|
|
496
504
|
- `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
|
|
497
505
|
an argument.
|
|
498
506
|
- `drain`: back pressure is removed from a channel. Channel type is passed.
|
|
499
|
-
- `remove`: channel is removed from the [pool](#sharded-connection).
|
|
507
|
+
- `remove`: channel is removed from the [pool](#sharded-connection), having failed to publish.
|
|
500
508
|
- `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
|
|
501
509
|
re-sent. Channel type is passed.
|
|
502
510
|
- `recover`: channel's topology is recovered. Channel type is passed.
|
|
@@ -504,7 +512,13 @@ Subscribe to one of the diagnostic events:
|
|
|
504
512
|
exceptions. Channel type,
|
|
505
513
|
raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
|
|
506
514
|
and the exception are passed as arguments.
|
|
515
|
+
- `return`: message is returned by the broker as unroutable. Channel type and the raw
|
|
516
|
+
[amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish) are
|
|
517
|
+
passed as arguments. In the case of a [sharded connection](#sharded-connection), the message is
|
|
518
|
+
reported only once every shard has rejected it.
|
|
507
519
|
- `pause`: channel is paused. Channel type is passed.
|
|
520
|
+
In the case of a [sharded connection](#sharded-connection), it means that there is no shard left
|
|
521
|
+
to publish to, be it because every one of them has rejected a publish or lost its connection.
|
|
508
522
|
- `resume`: channel is resumed. Channel type is passed.
|
|
509
523
|
|
|
510
524
|
In the case of a [sharded connection](#sharded-connection), an additional argument specifying the
|
|
@@ -522,6 +536,7 @@ established, there is no way to capture the initial `open` event.
|
|
|
522
536
|
io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
|
|
523
537
|
|
|
524
538
|
io.diagnose('reconnect', (shard) => console.log('AMQP reconnecting', { shard }))
|
|
539
|
+
io.diagnose('exhausted', (limit, shard) => console.log('AMQP channels exhausted', { limit, shard }))
|
|
525
540
|
io.diagnose('open', (shard) => console.log('AMQP connection established', { shard }))
|
|
526
541
|
io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { message: error?.message, shard }))
|
|
527
542
|
io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
|
-
const {
|
|
4
|
+
const { Promex } = require('promex')
|
|
5
5
|
|
|
6
6
|
const { track } = require('./track')
|
|
7
7
|
|
|
@@ -35,8 +35,8 @@ let test
|
|
|
35
35
|
beforeEach(() => {
|
|
36
36
|
jest.clearAllMocks()
|
|
37
37
|
|
|
38
|
-
done =
|
|
39
|
-
undone =
|
|
38
|
+
done = new Promex()
|
|
39
|
+
undone = new Promex()
|
|
40
40
|
test = new Test()
|
|
41
41
|
})
|
|
42
42
|
|
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
|
@@ -50,10 +50,7 @@ class Connection {
|
|
|
50
50
|
* @param {string} url
|
|
51
51
|
*/
|
|
52
52
|
constructor (url) {
|
|
53
|
-
this.#url = url
|
|
54
|
-
|
|
55
|
-
// EventEmitter throws on 'error' with no listeners
|
|
56
|
-
this.#diagnostics.on('error', noop)
|
|
53
|
+
this.#url = heartbeaten(url)
|
|
57
54
|
}
|
|
58
55
|
|
|
59
56
|
get connected () {
|
|
@@ -102,6 +99,7 @@ class Connection {
|
|
|
102
99
|
const topology = presets[type]
|
|
103
100
|
const channel = await channels.create(this.#connection, topology, index)
|
|
104
101
|
|
|
102
|
+
this.#channels = this.#channels.filter((channel) => !channel.closed)
|
|
105
103
|
this.#channels.push(channel)
|
|
106
104
|
|
|
107
105
|
return channel
|
|
@@ -143,6 +141,8 @@ class Connection {
|
|
|
143
141
|
this.#diagnostics.emit('open')
|
|
144
142
|
|
|
145
143
|
try {
|
|
144
|
+
this.#channels = this.#channels.filter((channel) => !channel.closed)
|
|
145
|
+
|
|
146
146
|
for (const channel of this.#channels) await channel.recover(connection)
|
|
147
147
|
} catch (exception) {
|
|
148
148
|
this.#diagnostics.emit('error', exception)
|
|
@@ -200,10 +200,30 @@ class Connection {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
connection.removeAllListeners()
|
|
203
|
-
|
|
203
|
+
|
|
204
|
+
// amqplib keeps its heartbeater running on a connection it does not know is
|
|
205
|
+
// gone and emits 'error' on it, which throws once no listener is left
|
|
206
|
+
connection.on('error', noop)
|
|
207
|
+
|
|
208
|
+
// a socket destroyed without an error tells amqplib nothing, leaving its
|
|
209
|
+
// timers running and everything pending on it hanging forever
|
|
210
|
+
connection.connection?.stream?.destroy(silence())
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
|
|
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
|
+
|
|
207
227
|
return this.#recovery
|
|
208
228
|
}
|
|
209
229
|
|
|
@@ -215,7 +235,7 @@ class Connection {
|
|
|
215
235
|
* @return {Promise<comq.amqp.Connection>}
|
|
216
236
|
*/
|
|
217
237
|
async #connect () {
|
|
218
|
-
const connecting = amqp.connect(this.#url,
|
|
238
|
+
const connecting = amqp.connect(this.#url, SOCKET_OPTIONS)
|
|
219
239
|
|
|
220
240
|
let timer
|
|
221
241
|
|
|
@@ -248,19 +268,23 @@ class Connection {
|
|
|
248
268
|
* @param {comq.amqp.Connection} connection
|
|
249
269
|
* @param {number} [timeoutMs]
|
|
250
270
|
*/
|
|
251
|
-
#armWatchdog (connection, timeoutMs =
|
|
271
|
+
#armWatchdog (connection, timeoutMs = tolerance(connection)) {
|
|
272
|
+
this.#disarmWatchdog()
|
|
273
|
+
|
|
252
274
|
const socket = connection.connection?.stream
|
|
253
275
|
|
|
254
|
-
if (socket === undefined) return
|
|
276
|
+
if (socket === undefined || timeoutMs === undefined) return
|
|
255
277
|
|
|
256
278
|
const reset = () => {
|
|
257
279
|
clearTimeout(this.#heartbeatTimer)
|
|
258
|
-
|
|
280
|
+
// destroying a socket without an error tells amqplib nothing: it only
|
|
281
|
+
// listens for 'error' and 'end', so a bare destroy() leaves the connection
|
|
282
|
+
// silently dead — no 'close' event, no recovery, every pending operation
|
|
283
|
+
// hanging forever
|
|
284
|
+
this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
|
|
259
285
|
this.#heartbeatTimer.unref()
|
|
260
286
|
}
|
|
261
287
|
|
|
262
|
-
this.#disarmWatchdog()
|
|
263
|
-
|
|
264
288
|
this.#heartbeatSocket = socket
|
|
265
289
|
this.#heartbeatReset = reset
|
|
266
290
|
|
|
@@ -289,7 +313,25 @@ class Connection {
|
|
|
289
313
|
}
|
|
290
314
|
}
|
|
291
315
|
|
|
316
|
+
/**
|
|
317
|
+
* The heartbeat to ask for when the caller has not, in seconds. A broker is free
|
|
318
|
+
* to suggest one that leaves a connection lost for minutes before anything
|
|
319
|
+
* notices, and RabbitMQ suggests 60 by default.
|
|
320
|
+
*
|
|
321
|
+
* @type {number}
|
|
322
|
+
*/
|
|
323
|
+
const HEARTBEAT_S = 15
|
|
324
|
+
|
|
292
325
|
/** @type {number} */
|
|
326
|
+
const KEEPALIVE_MS = 10_000
|
|
327
|
+
|
|
328
|
+
/** How many heartbeats a connection may miss before it is destroyed. */
|
|
329
|
+
const MISSED_HEARTBEATS = 3
|
|
330
|
+
|
|
331
|
+
/** @type {number} */
|
|
332
|
+
const WATCHDOG_MIN_MS = 15_000
|
|
333
|
+
|
|
334
|
+
/** The watchdog interval used when the negotiated heartbeat is unknown. */
|
|
293
335
|
const WATCHDOG_MS = 60_000
|
|
294
336
|
|
|
295
337
|
/** @type {number} */
|
|
@@ -298,6 +340,18 @@ const CONNECT_MS = 30_000
|
|
|
298
340
|
/** @type {number} */
|
|
299
341
|
const SHUTDOWN_MS = 5_000
|
|
300
342
|
|
|
343
|
+
const SOCKET_OPTIONS = {
|
|
344
|
+
timeout: CONNECT_MS,
|
|
345
|
+
// a peer that went away without a word is noticed by the kernel as well
|
|
346
|
+
keepAlive: true,
|
|
347
|
+
keepAliveDelay: KEEPALIVE_MS
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const HEARTBEAT_SET = /[?&]heartbeat=/
|
|
351
|
+
|
|
352
|
+
/** What amqplib says when the negotiated channel limit leaves no identifier free. */
|
|
353
|
+
const EXHAUSTED = 'No channels left to allocate'
|
|
354
|
+
|
|
301
355
|
const TRANSIENT_CODES = new Set([
|
|
302
356
|
'ECONNREFUSED',
|
|
303
357
|
'EAI_AGAIN',
|
|
@@ -323,6 +377,52 @@ function expiration () {
|
|
|
323
377
|
return delay(timeoutMs, undefined, { ref: false })
|
|
324
378
|
}
|
|
325
379
|
|
|
380
|
+
/**
|
|
381
|
+
* amqplib reads the heartbeat from the URL only, hence it cannot be passed along
|
|
382
|
+
* with the socket options.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} url
|
|
385
|
+
* @return {string}
|
|
386
|
+
*/
|
|
387
|
+
function heartbeaten (url) {
|
|
388
|
+
if (HEARTBEAT_SET.test(url)) return url
|
|
389
|
+
|
|
390
|
+
return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The watchdog measures silence, so it cannot be shorter than the interval at
|
|
395
|
+
* which a healthy broker is expected to say something, which is a heartbeat
|
|
396
|
+
* frame every half a heartbeat. A connection that has agreed to no heartbeats
|
|
397
|
+
* says nothing at all while it is idle, leaving nothing to measure.
|
|
398
|
+
*
|
|
399
|
+
* @param {comq.amqp.Connection} connection
|
|
400
|
+
* @return {number | undefined}
|
|
401
|
+
*/
|
|
402
|
+
function tolerance (connection) {
|
|
403
|
+
const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
|
|
404
|
+
|
|
405
|
+
if (override !== undefined) return override
|
|
406
|
+
|
|
407
|
+
const heartbeat = connection.connection?.heartbeat
|
|
408
|
+
|
|
409
|
+
if (heartbeat === undefined) return WATCHDOG_MS
|
|
410
|
+
if (heartbeat === 0) return undefined
|
|
411
|
+
|
|
412
|
+
return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @return {Error}
|
|
417
|
+
*/
|
|
418
|
+
function silence () {
|
|
419
|
+
const exception = new Error('Connection is silent')
|
|
420
|
+
|
|
421
|
+
exception.code = 'ETIMEDOUT'
|
|
422
|
+
|
|
423
|
+
return exception
|
|
424
|
+
}
|
|
425
|
+
|
|
326
426
|
function noop () {}
|
|
327
427
|
|
|
328
428
|
exports.Connection = Connection
|
package/source/emitter.js
CHANGED
|
@@ -2,8 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('node:events')
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A listener must not be able to break what it observes: an exception thrown by
|
|
7
|
+
* one used to leave the reconnection it was reporting on unfinished, or take the
|
|
8
|
+
* process down through the emitter amqplib was calling.
|
|
9
|
+
*/
|
|
10
|
+
class Emitter extends EventEmitter {
|
|
11
|
+
emit (event, ...args) {
|
|
12
|
+
// raw listeners are used so that `once` still removes itself
|
|
13
|
+
const listeners = this.rawListeners(event)
|
|
14
|
+
|
|
15
|
+
for (const listener of listeners) {
|
|
16
|
+
try {
|
|
17
|
+
listener.apply(this, args)
|
|
18
|
+
} catch {
|
|
19
|
+
// a diagnostic listener has no one to report to
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return listeners.length > 0
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
function create () {
|
|
6
|
-
const emitter = new
|
|
28
|
+
const emitter = new Emitter()
|
|
7
29
|
|
|
8
30
|
emitter.setMaxListeners(0)
|
|
9
31
|
|
package/source/encoders/index.js
CHANGED
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
|
-
exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost']
|
|
7
|
+
exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
|
package/source/io.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const stream = require('node:stream')
|
|
4
|
-
const { EventEmitter } = require('node:events')
|
|
5
4
|
const { randomBytes } = require('node:crypto')
|
|
6
5
|
const { setTimeout } = require('node:timers/promises')
|
|
7
6
|
const { Promex } = require('promex')
|
|
@@ -11,6 +10,7 @@ const { decode } = require('./decode')
|
|
|
11
10
|
const { encode } = require('./encode')
|
|
12
11
|
const { pipeline, transform } = require('./pipeline')
|
|
13
12
|
const events = require('./events')
|
|
13
|
+
const emitter = require('./emitter')
|
|
14
14
|
const io = require('./.io')
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -44,7 +44,7 @@ class IO {
|
|
|
44
44
|
/** @type {Set<comq.Destroyable>} */
|
|
45
45
|
#replyPipes = new Set()
|
|
46
46
|
|
|
47
|
-
#diagnostics =
|
|
47
|
+
#diagnostics = emitter.create()
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* @param {comq.Connection} connection
|
|
@@ -52,9 +52,6 @@ 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
|
-
|
|
58
55
|
for (const event of events.connection) {
|
|
59
56
|
this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
|
|
60
57
|
}
|
|
@@ -166,6 +163,12 @@ class IO {
|
|
|
166
163
|
await this.seal()
|
|
167
164
|
await this.#destroyStreams(this.#replyPipes)
|
|
168
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
|
+
|
|
169
172
|
await this.#connection.close()
|
|
170
173
|
})
|
|
171
174
|
|
|
@@ -220,6 +223,10 @@ class IO {
|
|
|
220
223
|
} else {
|
|
221
224
|
this.#requests.diagnose('recover', this.#retransmit)
|
|
222
225
|
}
|
|
226
|
+
|
|
227
|
+
// a reply that could not be routed is dropped by the broker, and the queue
|
|
228
|
+
// it was addressed to only exists again once this channel has recovered
|
|
229
|
+
this.#replies.diagnose('recover', this.#retransmit)
|
|
223
230
|
}
|
|
224
231
|
|
|
225
232
|
/**
|
package/source/shards/channel.js
CHANGED
|
@@ -31,6 +31,9 @@ class Channel {
|
|
|
31
31
|
/** @type {Map<comq.Channel, Promex>} */
|
|
32
32
|
#bench = new Map()
|
|
33
33
|
|
|
34
|
+
/** @type {boolean} */
|
|
35
|
+
#paused = false
|
|
36
|
+
|
|
34
37
|
/** @type {comq.topology.type} */
|
|
35
38
|
#type
|
|
36
39
|
|
|
@@ -74,6 +77,14 @@ class Channel {
|
|
|
74
77
|
return await this.#one((channel) => channel.fire(queue, buffer, options))
|
|
75
78
|
}
|
|
76
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
|
+
|
|
77
88
|
async seal () {
|
|
78
89
|
await this.#all((channel) => channel.seal())
|
|
79
90
|
}
|
|
@@ -126,6 +137,9 @@ class Channel {
|
|
|
126
137
|
this.#alive[index] = false
|
|
127
138
|
this.#down[index].resolve()
|
|
128
139
|
|
|
140
|
+
// the channel stays in the pool, yet there is one less shard to publish to
|
|
141
|
+
this.#update()
|
|
142
|
+
|
|
129
143
|
if (error !== undefined && connection.closed !== true) {
|
|
130
144
|
this.#diagnostics.emit(LOST, index)
|
|
131
145
|
}
|
|
@@ -134,6 +148,8 @@ class Channel {
|
|
|
134
148
|
connection.diagnose('open', () => {
|
|
135
149
|
this.#alive[index] = true
|
|
136
150
|
this.#down[index] = new Promex()
|
|
151
|
+
|
|
152
|
+
this.#update()
|
|
137
153
|
})
|
|
138
154
|
}
|
|
139
155
|
|
|
@@ -159,6 +175,7 @@ class Channel {
|
|
|
159
175
|
for (const event of events.channel) {
|
|
160
176
|
if (event === RETURN) continue // returns are retried before being reported
|
|
161
177
|
if (event === LOST) continue // a shard is lost with its connection, not its channel
|
|
178
|
+
if (event === REMOVE) continue // it is the pool that removes a channel, not the channel
|
|
162
179
|
|
|
163
180
|
channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
|
|
164
181
|
}
|
|
@@ -214,20 +231,23 @@ class Channel {
|
|
|
214
231
|
this.#bench.set(channel, new Promex())
|
|
215
232
|
this.#channels.delete(channel)
|
|
216
233
|
this.#update()
|
|
217
|
-
this.#diagnostics.emit(
|
|
234
|
+
this.#diagnostics.emit(REMOVE, channel.index)
|
|
218
235
|
}
|
|
219
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A shard leaves the pool when it rejects a publish, and stops being reachable
|
|
239
|
+
* when its connection is lost, which the pool is not told about. Both leave
|
|
240
|
+
* `#one` waiting, so both are reported the same way.
|
|
241
|
+
*/
|
|
220
242
|
#update () {
|
|
221
|
-
const from = this.#pool?.length
|
|
222
|
-
const to = this.#channels.size
|
|
223
|
-
|
|
224
243
|
this.#pool = Array.from(this.#channels)
|
|
225
244
|
|
|
226
|
-
|
|
245
|
+
const paused = this.#reachable().length === 0
|
|
227
246
|
|
|
228
|
-
if (
|
|
247
|
+
if (paused === this.#paused) return
|
|
229
248
|
|
|
230
|
-
|
|
249
|
+
this.#paused = paused
|
|
250
|
+
this.#diagnostics.emit(paused ? 'pause' : 'resume')
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
/**
|
|
@@ -369,6 +389,7 @@ async function create (connections, type) {
|
|
|
369
389
|
|
|
370
390
|
const DEFAULT = ''
|
|
371
391
|
const RETURN = 'return'
|
|
392
|
+
const REMOVE = 'remove'
|
|
372
393
|
const RETURN_HEADER = 'x-return'
|
|
373
394
|
const LOST = 'lost'
|
|
374
395
|
|
|
@@ -19,9 +19,6 @@ 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
|
-
|
|
25
22
|
connections.map(this.#pipe)
|
|
26
23
|
}
|
|
27
24
|
|
|
@@ -75,6 +72,4 @@ class Connection {
|
|
|
75
72
|
}
|
|
76
73
|
}
|
|
77
74
|
|
|
78
|
-
function noop () {}
|
|
79
|
-
|
|
80
75
|
exports.Connection = Connection
|
package/test/amqplib.mock.js
CHANGED
|
@@ -15,17 +15,33 @@ 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 {
|
|
21
|
-
constructor () {
|
|
22
|
+
constructor (url = '') {
|
|
22
23
|
super()
|
|
23
24
|
|
|
24
25
|
const stream = new EventEmitter()
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
// amqplib listens for 'error' and 'end' on the socket only, so destroying it
|
|
28
|
+
// without an error leaves the connection unaware and silent forever
|
|
29
|
+
stream.destroy = jest.fn((error) => {
|
|
30
|
+
if (error === undefined || stream.destroyed === true) return
|
|
31
|
+
|
|
32
|
+
stream.destroyed = true
|
|
33
|
+
|
|
34
|
+
this.emit('error', error)
|
|
35
|
+
this.emit('close', error)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// a broker accepts the heartbeat the client asks for
|
|
39
|
+
const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
|
|
40
|
+
|
|
41
|
+
// and negotiates the channel limit down to whatever the client asked for
|
|
42
|
+
const channelMax = Number(/[?&]channelMax=(\d+)/.exec(url)?.[1] ?? 2047)
|
|
27
43
|
|
|
28
|
-
this.connection = { stream }
|
|
44
|
+
this.connection = { stream, heartbeat, channelMax }
|
|
29
45
|
|
|
30
46
|
// noinspection JSValidateTypes
|
|
31
47
|
this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
|
|
@@ -39,7 +55,7 @@ class Connection extends EventEmitter {
|
|
|
39
55
|
close = jest.fn(async () => undefined)
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
const connect = async () => new Connection()
|
|
58
|
+
const connect = async (url) => new Connection(url)
|
|
43
59
|
|
|
44
60
|
/** @type {jest.MockedObject<import('amqplib')>} */
|
|
45
61
|
const amqplib = {
|
package/test/channel.fixtures.js
CHANGED
package/test/channel.test.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const { randomBytes } = require('node:crypto')
|
|
6
6
|
const { generate } = require('randomstring')
|
|
7
|
-
const { flip, random, timeout } = require('
|
|
7
|
+
const { flip, random, timeout } = require('./helpers')
|
|
8
8
|
|
|
9
9
|
const backpressure = require('./backpressure')
|
|
10
10
|
const { amqplib } = require('./amqplib.mock')
|
|
@@ -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
|
|