comq 0.18.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/source/io.js CHANGED
@@ -4,6 +4,8 @@ const stream = require('node:stream')
4
4
  const { setTimeout } = require('node:timers/promises')
5
5
  const { Promex } = require('promex')
6
6
  const { memo, failsafe, lazy, track } = require('./attributes')
7
+ const { verdictOf, PARK } = require('./verdicts')
8
+ const { Unroutable } = require('./unroutable')
7
9
 
8
10
  const { decode } = require('./decode')
9
11
  const { encode } = require('./encode')
@@ -74,31 +76,88 @@ class IO {
74
76
  await this.#requests.consume(queue, consumer)
75
77
  })
76
78
 
77
- // failsafe is aimed to retransmit unanswered messages
78
- request = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
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],
79
128
  failsafe(this, this.#recover,
80
129
  /**
81
130
  * @param {string} queue
82
131
  * @param {any | Readable} payload
83
- * @param {comq.Encoding} [encoding]
132
+ * @param {comq.Terms} terms
84
133
  * @returns {Promise<any | Readable>}
85
134
  */
86
- async (queue, payload, encoding) => {
135
+ async (queue, payload, terms) => {
87
136
  if (payload instanceof stream.Readable) {
88
137
  return pipeline(
89
138
  payload,
90
- (payload) => this.request(queue, payload, encoding),
139
+ (payload) => this.#request(queue, payload, terms),
91
140
  this.#requests
92
141
  )
93
142
  }
94
143
 
95
- const [buffer, contentType] = this.#encode(payload, encoding)
96
- const request = this.#createRequest(queue, contentType)
97
- const reply = this.#createReply(request)
98
-
99
- 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
+ }))
100
147
 
101
- return reply
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 }))
102
161
  }))
103
162
 
104
163
  consume = lazy(this, this.#createEventChannel,
@@ -220,6 +279,9 @@ class IO {
220
279
  this.#replies = await this.#createChannel('reply')
221
280
 
222
281
  this.#setupRetransmission()
282
+
283
+ // on a sharded connection, only once every shard has returned it
284
+ this.#requests.diagnose('return', this.#returned)
223
285
  }
224
286
 
225
287
  async #createEventChannel () {
@@ -302,7 +364,7 @@ class IO {
302
364
  */
303
365
  async (request) => {
304
366
  const payload = decode(request)
305
- const reply = await producer(payload)
367
+ const reply = await produce(producer, payload)
306
368
 
307
369
  if (request.properties.replyTo === undefined) return
308
370
 
@@ -349,22 +411,101 @@ class IO {
349
411
  await callback(payload, message.properties)
350
412
  })
351
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
+
352
492
  /**
353
493
  * The request holds no copy of what was sent: a retransmission encodes the
354
494
  * payload anew, and an unanswered request would otherwise keep two of it.
355
495
  *
356
496
  * @param {string} queue
357
497
  * @param {comq.Encoding} contentType
498
+ * @param {AbortSignal} [signal]
358
499
  * @return {comq.Request}
359
500
  */
360
- #createRequest (queue, contentType) {
501
+ #createRequest (queue, contentType, signal) {
361
502
  const emitter = this.#emitters.get(queue)
362
503
  const correlationId = emitter.next()
363
504
 
364
505
  /** @type {comq.amqp.Properties} */
365
506
  const properties = { contentType, correlationId, replyTo: emitter.queue }
366
507
 
367
- return { emitter, properties }
508
+ return { emitter, properties, signal }
368
509
  }
369
510
 
370
511
  /**
@@ -416,6 +557,9 @@ class IO {
416
557
  return reply.reject(RETRANSMISSION)
417
558
  }
418
559
 
560
+ // the caller stopped waiting while the stream was starting
561
+ if (request.signal?.aborted === true) return stream.destroy()
562
+
419
563
  reply.resolve(stream)
420
564
  } else {
421
565
  reply.resolve(payload)
@@ -549,4 +693,85 @@ const DEFAULT = 'application/json'
549
693
 
550
694
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
551
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
+
752
+ /**
753
+ * A verdict answers what should happen to a message now that it has failed and nobody
754
+ * is waiting for it. A Producer has a caller waiting, and what it is owed is a reply,
755
+ * so `Park` is a category error there rather than a policy choice — comq's own type
756
+ * vocabulary already draws the line between a Consumer and a Producer.
757
+ *
758
+ * It cannot be caught at wiring time, since nothing there knows what a producer will
759
+ * throw. Caught here instead and re-thrown as an ordinary rejection, which retries and
760
+ * eventually parks, carrying an explanation the parked message keeps as its reason.
761
+ *
762
+ * @param {comq.Producer} producer
763
+ * @param {any} payload
764
+ */
765
+ async function produce (producer, payload) {
766
+ try {
767
+ return await producer(payload)
768
+ } catch (exception) {
769
+ if (verdictOf(exception) !== PARK) throw exception
770
+
771
+ throw new Error('Park is not applicable to a reply producer: a Request has a caller ' +
772
+ 'awaiting a reply, so it is retried and parked on the count like any other failure',
773
+ { cause: exception })
774
+ }
775
+ }
776
+
552
777
  exports.IO = IO
@@ -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
- // only replies are published to the default exchange, and only they are mandatory
208
- const exhausted = message.fields.exchange !== DEFAULT ||
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
- next.fire(message.fields.routingKey, message.content, properties).catch(report)
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
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * How a consumer says what should happen to a message it could not handle.
5
+ *
6
+ * The brand is a registered symbol rather than the class identity: comq is a library,
7
+ * and a dependency that pins it while the application resolves its own tree makes two
8
+ * copies of it the ordinary outcome. `instanceof` is silently `false` across those,
9
+ * which would read a `Park` as an unclassified rejection and retry it to exhaustion
10
+ * before parking it anyway. The global symbol registry is shared; a class is not.
11
+ */
12
+ const VERDICT = Symbol.for('comq.verdict')
13
+
14
+ const RETRY = 'retry'
15
+ const PARK = 'park'
16
+
17
+ /**
18
+ * Whatever the consumer needed was briefly not there. This is what an unclassified
19
+ * rejection already means, so throwing it changes nothing but the reader's certainty.
20
+ */
21
+ class Retry extends Error {
22
+ [VERDICT] = RETRY
23
+
24
+ name = 'Retry'
25
+ }
26
+
27
+ /**
28
+ * This consumer will never process this message, however many times it is handed over.
29
+ * The message is kept rather than retried.
30
+ */
31
+ class Park extends Error {
32
+ [VERDICT] = PARK
33
+
34
+ name = 'Park'
35
+ }
36
+
37
+ /**
38
+ * A rejection nobody classified is a rejection nobody chose: it means Retry.
39
+ *
40
+ * @param {any} exception
41
+ * @returns {'retry' | 'park'}
42
+ */
43
+ const verdictOf = (exception) => {
44
+ const verdict = exception?.[VERDICT]
45
+
46
+ return verdict === PARK || verdict === RETRY ? verdict : RETRY
47
+ }
48
+
49
+ exports.Retry = Retry
50
+ exports.Park = Park
51
+ exports.verdictOf = verdictOf
52
+ exports.VERDICT = VERDICT
53
+ exports.RETRY = RETRY
54
+ exports.PARK = PARK
@@ -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
+ }