comq 0.24.0 → 0.24.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/features/heartbeat.feature +16 -0
- package/features/rpc.call.recovery.feature +2 -2
- package/features/rpc.shards.retransmission.feature +46 -0
- package/features/shards.feature +1 -1
- package/features/silence.feature +3 -2
- package/features/steps/.types/context.d.ts +6 -1
- package/features/steps/brokers.js +1 -1
- package/features/steps/connection.js +23 -0
- package/features/steps/context.js +1 -2
- package/features/steps/hooks.js +0 -1
- package/features/steps/network.js +11 -2
- package/features/steps/networks.js +31 -2
- package/features/steps/retransmission.js +165 -0
- package/features/steps/rpc.js +0 -6
- package/package.json +15 -1
- package/readme.md +9 -4
- package/source/channel.js +3 -1
- package/source/connection.js +9 -86
- package/source/io.js +90 -15
- package/source/shards/channel.js +73 -12
- package/test/connection.mock.js +3 -2
- package/test/connection.test.js +15 -77
- package/test/io.call.test.js +5 -3
- package/test/io.reply.test.js +5 -3
- package/test/io.request.test.js +264 -2
- package/test/memory.test.js +0 -2
- package/test/shards/channel.test.js +168 -2
- package/types/channel.d.ts +13 -4
- package/types/io.d.ts +9 -0
package/source/connection.js
CHANGED
|
@@ -35,18 +35,9 @@ class Connection {
|
|
|
35
35
|
/** @type {boolean} */
|
|
36
36
|
#closed = false
|
|
37
37
|
|
|
38
|
-
/** @type {NodeJS.Timeout | null} */
|
|
39
|
-
#heartbeatTimer = null
|
|
40
|
-
|
|
41
38
|
/** @type {comq.topology.Overrides} */
|
|
42
39
|
#overrides
|
|
43
40
|
|
|
44
|
-
/** @type {import('node:net').Socket | null} */
|
|
45
|
-
#heartbeatSocket = null
|
|
46
|
-
|
|
47
|
-
/** @type {(() => void) | null} */
|
|
48
|
-
#heartbeatReset = null
|
|
49
|
-
|
|
50
41
|
#diagnostics = emitter.create()
|
|
51
42
|
|
|
52
43
|
/**
|
|
@@ -147,7 +138,6 @@ class Connection {
|
|
|
147
138
|
connection.on('close', (error) => this.#close(connection, error))
|
|
148
139
|
this.#connection = connection
|
|
149
140
|
|
|
150
|
-
this.#armWatchdog(connection)
|
|
151
141
|
this.#diagnostics.emit('open')
|
|
152
142
|
|
|
153
143
|
try {
|
|
@@ -177,11 +167,14 @@ class Connection {
|
|
|
177
167
|
#close = (connection, error) => {
|
|
178
168
|
if (this.#connection !== connection) return
|
|
179
169
|
|
|
180
|
-
this.#disarmWatchdog()
|
|
181
170
|
this.#diagnostics.emit('close', error)
|
|
182
171
|
connection.removeAllListeners()
|
|
183
172
|
this.#connection = undefined
|
|
184
173
|
|
|
174
|
+
// amqplib only ends the socket of a connection it has given up on, and a peer that has gone
|
|
175
|
+
// silent never ends its side, which would leave the socket to the kernel's keepalive
|
|
176
|
+
if (error !== undefined) connection.connection?.stream?.destroy()
|
|
177
|
+
|
|
185
178
|
if (error !== undefined && !this.#closed) {
|
|
186
179
|
this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
187
180
|
}
|
|
@@ -224,10 +217,7 @@ class Connection {
|
|
|
224
217
|
* @param {comq.amqp.Connection} connection
|
|
225
218
|
*/
|
|
226
219
|
#drop (connection) {
|
|
227
|
-
if (this.#connection === connection)
|
|
228
|
-
this.#disarmWatchdog()
|
|
229
|
-
this.#connection = undefined
|
|
230
|
-
}
|
|
220
|
+
if (this.#connection === connection) this.#connection = undefined
|
|
231
221
|
|
|
232
222
|
connection.removeAllListeners()
|
|
233
223
|
|
|
@@ -294,46 +284,6 @@ class Connection {
|
|
|
294
284
|
}
|
|
295
285
|
}
|
|
296
286
|
|
|
297
|
-
/**
|
|
298
|
-
* @param {comq.amqp.Connection} connection
|
|
299
|
-
* @param {number} [timeoutMs]
|
|
300
|
-
*/
|
|
301
|
-
#armWatchdog (connection, timeoutMs = tolerance(connection)) {
|
|
302
|
-
this.#disarmWatchdog()
|
|
303
|
-
|
|
304
|
-
const socket = connection.connection?.stream
|
|
305
|
-
|
|
306
|
-
if (socket === undefined || timeoutMs === undefined) return
|
|
307
|
-
|
|
308
|
-
const reset = () => {
|
|
309
|
-
clearTimeout(this.#heartbeatTimer)
|
|
310
|
-
// destroying a socket without an error tells amqplib nothing: it only
|
|
311
|
-
// listens for 'error' and 'end', so a bare destroy() leaves the connection
|
|
312
|
-
// silently dead — no 'close' event, no recovery, every pending operation
|
|
313
|
-
// hanging forever
|
|
314
|
-
this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
|
|
315
|
-
this.#heartbeatTimer.unref()
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
this.#heartbeatSocket = socket
|
|
319
|
-
this.#heartbeatReset = reset
|
|
320
|
-
|
|
321
|
-
reset()
|
|
322
|
-
socket.on('data', reset)
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
#disarmWatchdog () {
|
|
326
|
-
clearTimeout(this.#heartbeatTimer)
|
|
327
|
-
this.#heartbeatTimer = null
|
|
328
|
-
|
|
329
|
-
// otherwise a byte arriving on a replaced socket rearms the watchdog of a
|
|
330
|
-
// connection that is already gone, leaving the current one unguarded
|
|
331
|
-
this.#heartbeatSocket?.off('data', this.#heartbeatReset)
|
|
332
|
-
|
|
333
|
-
this.#heartbeatSocket = null
|
|
334
|
-
this.#heartbeatReset = null
|
|
335
|
-
}
|
|
336
|
-
|
|
337
287
|
#transient (exception) {
|
|
338
288
|
if (this.#running) return true
|
|
339
289
|
if (TRANSIENT_CODES.has(exception.code)) return true
|
|
@@ -348,6 +298,10 @@ class Connection {
|
|
|
348
298
|
* to suggest one that leaves a connection lost for minutes before anything
|
|
349
299
|
* notices, and RabbitMQ suggests 60 by default.
|
|
350
300
|
*
|
|
301
|
+
* It is amqplib that tells a connection gone silent: one that has received nothing
|
|
302
|
+
* for two heartbeats is closed with an error, whatever the broker and the operating
|
|
303
|
+
* system have reported, which is what restores it.
|
|
304
|
+
*
|
|
351
305
|
* @type {number}
|
|
352
306
|
*/
|
|
353
307
|
const HEARTBEAT_S = 15
|
|
@@ -355,15 +309,6 @@ const HEARTBEAT_S = 15
|
|
|
355
309
|
/** @type {number} */
|
|
356
310
|
const KEEPALIVE_MS = 10_000
|
|
357
311
|
|
|
358
|
-
/** How many heartbeats a connection may miss before it is destroyed. */
|
|
359
|
-
const MISSED_HEARTBEATS = 3
|
|
360
|
-
|
|
361
|
-
/** @type {number} */
|
|
362
|
-
const WATCHDOG_MIN_MS = 15_000
|
|
363
|
-
|
|
364
|
-
/** The watchdog interval used when the negotiated heartbeat is unknown. */
|
|
365
|
-
const WATCHDOG_MS = 60_000
|
|
366
|
-
|
|
367
312
|
/** @type {number} */
|
|
368
313
|
const CONNECT_MS = 30_000
|
|
369
314
|
|
|
@@ -422,28 +367,6 @@ function heartbeaten (url) {
|
|
|
422
367
|
return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
|
|
423
368
|
}
|
|
424
369
|
|
|
425
|
-
/**
|
|
426
|
-
* The watchdog measures silence, so it cannot be shorter than the interval at
|
|
427
|
-
* which a healthy broker is expected to say something, which is a heartbeat
|
|
428
|
-
* frame every half a heartbeat. A connection that has agreed to no heartbeats
|
|
429
|
-
* says nothing at all while it is idle, leaving nothing to measure.
|
|
430
|
-
*
|
|
431
|
-
* @param {comq.amqp.Connection} connection
|
|
432
|
-
* @return {number | undefined}
|
|
433
|
-
*/
|
|
434
|
-
function tolerance (connection) {
|
|
435
|
-
const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
|
|
436
|
-
|
|
437
|
-
if (override !== undefined) return override
|
|
438
|
-
|
|
439
|
-
const heartbeat = connection.connection?.heartbeat
|
|
440
|
-
|
|
441
|
-
if (heartbeat === undefined) return WATCHDOG_MS
|
|
442
|
-
if (heartbeat === 0) return undefined
|
|
443
|
-
|
|
444
|
-
return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
370
|
/**
|
|
448
371
|
* @return {Error}
|
|
449
372
|
*/
|
package/source/io.js
CHANGED
|
@@ -154,7 +154,7 @@ class IO {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
return await this.#ask(queue, payload, terms,
|
|
157
|
-
(buffer, properties) => this.#requests.send(queue, buffer, properties))
|
|
157
|
+
(buffer, properties, via) => this.#requests.send(queue, buffer, properties, via))
|
|
158
158
|
}))
|
|
159
159
|
|
|
160
160
|
// the terms are taken once, so a re-sent call keeps the deadline of the first
|
|
@@ -170,7 +170,8 @@ class IO {
|
|
|
170
170
|
async (exchange, key, payload, terms) => {
|
|
171
171
|
// mandatory: a Request nobody holds the key of is returned rather than dropped
|
|
172
172
|
return await this.#ask(exchange, payload, terms,
|
|
173
|
-
(buffer, properties) =>
|
|
173
|
+
(buffer, properties, via) =>
|
|
174
|
+
this.#requests.route(exchange, key, buffer, { ...properties, mandatory: true }, via))
|
|
174
175
|
}))
|
|
175
176
|
|
|
176
177
|
consume = lazy(this, this.#createEventChannel,
|
|
@@ -357,12 +358,20 @@ class IO {
|
|
|
357
358
|
return channel
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
/**
|
|
362
|
+
* On a sharded connection, what is re-sent is what the shard in question holds: the index of
|
|
363
|
+
* the shard comes with each of the events, and a Request that went through another shard is
|
|
364
|
+
* answered through that one.
|
|
365
|
+
*/
|
|
360
366
|
#setupRetransmission () {
|
|
361
367
|
if (this.#requests.sharded === true) {
|
|
362
368
|
// a shard leaves the pool when it rejects a publish, and is lost when its
|
|
363
369
|
// connection drops, which leaves an already sent request unanswered
|
|
364
370
|
this.#requests.diagnose('remove', this.#retransmit)
|
|
365
371
|
this.#requests.diagnose('lost', this.#retransmit)
|
|
372
|
+
|
|
373
|
+
// a returned call is published on another shard, and is held by that one from then on
|
|
374
|
+
this.#requests.diagnose('reroute', this.#rerouted)
|
|
366
375
|
} else {
|
|
367
376
|
this.#requests.diagnose('recover', this.#retransmit)
|
|
368
377
|
}
|
|
@@ -462,11 +471,33 @@ class IO {
|
|
|
462
471
|
|
|
463
472
|
if (signal !== undefined) this.#abandon(request, reply, signal)
|
|
464
473
|
|
|
465
|
-
|
|
474
|
+
request.publishing = true
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await abortable(publish(buffer, properties, (index) => this.#board(request, index)), signal)
|
|
478
|
+
} finally {
|
|
479
|
+
request.publishing = false
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// the shard it went through was lost while it was being published, and it has not failed
|
|
483
|
+
// over to another since
|
|
484
|
+
if (request.lost === true) this.#resend(request, reply)
|
|
466
485
|
|
|
467
486
|
return reply
|
|
468
487
|
}
|
|
469
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Records the shard a Request is published through. It is recorded as the shard is chosen,
|
|
491
|
+
* rather than once the publish is done, so that a shard lost in between is known to hold it.
|
|
492
|
+
*
|
|
493
|
+
* @param {comq.Request} request
|
|
494
|
+
* @param {number} index
|
|
495
|
+
*/
|
|
496
|
+
#board (request, index) {
|
|
497
|
+
request.shard = index
|
|
498
|
+
request.lost = false
|
|
499
|
+
}
|
|
500
|
+
|
|
470
501
|
/**
|
|
471
502
|
* Stops waiting for a Reply once the signal aborts. The Request is forgotten at once, so a
|
|
472
503
|
* retransmission leaves it be, and a Reply arriving later finds nobody waiting for it.
|
|
@@ -494,17 +525,41 @@ class IO {
|
|
|
494
525
|
* @param {comq.amqp.Message} message
|
|
495
526
|
*/
|
|
496
527
|
#returned = (message) => {
|
|
528
|
+
const pending = this.#pendingOf(message)
|
|
529
|
+
|
|
530
|
+
if (pending === undefined) return
|
|
531
|
+
|
|
532
|
+
const [reply, request] = pending
|
|
533
|
+
|
|
534
|
+
request.emitter.off(request.properties.correlationId)
|
|
535
|
+
this.#pendingReplies.delete(reply)
|
|
536
|
+
reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* A Request one shard returned is published on another.
|
|
541
|
+
*
|
|
542
|
+
* @param {comq.amqp.Message} message
|
|
543
|
+
* @param {number} index
|
|
544
|
+
*/
|
|
545
|
+
#rerouted = (message, index) => {
|
|
546
|
+
const pending = this.#pendingOf(message)
|
|
547
|
+
|
|
548
|
+
if (pending !== undefined) this.#board(pending[1], index)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* @param {comq.amqp.Message} message
|
|
553
|
+
* @return {[Promex, comq.Request] | undefined}
|
|
554
|
+
*/
|
|
555
|
+
#pendingOf (message) {
|
|
497
556
|
const { correlationId, replyTo } = message.properties
|
|
498
557
|
|
|
499
558
|
for (const [reply, request] of this.#pendingReplies) {
|
|
500
559
|
if (request.properties.correlationId !== correlationId) continue
|
|
501
560
|
if (request.emitter.queue !== replyTo) continue
|
|
502
561
|
|
|
503
|
-
request
|
|
504
|
-
this.#pendingReplies.delete(reply)
|
|
505
|
-
reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
|
|
506
|
-
|
|
507
|
-
return
|
|
562
|
+
return [reply, request]
|
|
508
563
|
}
|
|
509
564
|
}
|
|
510
565
|
|
|
@@ -665,24 +720,44 @@ class IO {
|
|
|
665
720
|
// an unroutable reply must be returned by the broker rather than dropped
|
|
666
721
|
properties.mandatory = true
|
|
667
722
|
|
|
668
|
-
|
|
723
|
+
// back the way the request came: see `fire` of a sharded channel
|
|
724
|
+
return await this.#replies.fire(replyTo, buffer, properties, request)
|
|
669
725
|
}
|
|
670
726
|
|
|
671
727
|
#recover (exception) {
|
|
672
728
|
if (exception !== RETRANSMISSION) return false
|
|
673
729
|
}
|
|
674
730
|
|
|
675
|
-
|
|
731
|
+
/**
|
|
732
|
+
* Re-sends the Requests awaiting their replies on a shard, or on the connection when no shard
|
|
733
|
+
* is given. A Request that went through another shard is not re-sent: it is answered through
|
|
734
|
+
* that shard, and it is not for comq to assume that it is idempotent.
|
|
735
|
+
*
|
|
736
|
+
* @param {number} [index]
|
|
737
|
+
*/
|
|
738
|
+
#retransmit = (index) => {
|
|
676
739
|
for (const [reply, request] of this.#pendingReplies) {
|
|
677
|
-
|
|
678
|
-
// that are still flowing over the other shards in place
|
|
679
|
-
request.emitter.off(request.properties.correlationId)
|
|
740
|
+
if (index !== undefined && request.shard !== index) continue
|
|
680
741
|
|
|
681
|
-
//
|
|
682
|
-
|
|
742
|
+
// one still being published is re-sent once it is, unless it fails over to another shard
|
|
743
|
+
if (request.publishing === true) request.lost = true
|
|
744
|
+
else this.#resend(request, reply)
|
|
683
745
|
}
|
|
684
746
|
}
|
|
685
747
|
|
|
748
|
+
/**
|
|
749
|
+
* @param {comq.Request} request
|
|
750
|
+
* @param {Promex} reply
|
|
751
|
+
*/
|
|
752
|
+
#resend (request, reply) {
|
|
753
|
+
// detaching this attempt alone leaves the listeners of the reply streams
|
|
754
|
+
// that are still flowing over the other shards in place
|
|
755
|
+
request.emitter.off(request.properties.correlationId)
|
|
756
|
+
|
|
757
|
+
// trigger failsafe attribute
|
|
758
|
+
reply.reject(RETRANSMISSION)
|
|
759
|
+
}
|
|
760
|
+
|
|
686
761
|
/**
|
|
687
762
|
* @param {any} payload
|
|
688
763
|
* @param {comq.Encoding} [contentType]
|
package/source/shards/channel.js
CHANGED
|
@@ -57,8 +57,12 @@ class Channel {
|
|
|
57
57
|
await Promise.any(promises)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A message is told which shard it arrived on, so that whatever answers it can go back the same
|
|
62
|
+
* way. See `fire`.
|
|
63
|
+
*/
|
|
60
64
|
async consume (queue, consumer) {
|
|
61
|
-
return await this.#every((channel) => channel.consume(queue, consumer))
|
|
65
|
+
return await this.#every((channel) => channel.consume(queue, arrival(consumer, channel)))
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
async subscribe (queue, group, consumer) {
|
|
@@ -74,24 +78,51 @@ class Channel {
|
|
|
74
78
|
* claiming it, and holds it as well once it is let go.
|
|
75
79
|
*/
|
|
76
80
|
async held (exchange, queue, key, consumer) {
|
|
77
|
-
await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, consumer)))
|
|
81
|
+
await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, arrival(consumer, channel))))
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} queue
|
|
86
|
+
* @param {Buffer} buffer
|
|
87
|
+
* @param {comq.amqp.options.Publish} [options]
|
|
88
|
+
* @param {(index: number) => void} [via] told which shard the message is published through,
|
|
89
|
+
* as soon as one is chosen and again whenever the publish fails over to another
|
|
90
|
+
*/
|
|
91
|
+
async send (queue, buffer, options, via) {
|
|
92
|
+
await this.#one((channel) => channel.send(queue, buffer, options), { via })
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
async publish (exchange, buffer, options) {
|
|
85
96
|
await this.#one((channel) => channel.publish(exchange, buffer, options))
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} exchange
|
|
101
|
+
* @param {string} key
|
|
102
|
+
* @param {Buffer} buffer
|
|
103
|
+
* @param {comq.amqp.options.Publish} [options]
|
|
104
|
+
* @param {(index: number) => void} [via] as `send` has it
|
|
105
|
+
*/
|
|
106
|
+
async route (exchange, key, buffer, options, via) {
|
|
107
|
+
await this.#one((channel) => channel.route(exchange, key, buffer, options), { via })
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
|
|
110
|
+
/**
|
|
111
|
+
* A reply goes back through the shard the message it answers arrived on. Whoever sent that
|
|
112
|
+
* message re-sends what it sent through a shard once the shard is lost, and nothing else, so a
|
|
113
|
+
* reply taking another way could be lost with a shard that nothing is re-sent for. It takes
|
|
114
|
+
* another way when that shard is not reachable, which leaves its message unacknowledged there,
|
|
115
|
+
* to be delivered and answered again.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} queue
|
|
118
|
+
* @param {Buffer} buffer
|
|
119
|
+
* @param {comq.amqp.options.Publish} [options]
|
|
120
|
+
* @param {comq.amqp.Message} [origin] the message this one answers
|
|
121
|
+
* @return {Promise<boolean>}
|
|
122
|
+
*/
|
|
123
|
+
async fire (queue, buffer, options, origin) {
|
|
93
124
|
// noinspection JSValidateTypes
|
|
94
|
-
return await this.#one((channel) => channel.fire(queue, buffer, options))
|
|
125
|
+
return await this.#one((channel) => channel.fire(queue, buffer, options), { prefer: origin?.[ARRIVAL] })
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
async close () {
|
|
@@ -230,6 +261,9 @@ class Channel {
|
|
|
230
261
|
|
|
231
262
|
const next = rest[Math.floor(Math.random() * rest.length)]
|
|
232
263
|
|
|
264
|
+
// the message is on that shard now, and is lost with it rather than with this one
|
|
265
|
+
this.#diagnostics.emit(REROUTE, message, next.index)
|
|
266
|
+
|
|
233
267
|
// a Request under a key goes through its routed exchange, which `route` declares on that
|
|
234
268
|
// shard before publishing: publishing to an exchange a broker lacks closes the connection
|
|
235
269
|
const retried = exchange === DEFAULT
|
|
@@ -374,9 +408,13 @@ class Channel {
|
|
|
374
408
|
}
|
|
375
409
|
|
|
376
410
|
/**
|
|
411
|
+
* Publishes through a shard chosen at random, or through the one preferred while it is
|
|
412
|
+
* reachable, and fails over to another when that one fails.
|
|
413
|
+
*
|
|
377
414
|
* @param {(channel: comq.Channel) => void} fn
|
|
415
|
+
* @param {{ via?: (index: number) => void, prefer?: number }} [route]
|
|
378
416
|
*/
|
|
379
|
-
async #one (fn) {
|
|
417
|
+
async #one (fn, route = {}) {
|
|
380
418
|
// capture before the check: recover may replace `#recovery` in between
|
|
381
419
|
const waiting = this.#recovery
|
|
382
420
|
const pool = this.#reachable()
|
|
@@ -384,21 +422,38 @@ class Channel {
|
|
|
384
422
|
if (pool.length === 0) {
|
|
385
423
|
await waiting
|
|
386
424
|
|
|
387
|
-
return this.#one(fn)
|
|
425
|
+
return this.#one(fn, route)
|
|
388
426
|
}
|
|
389
427
|
|
|
390
|
-
const channel = pool
|
|
428
|
+
const channel = pool.find((channel) => channel.index === route.prefer) ??
|
|
429
|
+
pool[Math.floor(Math.random() * pool.length)]
|
|
430
|
+
|
|
431
|
+
// told before the publish is awaited: a shard lost while it is under way has it on board
|
|
432
|
+
route.via?.(channel.index)
|
|
391
433
|
|
|
392
434
|
try {
|
|
393
435
|
return await fn(channel)
|
|
394
436
|
} catch {
|
|
395
437
|
this.#remove(channel)
|
|
396
438
|
|
|
397
|
-
return this.#one(fn)
|
|
439
|
+
return this.#one(fn, route)
|
|
398
440
|
}
|
|
399
441
|
}
|
|
400
442
|
}
|
|
401
443
|
|
|
444
|
+
/**
|
|
445
|
+
* @param {comq.channels.Consumer} consumer
|
|
446
|
+
* @param {comq.Channel} channel
|
|
447
|
+
* @return {comq.channels.Consumer}
|
|
448
|
+
*/
|
|
449
|
+
function arrival (consumer, channel) {
|
|
450
|
+
return (message) => {
|
|
451
|
+
message[ARRIVAL] = channel.index
|
|
452
|
+
|
|
453
|
+
return consumer(message)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
402
457
|
/**
|
|
403
458
|
* @param {comq.Connection[]} connections
|
|
404
459
|
* @param {comq.topology.type} type
|
|
@@ -418,6 +473,12 @@ const REMOVE = 'remove'
|
|
|
418
473
|
const RETURN_HEADER = 'x-return'
|
|
419
474
|
const LOST = 'lost'
|
|
420
475
|
|
|
476
|
+
/** A returned message published on another shard, which is where it can be lost from now on. */
|
|
477
|
+
const REROUTE = 'reroute'
|
|
478
|
+
|
|
479
|
+
/** Which shard a consumed message arrived on. */
|
|
480
|
+
const ARRIVAL = Symbol('arrival')
|
|
481
|
+
|
|
421
482
|
function noop () {}
|
|
422
483
|
|
|
423
484
|
exports.create = create
|
package/test/connection.mock.js
CHANGED
|
@@ -6,13 +6,14 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
|
|
|
6
6
|
sharded,
|
|
7
7
|
consume: jest.fn(async () => undefined),
|
|
8
8
|
deliver: jest.fn(async () => undefined),
|
|
9
|
-
|
|
9
|
+
// a sharded channel tells which shard a message goes through, the first one unless told otherwise
|
|
10
|
+
send: jest.fn(async (_queue, _buffer, _options, via) => { if (sharded) via?.(0) }),
|
|
10
11
|
fire: jest.fn(async () => undefined),
|
|
11
12
|
subscribe: jest.fn(async () => undefined),
|
|
12
13
|
bound: jest.fn(async () => undefined),
|
|
13
14
|
held: jest.fn(async () => undefined),
|
|
14
15
|
publish: jest.fn(async () => undefined),
|
|
15
|
-
route: jest.fn(async () =>
|
|
16
|
+
route: jest.fn(async (_exchange, _key, _buffer, _options, via) => { if (sharded) via?.(0) }),
|
|
16
17
|
diagnose: jest.fn(async () => undefined),
|
|
17
18
|
forget: jest.fn(() => undefined),
|
|
18
19
|
seal: jest.fn(async () => undefined),
|
package/test/connection.test.js
CHANGED
|
@@ -543,109 +543,47 @@ describe('channel exhaustion', () => {
|
|
|
543
543
|
})
|
|
544
544
|
})
|
|
545
545
|
|
|
546
|
-
describe('
|
|
546
|
+
describe('silence', () => {
|
|
547
547
|
afterEach(() => {
|
|
548
548
|
jest.useRealTimers()
|
|
549
549
|
})
|
|
550
550
|
|
|
551
|
-
|
|
551
|
+
// amqplib reads the socket through a callback rather than 'data' events, and tells a
|
|
552
|
+
// connection gone silent by its heartbeat
|
|
553
|
+
it('should keep an idle connection', async () => {
|
|
552
554
|
jest.useFakeTimers()
|
|
553
555
|
|
|
554
556
|
await connection.open()
|
|
555
557
|
|
|
556
558
|
const conn = await amqplib.connect.mock.results[0].value
|
|
557
559
|
|
|
558
|
-
await jest.advanceTimersByTimeAsync(
|
|
559
|
-
|
|
560
|
-
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
561
|
-
})
|
|
562
|
-
|
|
563
|
-
it('should reconnect after the watchdog destroys a silent connection', async () => {
|
|
564
|
-
jest.useFakeTimers()
|
|
565
|
-
|
|
566
|
-
await connection.open()
|
|
567
|
-
|
|
568
|
-
await jest.advanceTimersByTimeAsync(46_000)
|
|
569
|
-
|
|
570
|
-
// a socket destroyed without an error is a socket amqplib never reports,
|
|
571
|
-
// which leaves the connection silently dead instead of recovering
|
|
572
|
-
expect(amqplib.connect).toHaveBeenCalledTimes(2)
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
it('should not let a replaced socket disarm the watchdog', async () => {
|
|
576
|
-
jest.useFakeTimers()
|
|
577
|
-
|
|
578
|
-
await connection.open()
|
|
579
|
-
|
|
580
|
-
const stale = await amqplib.connect.mock.results[0].value
|
|
581
|
-
|
|
582
|
-
stale.emit('close', new Error('lost'))
|
|
583
|
-
|
|
584
|
-
await jest.advanceTimersByTimeAsync(1)
|
|
585
|
-
|
|
586
|
-
const live = await amqplib.connect.mock.results[1].value
|
|
587
|
-
|
|
588
|
-
await jest.advanceTimersByTimeAsync(30_000)
|
|
589
|
-
|
|
590
|
-
stale.connection.stream.emit('data', Buffer.alloc(0))
|
|
591
|
-
|
|
592
|
-
await jest.advanceTimersByTimeAsync(30_000)
|
|
593
|
-
|
|
594
|
-
expect(live.connection.stream.destroy).toHaveBeenCalled()
|
|
595
|
-
})
|
|
596
|
-
|
|
597
|
-
it('should reset watchdog on socket data', async () => {
|
|
598
|
-
jest.useFakeTimers()
|
|
599
|
-
|
|
600
|
-
await connection.open()
|
|
601
|
-
|
|
602
|
-
const conn = await amqplib.connect.mock.results[0].value
|
|
603
|
-
|
|
604
|
-
await jest.advanceTimersByTimeAsync(40_000)
|
|
605
|
-
|
|
606
|
-
conn.connection.stream.emit('data', Buffer.alloc(0))
|
|
607
|
-
|
|
608
|
-
await jest.advanceTimersByTimeAsync(40_000)
|
|
560
|
+
await jest.advanceTimersByTimeAsync(600_000)
|
|
609
561
|
|
|
610
562
|
expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
|
|
611
|
-
|
|
612
|
-
await jest.advanceTimersByTimeAsync(10_000)
|
|
613
|
-
|
|
614
|
-
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
563
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(1)
|
|
615
564
|
})
|
|
616
565
|
|
|
617
|
-
it('should
|
|
618
|
-
jest.useFakeTimers()
|
|
619
|
-
|
|
620
|
-
const connection = new Connection('amqp://localhost?heartbeat=30')
|
|
621
|
-
|
|
566
|
+
it('should destroy the socket of a connection lost with an error', async () => {
|
|
622
567
|
await connection.open()
|
|
623
568
|
|
|
624
569
|
const conn = await amqplib.connect.mock.results[0].value
|
|
570
|
+
const stream = conn.connection.stream
|
|
625
571
|
|
|
626
|
-
|
|
627
|
-
await jest.advanceTimersByTimeAsync(60_000)
|
|
628
|
-
|
|
629
|
-
expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
|
|
630
|
-
|
|
631
|
-
await jest.advanceTimersByTimeAsync(31_000)
|
|
572
|
+
conn.emit('close', new Error('Heartbeat timeout'))
|
|
632
573
|
|
|
633
|
-
expect(
|
|
574
|
+
expect(stream.destroy).toHaveBeenCalled()
|
|
634
575
|
})
|
|
635
576
|
|
|
636
|
-
it('should
|
|
637
|
-
jest.useFakeTimers()
|
|
638
|
-
|
|
639
|
-
const connection = new Connection('amqp://localhost?heartbeat=0')
|
|
640
|
-
|
|
577
|
+
it('should reconnect once a connection is lost with an error', async () => {
|
|
641
578
|
await connection.open()
|
|
642
579
|
|
|
643
580
|
const conn = await amqplib.connect.mock.results[0].value
|
|
644
581
|
|
|
645
|
-
|
|
646
|
-
await jest.advanceTimersByTimeAsync(600_000)
|
|
582
|
+
conn.emit('close', new Error('Heartbeat timeout'))
|
|
647
583
|
|
|
648
|
-
|
|
584
|
+
await timeout(0)
|
|
585
|
+
|
|
586
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(2)
|
|
649
587
|
})
|
|
650
588
|
})
|
|
651
589
|
|
package/test/io.call.test.js
CHANGED
|
@@ -52,7 +52,7 @@ describe('call', () => {
|
|
|
52
52
|
|
|
53
53
|
it('should publish to the exchange under the key', async () => {
|
|
54
54
|
expect(requests.route).toHaveBeenCalledWith(exchange, key, expect.any(Buffer),
|
|
55
|
-
expect.objectContaining({ mandatory: true }))
|
|
55
|
+
expect.objectContaining({ mandatory: true }), expect.any(Function))
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('should declare no queue to publish to', async () => {
|
|
@@ -116,14 +116,16 @@ describe('back', () => {
|
|
|
116
116
|
const contentType = 'application/json'
|
|
117
117
|
const properties = { contentType, correlationId: generate(), replyTo: generate() }
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
const request = { content: encode(payload, contentType), properties }
|
|
120
|
+
|
|
121
|
+
await consumer(request)
|
|
120
122
|
|
|
121
123
|
replies = await findChannel('reply')
|
|
122
124
|
|
|
123
125
|
expect(producer).toHaveBeenCalledWith(payload)
|
|
124
126
|
|
|
125
127
|
expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, expect.any(Buffer),
|
|
126
|
-
expect.objectContaining({ correlationId: properties.correlationId }))
|
|
128
|
+
expect.objectContaining({ correlationId: properties.correlationId }), request)
|
|
127
129
|
})
|
|
128
130
|
})
|
|
129
131
|
|
package/test/io.reply.test.js
CHANGED
|
@@ -98,7 +98,8 @@ describe('reply', () => {
|
|
|
98
98
|
const reply = await produce.mock.results[0].value
|
|
99
99
|
const buffer = encode(reply, properties.contentType)
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// the request goes along, so that the reply goes back the way it came
|
|
102
|
+
expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, buffer, expect.anything(), message)
|
|
102
103
|
})
|
|
103
104
|
|
|
104
105
|
it('should throw if producer returned undefined', async () => {
|
|
@@ -190,7 +191,7 @@ describe('encoding', () => {
|
|
|
190
191
|
mandatory: true
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props)
|
|
194
|
+
expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props, message)
|
|
194
195
|
})
|
|
195
196
|
|
|
196
197
|
it('should set octet-stream for Buffer reply', async () => {
|
|
@@ -215,7 +216,8 @@ describe('encoding', () => {
|
|
|
215
216
|
.toHaveBeenCalledWith(
|
|
216
217
|
expect.any(String),
|
|
217
218
|
buffer,
|
|
218
|
-
expect.objectContaining({ contentType: 'application/octet-stream' })
|
|
219
|
+
expect.objectContaining({ contentType: 'application/octet-stream' }),
|
|
220
|
+
message
|
|
219
221
|
)
|
|
220
222
|
})
|
|
221
223
|
|