comq 0.19.0 → 0.20.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/features/rpc.call.feature +71 -0
- package/features/rpc.call.recovery.feature +75 -0
- package/features/rpc.call.shards.feature +24 -0
- package/features/rpc.parked.feature +20 -0
- package/features/rpc.timeout.feature +19 -0
- package/features/steps/call.js +462 -0
- package/features/steps/context.js +4 -0
- package/features/steps/network.js +8 -0
- package/features/steps/networks.js +24 -4
- package/features/steps/parked.js +102 -0
- package/package.json +1 -1
- package/readme.md +141 -31
- package/source/attributes/recall.js +2 -1
- package/source/channel.js +128 -1
- package/source/events.js +1 -1
- package/source/index.js +2 -0
- package/source/io.js +212 -13
- package/source/shards/channel.js +20 -3
- package/source/unroutable.js +27 -0
- package/test/amqplib.mock.js +1 -0
- package/test/channel.held.test.js +237 -0
- package/test/connection.mock.js +1 -0
- package/test/io.call.test.js +161 -0
- package/test/io.request.terms.test.js +247 -0
- package/test/shards/channel.test.js +68 -0
- package/types/channel.d.ts +3 -0
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +9 -0
- package/types/io.d.ts +42 -2
package/source/io.js
CHANGED
|
@@ -5,6 +5,7 @@ const { setTimeout } = require('node:timers/promises')
|
|
|
5
5
|
const { Promex } = require('promex')
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
7
|
const { verdictOf, PARK } = require('./verdicts')
|
|
8
|
+
const { Unroutable } = require('./unroutable')
|
|
8
9
|
|
|
9
10
|
const { decode } = require('./decode')
|
|
10
11
|
const { encode } = require('./encode')
|
|
@@ -75,31 +76,88 @@ class IO {
|
|
|
75
76
|
await this.#requests.consume(queue, consumer)
|
|
76
77
|
})
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} queue
|
|
81
|
+
* @param {any | Readable} payload
|
|
82
|
+
* @param {comq.Encoding | comq.RequestOptions} [options]
|
|
83
|
+
* @returns {Promise<any | Readable>}
|
|
84
|
+
*/
|
|
85
|
+
request (queue, payload, options) {
|
|
86
|
+
const settled = terms(options)
|
|
87
|
+
|
|
88
|
+
// the channels and the queue a first Request declares are waited for within the terms too
|
|
89
|
+
return abortable(this.#request(queue, payload, settled), settled.signal)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A Request to whoever holds `key` on a routed `exchange`, through `back`. One nobody holds
|
|
94
|
+
* is returned by the broker and rejects with `Unroutable`.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} exchange
|
|
97
|
+
* @param {string} key
|
|
98
|
+
* @param {any} payload
|
|
99
|
+
* @param {comq.Encoding | comq.RequestOptions} [options]
|
|
100
|
+
* @returns {Promise<any | Readable>}
|
|
101
|
+
*/
|
|
102
|
+
call (exchange, key, payload, options) {
|
|
103
|
+
const settled = terms(options)
|
|
104
|
+
|
|
105
|
+
return abortable(this.#call(exchange, key, payload, settled), settled.signal)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Answers the Requests `call` makes under `key`, from a queue that belongs to this
|
|
110
|
+
* connection alone. Sealing withdraws the key before it stops consuming.
|
|
111
|
+
*/
|
|
112
|
+
back = lazy(this, this.#createRequestReplyChannels,
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} exchange
|
|
115
|
+
* @param {string} key
|
|
116
|
+
* @param {comq.Producer} callback
|
|
117
|
+
* @returns {Promise<void>}
|
|
118
|
+
*/
|
|
119
|
+
async (exchange, key, callback) => {
|
|
120
|
+
const consumer = this.#getRequestConsumer(callback)
|
|
121
|
+
|
|
122
|
+
await this.#requests.held(exchange, exchange + '.' + key, key, consumer)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// failsafe is aimed to retransmit unanswered messages; the terms are taken once, so a
|
|
126
|
+
// re-sent Request keeps the deadline of the first
|
|
127
|
+
#request = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
|
|
80
128
|
failsafe(this, this.#recover,
|
|
81
129
|
/**
|
|
82
130
|
* @param {string} queue
|
|
83
131
|
* @param {any | Readable} payload
|
|
84
|
-
* @param {comq.
|
|
132
|
+
* @param {comq.Terms} terms
|
|
85
133
|
* @returns {Promise<any | Readable>}
|
|
86
134
|
*/
|
|
87
|
-
async (queue, payload,
|
|
135
|
+
async (queue, payload, terms) => {
|
|
88
136
|
if (payload instanceof stream.Readable) {
|
|
89
137
|
return pipeline(
|
|
90
138
|
payload,
|
|
91
|
-
(payload) => this
|
|
139
|
+
(payload) => this.#request(queue, payload, terms),
|
|
92
140
|
this.#requests
|
|
93
141
|
)
|
|
94
142
|
}
|
|
95
143
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
await this.#requests.send(queue, buffer, request.properties)
|
|
144
|
+
return await this.#ask(queue, payload, terms,
|
|
145
|
+
(buffer, properties) => this.#requests.send(queue, buffer, properties))
|
|
146
|
+
}))
|
|
101
147
|
|
|
102
|
-
|
|
148
|
+
#call = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
|
|
149
|
+
failsafe(this, this.#recover,
|
|
150
|
+
/**
|
|
151
|
+
* @param {string} exchange
|
|
152
|
+
* @param {string} key
|
|
153
|
+
* @param {any} payload
|
|
154
|
+
* @param {comq.Terms} terms
|
|
155
|
+
* @returns {Promise<any | Readable>}
|
|
156
|
+
*/
|
|
157
|
+
async (exchange, key, payload, terms) => {
|
|
158
|
+
// mandatory: a Request nobody holds the key of is returned rather than dropped
|
|
159
|
+
return await this.#ask(exchange, payload, terms,
|
|
160
|
+
(buffer, properties) => this.#requests.route(exchange, key, buffer, { ...properties, mandatory: true }))
|
|
103
161
|
}))
|
|
104
162
|
|
|
105
163
|
consume = lazy(this, this.#createEventChannel,
|
|
@@ -221,6 +279,9 @@ class IO {
|
|
|
221
279
|
this.#replies = await this.#createChannel('reply')
|
|
222
280
|
|
|
223
281
|
this.#setupRetransmission()
|
|
282
|
+
|
|
283
|
+
// on a sharded connection, only once every shard has returned it
|
|
284
|
+
this.#requests.diagnose('return', this.#returned)
|
|
224
285
|
}
|
|
225
286
|
|
|
226
287
|
async #createEventChannel () {
|
|
@@ -350,22 +411,101 @@ class IO {
|
|
|
350
411
|
await callback(payload, message.properties)
|
|
351
412
|
})
|
|
352
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Sends a Request and returns what settles with its Reply, within the terms.
|
|
416
|
+
*
|
|
417
|
+
* @param {string} target the queue or the exchange the Request is published to
|
|
418
|
+
* @param {any} payload
|
|
419
|
+
* @param {comq.Terms} terms
|
|
420
|
+
* @param {(buffer: Buffer, properties: comq.amqp.options.Publish) => Promise<void>} publish
|
|
421
|
+
* @returns {Promise<any | Readable>}
|
|
422
|
+
*/
|
|
423
|
+
async #ask (target, payload, terms, publish) {
|
|
424
|
+
const { signal, expires } = terms
|
|
425
|
+
|
|
426
|
+
signal?.throwIfAborted()
|
|
427
|
+
|
|
428
|
+
const [buffer, contentType] = this.#encode(payload, terms.encoding)
|
|
429
|
+
const request = this.#createRequest(target, contentType, signal)
|
|
430
|
+
const properties = { ...request.properties }
|
|
431
|
+
|
|
432
|
+
if (expires !== undefined) {
|
|
433
|
+
const left = Math.ceil(expires - Date.now())
|
|
434
|
+
|
|
435
|
+
// a re-send past the deadline, ahead of the timer that is about to end it
|
|
436
|
+
if (left <= 0) throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
|
|
437
|
+
|
|
438
|
+
// what nobody has taken by the time its caller stops waiting, the broker drops
|
|
439
|
+
properties.expiration = String(left)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const reply = this.#createReply(request)
|
|
443
|
+
|
|
444
|
+
if (signal !== undefined) this.#abandon(request, reply, signal)
|
|
445
|
+
|
|
446
|
+
await abortable(publish(buffer, properties), signal)
|
|
447
|
+
|
|
448
|
+
return reply
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Stops waiting for a Reply once the signal aborts. The Request is forgotten at once, so a
|
|
453
|
+
* retransmission leaves it be, and a Reply arriving later finds nobody waiting for it.
|
|
454
|
+
*
|
|
455
|
+
* @param {comq.Request} request
|
|
456
|
+
* @param {Promex} reply
|
|
457
|
+
* @param {AbortSignal} signal
|
|
458
|
+
*/
|
|
459
|
+
#abandon (request, reply, signal) {
|
|
460
|
+
const abandon = () => {
|
|
461
|
+
request.emitter.off(request.properties.correlationId)
|
|
462
|
+
this.#pendingReplies.delete(reply)
|
|
463
|
+
reply.reject(signal.reason)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const settled = () => signal.removeEventListener('abort', abandon)
|
|
467
|
+
|
|
468
|
+
signal.addEventListener('abort', abandon, { once: true })
|
|
469
|
+
reply.then(settled, settled)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* A Request the broker returned reached nobody, and nobody will answer it.
|
|
474
|
+
*
|
|
475
|
+
* @param {comq.amqp.Message} message
|
|
476
|
+
*/
|
|
477
|
+
#returned = (message) => {
|
|
478
|
+
const { correlationId, replyTo } = message.properties
|
|
479
|
+
|
|
480
|
+
for (const [reply, request] of this.#pendingReplies) {
|
|
481
|
+
if (request.properties.correlationId !== correlationId) continue
|
|
482
|
+
if (request.emitter.queue !== replyTo) continue
|
|
483
|
+
|
|
484
|
+
request.emitter.off(correlationId)
|
|
485
|
+
this.#pendingReplies.delete(reply)
|
|
486
|
+
reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
|
|
487
|
+
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
353
492
|
/**
|
|
354
493
|
* The request holds no copy of what was sent: a retransmission encodes the
|
|
355
494
|
* payload anew, and an unanswered request would otherwise keep two of it.
|
|
356
495
|
*
|
|
357
496
|
* @param {string} queue
|
|
358
497
|
* @param {comq.Encoding} contentType
|
|
498
|
+
* @param {AbortSignal} [signal]
|
|
359
499
|
* @return {comq.Request}
|
|
360
500
|
*/
|
|
361
|
-
#createRequest (queue, contentType) {
|
|
501
|
+
#createRequest (queue, contentType, signal) {
|
|
362
502
|
const emitter = this.#emitters.get(queue)
|
|
363
503
|
const correlationId = emitter.next()
|
|
364
504
|
|
|
365
505
|
/** @type {comq.amqp.Properties} */
|
|
366
506
|
const properties = { contentType, correlationId, replyTo: emitter.queue }
|
|
367
507
|
|
|
368
|
-
return { emitter, properties }
|
|
508
|
+
return { emitter, properties, signal }
|
|
369
509
|
}
|
|
370
510
|
|
|
371
511
|
/**
|
|
@@ -417,6 +557,9 @@ class IO {
|
|
|
417
557
|
return reply.reject(RETRANSMISSION)
|
|
418
558
|
}
|
|
419
559
|
|
|
560
|
+
// the caller stopped waiting while the stream was starting
|
|
561
|
+
if (request.signal?.aborted === true) return stream.destroy()
|
|
562
|
+
|
|
420
563
|
reply.resolve(stream)
|
|
421
564
|
} else {
|
|
422
565
|
reply.resolve(payload)
|
|
@@ -550,6 +693,62 @@ const DEFAULT = 'application/json'
|
|
|
550
693
|
|
|
551
694
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
552
695
|
|
|
696
|
+
/**
|
|
697
|
+
* What a caller passed, settled once: a timeout becomes the moment the Request expires, so that
|
|
698
|
+
* every attempt of it shares one deadline.
|
|
699
|
+
*
|
|
700
|
+
* @param {comq.Encoding | comq.RequestOptions} [options]
|
|
701
|
+
* @returns {comq.Terms}
|
|
702
|
+
*/
|
|
703
|
+
function terms (options) {
|
|
704
|
+
if (typeof options !== 'object' || options === null) return { encoding: options }
|
|
705
|
+
|
|
706
|
+
const { encoding, timeout, signal } = options
|
|
707
|
+
const signals = []
|
|
708
|
+
|
|
709
|
+
if (signal !== undefined) signals.push(signal)
|
|
710
|
+
if (timeout !== undefined) signals.push(AbortSignal.timeout(timeout))
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
encoding,
|
|
714
|
+
expires: timeout === undefined ? undefined : Date.now() + timeout,
|
|
715
|
+
signal: signals.length === 0 ? undefined : AbortSignal.any(signals)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Waits for a promise, or for the signal to abort, whichever comes first. Declaring a channel,
|
|
721
|
+
* a queue or publishing waits out back pressure and a lost connection, and a caller that has
|
|
722
|
+
* stopped waiting is released from all of it.
|
|
723
|
+
*
|
|
724
|
+
* @template T
|
|
725
|
+
* @param {Promise<T>} promise
|
|
726
|
+
* @param {AbortSignal} [signal]
|
|
727
|
+
* @returns {Promise<T>}
|
|
728
|
+
*/
|
|
729
|
+
async function abortable (promise, signal) {
|
|
730
|
+
if (signal === undefined) return await promise
|
|
731
|
+
|
|
732
|
+
if (signal.aborted) {
|
|
733
|
+
// it settles later, with nobody waiting for it
|
|
734
|
+
promise.catch(() => undefined)
|
|
735
|
+
|
|
736
|
+
throw signal.reason
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let abort
|
|
740
|
+
|
|
741
|
+
const aborted = new Promise((_resolve, reject) => { abort = () => reject(signal.reason) })
|
|
742
|
+
|
|
743
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
return await Promise.race([promise, aborted])
|
|
747
|
+
} finally {
|
|
748
|
+
signal.removeEventListener('abort', abort)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
553
752
|
/**
|
|
554
753
|
* A verdict answers what should happen to a message now that it has failed and nobody
|
|
555
754
|
* is waiting for it. A Producer has a caller waiting, and what it is owed is a reply,
|
package/source/shards/channel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { Promex } = require('promex')
|
|
4
|
+
const { RETRY_PREFIX } = require('../channel')
|
|
4
5
|
const events = require('../events')
|
|
5
6
|
const emitter = require('../emitter')
|
|
6
7
|
|
|
@@ -68,6 +69,14 @@ class Channel {
|
|
|
68
69
|
await this.#every((channel) => channel.bound(exchange, queue, key, consumer))
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A key is held once any shard holds it. A shard where another connection holds it goes on
|
|
74
|
+
* claiming it, and holds it as well once it is let go.
|
|
75
|
+
*/
|
|
76
|
+
async held (exchange, queue, key, consumer) {
|
|
77
|
+
await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, consumer)))
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
async send (queue, buffer, options) {
|
|
72
81
|
await this.#one((channel) => channel.send(queue, buffer, options))
|
|
73
82
|
}
|
|
@@ -203,9 +212,11 @@ class Channel {
|
|
|
203
212
|
const report = () => this.#diagnostics.emit(RETURN, message, channel.index)
|
|
204
213
|
const attempt = (message.properties.headers?.[RETURN_HEADER] ?? 0) + 1
|
|
205
214
|
const rest = this.#pool.filter((one) => one !== channel)
|
|
215
|
+
const { exchange, routingKey } = message.fields
|
|
206
216
|
|
|
207
|
-
//
|
|
208
|
-
|
|
217
|
+
// a failed message waits on an exchange of comq's own, a fanout that is declared as one
|
|
218
|
+
// where it is consumed, and declaring it on another shard as anything else is refused
|
|
219
|
+
const exhausted = exchange.startsWith(RETRY_PREFIX) ||
|
|
209
220
|
attempt >= this.#connections.length ||
|
|
210
221
|
rest.length === 0
|
|
211
222
|
|
|
@@ -219,7 +230,13 @@ class Channel {
|
|
|
219
230
|
|
|
220
231
|
const next = rest[Math.floor(Math.random() * rest.length)]
|
|
221
232
|
|
|
222
|
-
|
|
233
|
+
// a Request under a key goes through its routed exchange, which `route` declares on that
|
|
234
|
+
// shard before publishing: publishing to an exchange a broker lacks closes the connection
|
|
235
|
+
const retried = exchange === DEFAULT
|
|
236
|
+
? next.fire(routingKey, message.content, properties)
|
|
237
|
+
: next.route(exchange, routingKey, message.content, properties)
|
|
238
|
+
|
|
239
|
+
retried.catch(report)
|
|
223
240
|
}
|
|
224
241
|
|
|
225
242
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Request the broker returned instead of routing: nothing was bound under its key, so it
|
|
5
|
+
* reached no one and did not run.
|
|
6
|
+
*/
|
|
7
|
+
class Unroutable extends Error {
|
|
8
|
+
/** @type {string} */
|
|
9
|
+
exchange
|
|
10
|
+
|
|
11
|
+
/** @type {string} */
|
|
12
|
+
key
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} exchange
|
|
16
|
+
* @param {string} key
|
|
17
|
+
*/
|
|
18
|
+
constructor (exchange, key) {
|
|
19
|
+
super(`Nothing is bound to '${exchange}' under '${key}'`)
|
|
20
|
+
|
|
21
|
+
this.name = 'Unroutable'
|
|
22
|
+
this.exchange = exchange
|
|
23
|
+
this.key = key
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
exports.Unroutable = Unroutable
|
package/test/amqplib.mock.js
CHANGED
|
@@ -14,6 +14,7 @@ class Channel extends EventEmitter {
|
|
|
14
14
|
assertQueue = jest.fn(async (name) => ({ queue: name ?? generate() }))
|
|
15
15
|
assertExchange = jest.fn(async () => undefined)
|
|
16
16
|
bindQueue = jest.fn(async () => undefined)
|
|
17
|
+
unbindQueue = jest.fn(async () => undefined)
|
|
17
18
|
publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
|
|
18
19
|
close = jest.fn(async () => undefined)
|
|
19
20
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// region setup
|
|
4
|
+
|
|
5
|
+
const { generate } = require('randomstring')
|
|
6
|
+
const { timeout, until } = require('./helpers')
|
|
7
|
+
|
|
8
|
+
const { amqplib } = require('./amqplib.mock')
|
|
9
|
+
const fixtures = require('./channel.fixtures')
|
|
10
|
+
const { create } = require('../source/channel')
|
|
11
|
+
|
|
12
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
13
|
+
let connection
|
|
14
|
+
|
|
15
|
+
/** @type {comq.Topology} */
|
|
16
|
+
let topology
|
|
17
|
+
|
|
18
|
+
/** @type {comq.Channel} */
|
|
19
|
+
let channel
|
|
20
|
+
|
|
21
|
+
/** @type {jest.MockedObject<comq.amqp.Channel>} */
|
|
22
|
+
let chan
|
|
23
|
+
|
|
24
|
+
const exchange = generate()
|
|
25
|
+
const queue = generate()
|
|
26
|
+
const key = generate()
|
|
27
|
+
const consumer = jest.fn()
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
jest.clearAllMocks()
|
|
31
|
+
|
|
32
|
+
global.COMQ_TESTING_CLAIM_BACKOFF = { base: 5, max: 5 }
|
|
33
|
+
|
|
34
|
+
connection = await amqplib.connect()
|
|
35
|
+
topology = fixtures.preset()
|
|
36
|
+
channel = await create(connection, topology)
|
|
37
|
+
chan = await getCreatedChannel(connection)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
delete global.COMQ_TESTING_CLAIM_BACKOFF
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// endregion
|
|
45
|
+
|
|
46
|
+
describe('held', () => {
|
|
47
|
+
it('should assert a direct exchange', async () => {
|
|
48
|
+
await channel.held(exchange, queue, key, consumer)
|
|
49
|
+
|
|
50
|
+
expect(chan.assertExchange).toHaveBeenCalledWith(exchange, 'direct', expect.anything())
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should declare the queue exclusive on a channel of its own', async () => {
|
|
54
|
+
await channel.held(exchange, queue, key, consumer)
|
|
55
|
+
|
|
56
|
+
const [probe] = await getProbes(connection)
|
|
57
|
+
|
|
58
|
+
expect(probe.assertQueue).toHaveBeenCalledWith(queue, { exclusive: true })
|
|
59
|
+
expect(probe.close).toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should bind the queue under the key', async () => {
|
|
63
|
+
await channel.held(exchange, queue, key, consumer)
|
|
64
|
+
|
|
65
|
+
expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, key)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should consume the queue', async () => {
|
|
69
|
+
await channel.held(exchange, queue, key, consumer)
|
|
70
|
+
|
|
71
|
+
expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.any(Object))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should claim the queue again while another connection holds it', async () => {
|
|
75
|
+
const taken = jest.fn()
|
|
76
|
+
|
|
77
|
+
channel.diagnose('taken', taken)
|
|
78
|
+
lock(connection, { times: 2 })
|
|
79
|
+
|
|
80
|
+
await channel.held(exchange, queue, key, consumer)
|
|
81
|
+
|
|
82
|
+
expect(taken).toHaveBeenCalledTimes(2)
|
|
83
|
+
expect(taken).toHaveBeenCalledWith(queue)
|
|
84
|
+
expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.any(Object))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should stop claiming once sealed', async () => {
|
|
88
|
+
lock(connection)
|
|
89
|
+
|
|
90
|
+
const holding = channel.held(exchange, queue, key, consumer)
|
|
91
|
+
|
|
92
|
+
await timeout(20)
|
|
93
|
+
await channel.seal()
|
|
94
|
+
await holding
|
|
95
|
+
|
|
96
|
+
expect(chan.bindQueue).not.toHaveBeenCalled()
|
|
97
|
+
expect(chan.consume).not.toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should reject with any other refusal', async () => {
|
|
101
|
+
lock(connection, { code: 403 })
|
|
102
|
+
|
|
103
|
+
await expect(channel.held(exchange, queue, key, consumer)).rejects.toMatchObject({ code: 403 })
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('seal', () => {
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
await channel.held(exchange, queue, key, consumer)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should withdraw the key', async () => {
|
|
113
|
+
await channel.seal()
|
|
114
|
+
|
|
115
|
+
expect(chan.unbindQueue).toHaveBeenCalledWith(queue, exchange, key)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should withdraw the key before it stops consuming', async () => {
|
|
119
|
+
await channel.seal()
|
|
120
|
+
|
|
121
|
+
const [unbound] = chan.unbindQueue.mock.invocationCallOrder
|
|
122
|
+
const [cancelled] = chan.cancel.mock.invocationCallOrder
|
|
123
|
+
|
|
124
|
+
expect(unbound).toBeLessThan(cancelled)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('recovery', () => {
|
|
129
|
+
const other = generate()
|
|
130
|
+
|
|
131
|
+
beforeEach(async () => {
|
|
132
|
+
await channel.held(exchange, queue, key, consumer)
|
|
133
|
+
await channel.consume(other, consumer)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
afterEach(async () => {
|
|
137
|
+
await channel.seal()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should hold the queue on the new connection', async () => {
|
|
141
|
+
const replacement = await amqplib.connect()
|
|
142
|
+
|
|
143
|
+
await channel.recover(replacement)
|
|
144
|
+
|
|
145
|
+
const repl = await getCreatedChannel(replacement)
|
|
146
|
+
|
|
147
|
+
expect(await until(() => repl.bindQueue.mock.calls.some(([bound]) => bound === queue), 1000)).toStrictEqual(true)
|
|
148
|
+
expect(repl.bindQueue).toHaveBeenCalledWith(queue, exchange, key)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should restore the rest while another connection holds the queue', async () => {
|
|
152
|
+
const replacement = await amqplib.connect()
|
|
153
|
+
|
|
154
|
+
lock(replacement, { main: true })
|
|
155
|
+
|
|
156
|
+
const recovered = channel.recover(replacement).then(() => true)
|
|
157
|
+
const hung = timeout(200).then(() => false)
|
|
158
|
+
|
|
159
|
+
await expect(Promise.race([recovered, hung])).resolves.toStrictEqual(true)
|
|
160
|
+
|
|
161
|
+
const repl = await getCreatedChannel(replacement)
|
|
162
|
+
|
|
163
|
+
expect(repl.consume).toHaveBeenCalledWith(other, expect.any(Function), expect.any(Object))
|
|
164
|
+
expect(repl.bindQueue).not.toHaveBeenCalledWith(queue, exchange, key)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should hold the queue once it is let go', async () => {
|
|
168
|
+
const replacement = await amqplib.connect()
|
|
169
|
+
|
|
170
|
+
lock(replacement, { main: true, times: 2 })
|
|
171
|
+
|
|
172
|
+
await channel.recover(replacement)
|
|
173
|
+
|
|
174
|
+
const repl = await getCreatedChannel(replacement)
|
|
175
|
+
|
|
176
|
+
expect(await until(() => repl.bindQueue.mock.calls.some(([bound]) => bound === queue), 1000)).toStrictEqual(true)
|
|
177
|
+
expect(repl.bindQueue).toHaveBeenCalledWith(queue, exchange, key)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Makes the channels a connection creates from now on refuse an exclusive queue, the way a
|
|
183
|
+
* broker does: by closing the channel, with the reason emitted as its error.
|
|
184
|
+
*
|
|
185
|
+
* @param {jest.MockedObject<comq.amqp.Connection>} conn
|
|
186
|
+
* @param {{ times?: number, code?: number, main?: boolean }} [options]
|
|
187
|
+
* `main` says the channel to be created first is the channel under test, which is left alone
|
|
188
|
+
*/
|
|
189
|
+
function lock (conn, { times = Infinity, code = 405, main = false } = {}) {
|
|
190
|
+
const create = conn.createChannel.getMockImplementation()
|
|
191
|
+
let skip = main && !topology.confirms
|
|
192
|
+
let refused = 0
|
|
193
|
+
|
|
194
|
+
conn.createChannel.mockImplementation(async () => {
|
|
195
|
+
const created = await create()
|
|
196
|
+
|
|
197
|
+
if (skip) {
|
|
198
|
+
skip = false
|
|
199
|
+
|
|
200
|
+
return created
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (refused < times) {
|
|
204
|
+
refused++
|
|
205
|
+
|
|
206
|
+
created.assertQueue.mockImplementation(async () => {
|
|
207
|
+
created.emit('error', Object.assign(new Error('Refused'), { code }))
|
|
208
|
+
|
|
209
|
+
throw new Error('Channel closed')
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return created
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {jest.MockedObject<comq.amqp.Connection>} conn
|
|
219
|
+
* @returns {Promise<jest.MockedObject<comq.amqp.Channel>>}
|
|
220
|
+
*/
|
|
221
|
+
function getCreatedChannel (conn) {
|
|
222
|
+
const method = `create${topology.confirms ? 'Confirm' : ''}Channel`
|
|
223
|
+
|
|
224
|
+
return conn[method].mock.results[0].value
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The channels a connection created to declare an exclusive queue on.
|
|
229
|
+
*
|
|
230
|
+
* @param {jest.MockedObject<comq.amqp.Connection>} conn
|
|
231
|
+
* @returns {Promise<jest.MockedObject<comq.amqp.Channel>[]>}
|
|
232
|
+
*/
|
|
233
|
+
async function getProbes (conn) {
|
|
234
|
+
const created = await Promise.all(conn.createChannel.mock.results.map((result) => result.value))
|
|
235
|
+
|
|
236
|
+
return topology.confirms ? created : created.slice(1)
|
|
237
|
+
}
|
package/test/connection.mock.js
CHANGED
|
@@ -10,6 +10,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
|
|
|
10
10
|
fire: jest.fn(async () => undefined),
|
|
11
11
|
subscribe: jest.fn(async () => undefined),
|
|
12
12
|
bound: jest.fn(async () => undefined),
|
|
13
|
+
held: jest.fn(async () => undefined),
|
|
13
14
|
publish: jest.fn(async () => undefined),
|
|
14
15
|
route: jest.fn(async () => undefined),
|
|
15
16
|
diagnose: jest.fn(async () => undefined),
|