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/discussions/poison-message-handling.md +14 -10
- package/docs/headers.md +1 -0
- package/features/events.poison.feature +11 -0
- 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/events.js +15 -0
- package/features/steps/network.js +8 -0
- package/features/steps/networks.js +24 -4
- package/features/steps/parked.js +102 -0
- package/features/steps/poison.js +3 -2
- package/package.json +1 -1
- package/readme.md +176 -31
- package/source/attributes/recall.js +2 -1
- package/source/channel.js +138 -3
- package/source/events.js +1 -1
- package/source/index.js +6 -0
- package/source/io.js +239 -14
- package/source/shards/channel.js +20 -3
- package/source/unroutable.js +27 -0
- package/source/verdicts.js +54 -0
- package/test/amqplib.mock.js +1 -0
- package/test/channel.held.test.js +237 -0
- package/test/channel.test.js +104 -0
- package/test/connection.mock.js +1 -0
- package/test/io.call.test.js +161 -0
- package/test/io.reply.test.js +66 -0
- package/test/io.request.terms.test.js +247 -0
- package/test/shards/channel.test.js +68 -0
- package/test/verdicts.test.js +57 -0
- package/types/channel.d.ts +3 -0
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +26 -0
- package/types/io.d.ts +42 -2
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
|
-
|
|
78
|
-
|
|
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.
|
|
132
|
+
* @param {comq.Terms} terms
|
|
84
133
|
* @returns {Promise<any | Readable>}
|
|
85
134
|
*/
|
|
86
|
-
async (queue, payload,
|
|
135
|
+
async (queue, payload, terms) => {
|
|
87
136
|
if (payload instanceof stream.Readable) {
|
|
88
137
|
return pipeline(
|
|
89
138
|
payload,
|
|
90
|
-
(payload) => this
|
|
139
|
+
(payload) => this.#request(queue, payload, terms),
|
|
91
140
|
this.#requests
|
|
92
141
|
)
|
|
93
142
|
}
|
|
94
143
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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
|
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
|
|
@@ -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
|
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
|
+
}
|