comq 0.22.0 → 0.23.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/one-parking-queue.md +63 -0
- package/discussions/one-reply-queue.md +44 -0
- package/discussions/poison-message-handling.md +2 -1
- package/docs/headers.md +1 -1
- package/features/parking.feature +32 -0
- package/features/rpc.replies.feature +26 -0
- package/features/steps/brokers.js +25 -1
- package/features/steps/parked.js +7 -18
- package/features/steps/parking.js +113 -0
- package/features/steps/poison.js +17 -19
- package/features/steps/replies.js +35 -0
- package/package.json +1 -1
- package/readme.md +17 -33
- package/source/.io/ReplyPipe.js +16 -1
- package/source/channel.js +19 -27
- package/source/index.js +0 -2
- package/source/io.js +32 -49
- package/test/ReplyPipe.test.js +40 -0
- package/test/channel.test.js +29 -18
- package/test/io.request.test.js +13 -3
- package/types/index.d.ts +0 -6
- package/types/io.d.ts +0 -6
- package/features/closing.feature +0 -32
- package/features/steps/closing.js +0 -109
- package/source/abandoned.js +0 -15
package/source/channel.js
CHANGED
|
@@ -27,14 +27,6 @@ class Channel {
|
|
|
27
27
|
/** @type {string[]} */
|
|
28
28
|
#tags = []
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* How each queue was declared, so that the queue holding what it could not
|
|
32
|
-
* process is declared to live exactly as long as it does.
|
|
33
|
-
*
|
|
34
|
-
* @type {Map<string, comq.amqp.options.Queue>}
|
|
35
|
-
*/
|
|
36
|
-
#queues = new Map()
|
|
37
|
-
|
|
38
30
|
/**
|
|
39
31
|
* The exchange and key each held queue is bound under, so that sealing withdraws them.
|
|
40
32
|
*
|
|
@@ -88,8 +80,7 @@ class Channel {
|
|
|
88
80
|
// the consumers of the previous channel went down with it, their tags mean nothing here
|
|
89
81
|
this.#tags = []
|
|
90
82
|
|
|
91
|
-
// a fresh channel has
|
|
92
|
-
this.#queues.clear()
|
|
83
|
+
// a fresh channel has bound nothing
|
|
93
84
|
this.#held.clear()
|
|
94
85
|
|
|
95
86
|
await this.#channel.prefetch(this.#topology.prefetch)
|
|
@@ -384,8 +375,6 @@ class Channel {
|
|
|
384
375
|
|
|
385
376
|
const { queue } = await this.#channel.assertQueue(name, options)
|
|
386
377
|
|
|
387
|
-
this.#queues.set(queue, options)
|
|
388
|
-
|
|
389
378
|
return [queue]
|
|
390
379
|
}
|
|
391
380
|
|
|
@@ -485,21 +474,23 @@ class Channel {
|
|
|
485
474
|
* The queue a message is kept in once it has run out of attempts. It has no consumer
|
|
486
475
|
* either: what is in it is waiting for a person.
|
|
487
476
|
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
477
|
+
* One queue serves every queue this channel consumes, as one retry queue serves every
|
|
478
|
+
* source that shares its delay. A parked message carries the queue it came from, so a
|
|
479
|
+
* queue per source would name what the message already says, and stay on the broker
|
|
480
|
+
* long after the queue it was named for has gone.
|
|
481
|
+
*
|
|
482
|
+
* It is durable whatever the channel is. A groupless subscriber's own queue goes with
|
|
483
|
+
* its connection, but what it could not process is waiting for a person, and there is
|
|
484
|
+
* nobody left to tell.
|
|
493
485
|
*
|
|
494
|
-
* @param {string} queue the queue the message was consumed from
|
|
495
486
|
* @returns {Promise<void>}
|
|
496
487
|
*/
|
|
497
|
-
async #assertParkedQueue (
|
|
488
|
+
async #assertParkedQueue () {
|
|
498
489
|
if (!this.#topology.acknowledgments) return
|
|
499
490
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
await this.#assertQueue(
|
|
491
|
+
// declared here rather than when a message is first parked, for the reason the retry
|
|
492
|
+
// queues are: the failure handler is the least likely moment for a declare to succeed
|
|
493
|
+
await this.#assertQueue(PARKED, DURABLE)
|
|
503
494
|
}
|
|
504
495
|
|
|
505
496
|
/** The waits between attempts, as a ladder even when it is one rung. */
|
|
@@ -556,8 +547,9 @@ class Channel {
|
|
|
556
547
|
}
|
|
557
548
|
|
|
558
549
|
/**
|
|
559
|
-
* The retry topology is asserted once per channel
|
|
560
|
-
*
|
|
550
|
+
* The retry and parking topology is asserted once per channel: `lazy` keys an
|
|
551
|
+
* initializer by the arguments it takes, and neither takes any. Both again after
|
|
552
|
+
* a recovery.
|
|
561
553
|
*/
|
|
562
554
|
#consume = lazy(this, [this.#assertRetryQueue, this.#assertParkedQueue],
|
|
563
555
|
/**
|
|
@@ -660,7 +652,7 @@ class Channel {
|
|
|
660
652
|
[PARKED_AT_HEADER]: Date.now()
|
|
661
653
|
})
|
|
662
654
|
|
|
663
|
-
await this.#publish(DEFAULT,
|
|
655
|
+
await this.#publish(DEFAULT, PARKED, message.content, properties)
|
|
664
656
|
|
|
665
657
|
this.#channel.ack(message)
|
|
666
658
|
|
|
@@ -775,9 +767,9 @@ const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
|
|
|
775
767
|
const RESOURCE_LOCKED = 405
|
|
776
768
|
|
|
777
769
|
const RETRY_PREFIX = 'comq.retry.'
|
|
778
|
-
const PARKED_PREFIX = 'comq.parked.'
|
|
779
770
|
|
|
780
|
-
|
|
771
|
+
/** Where every message that has run out of attempts is kept, whatever it was consumed from. */
|
|
772
|
+
const PARKED = 'comq.parked'
|
|
781
773
|
|
|
782
774
|
// Everything comq writes onto a message, under its own prefix: AMQP defines no retry
|
|
783
775
|
// counter, so this one is comq's invention rather than a convention, and an unprefixed
|
package/source/index.js
CHANGED
|
@@ -4,7 +4,6 @@ const { connect, assert } = require('./connect')
|
|
|
4
4
|
const { Retry, Park } = require('./verdicts')
|
|
5
5
|
const { Unroutable } = require('./unroutable')
|
|
6
6
|
const { Interrupted } = require('./interrupted')
|
|
7
|
-
const { Abandoned } = require('./abandoned')
|
|
8
7
|
|
|
9
8
|
exports.connect = connect
|
|
10
9
|
exports.assert = assert
|
|
@@ -13,4 +12,3 @@ exports.Retry = Retry
|
|
|
13
12
|
exports.Park = Park
|
|
14
13
|
exports.Unroutable = Unroutable
|
|
15
14
|
exports.Interrupted = Interrupted
|
|
16
|
-
exports.Abandoned = Abandoned
|
package/source/io.js
CHANGED
|
@@ -6,7 +6,6 @@ const { Promex } = require('promex')
|
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
7
|
const { verdictOf, PARK } = require('./verdicts')
|
|
8
8
|
const { Unroutable } = require('./unroutable')
|
|
9
|
-
const { Abandoned } = require('./abandoned')
|
|
10
9
|
|
|
11
10
|
const { decode } = require('./decode')
|
|
12
11
|
const { encode } = require('./encode')
|
|
@@ -31,8 +30,15 @@ class IO {
|
|
|
31
30
|
/** @type {comq.Channel} */
|
|
32
31
|
#events
|
|
33
32
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Where this IO's replies arrive. One queue answers every request it makes: a reply is
|
|
35
|
+
* found by its correlation identifier, which is unique across processes, so a queue per
|
|
36
|
+
* target would name what the identifier already tells apart — and the broker would hold
|
|
37
|
+
* one for every distinct queue the caller has ever requested.
|
|
38
|
+
*
|
|
39
|
+
* @type {comq.ReplyEmitter}
|
|
40
|
+
*/
|
|
41
|
+
#emitter
|
|
36
42
|
|
|
37
43
|
/** @type {comq.ReplyEmitter | null} */
|
|
38
44
|
#control = null
|
|
@@ -40,9 +46,6 @@ class IO {
|
|
|
40
46
|
/** @type {Map<Promex, comq.Request>} */
|
|
41
47
|
#pendingReplies = new Map()
|
|
42
48
|
|
|
43
|
-
/** Whether this has stopped waiting for Replies, see `abandon`. */
|
|
44
|
-
#abandoning = false
|
|
45
|
-
|
|
46
49
|
/** @type {[comq.diagnostics.Event, Function][]} */
|
|
47
50
|
#forwarders = []
|
|
48
51
|
|
|
@@ -254,30 +257,6 @@ class IO {
|
|
|
254
257
|
await this.#destroyStreams(this.#replyStreams)
|
|
255
258
|
})
|
|
256
259
|
|
|
257
|
-
/**
|
|
258
|
-
* Stops waiting for Replies: every one still outstanding is rejected with `Abandoned`, and a
|
|
259
|
-
* Request made from now on is refused with it.
|
|
260
|
-
*
|
|
261
|
-
* A close waits for every consumer callback to return, and such a callback may be waiting on
|
|
262
|
-
* a Request of its own. Where the answer to that Request is the very thing going away — the
|
|
263
|
-
* process it would come from is shutting down alongside this one — the close waits for as
|
|
264
|
-
* long as the answer takes. This is how that wait is ended. What the Requests themselves do
|
|
265
|
-
* is not affected: they were sent and may well be processed; what is given up is this side's
|
|
266
|
-
* interest in the answers.
|
|
267
|
-
*
|
|
268
|
-
* Callable at any time, including while a close is already under way.
|
|
269
|
-
*/
|
|
270
|
-
abandon = memo(async () => {
|
|
271
|
-
this.#abandoning = true
|
|
272
|
-
|
|
273
|
-
// the same three steps `#abandon` takes for one Reply whose caller stopped waiting
|
|
274
|
-
for (const [reply, request] of this.#pendingReplies) {
|
|
275
|
-
request.emitter.off(request.properties.correlationId)
|
|
276
|
-
this.#pendingReplies.delete(reply)
|
|
277
|
-
reply.reject(new Abandoned())
|
|
278
|
-
}
|
|
279
|
-
})
|
|
280
|
-
|
|
281
260
|
close = memo(async () => {
|
|
282
261
|
await this.seal()
|
|
283
262
|
await this.#destroyStreams(this.#replyPipes)
|
|
@@ -340,13 +319,20 @@ class IO {
|
|
|
340
319
|
return [buffer, properties]
|
|
341
320
|
}
|
|
342
321
|
|
|
343
|
-
async #consumeReplies (
|
|
344
|
-
|
|
345
|
-
|
|
322
|
+
async #consumeReplies () {
|
|
323
|
+
this.#emitter = await this.#consumeRepliesOf(REPLY)
|
|
324
|
+
}
|
|
346
325
|
|
|
347
|
-
|
|
326
|
+
/**
|
|
327
|
+
* @param {string} label
|
|
328
|
+
* @returns {Promise<comq.ReplyEmitter>}
|
|
329
|
+
*/
|
|
330
|
+
async #consumeRepliesOf (label) {
|
|
331
|
+
const emitter = io.createReplyEmitter(label)
|
|
332
|
+
|
|
333
|
+
await this.#replies.consume(emitter.queue, this.#getReplyConsumer(emitter))
|
|
348
334
|
|
|
349
|
-
|
|
335
|
+
return emitter
|
|
350
336
|
}
|
|
351
337
|
|
|
352
338
|
// endregion
|
|
@@ -417,11 +403,10 @@ class IO {
|
|
|
417
403
|
})
|
|
418
404
|
|
|
419
405
|
/**
|
|
420
|
-
* @param {string} queue
|
|
421
406
|
* @param {comq.ReplyEmitter} emitter
|
|
422
407
|
* @returns {comq.channels.Consumer}
|
|
423
408
|
*/
|
|
424
|
-
#getReplyConsumer = (
|
|
409
|
+
#getReplyConsumer = (emitter) =>
|
|
425
410
|
(message) => {
|
|
426
411
|
const payload = decode(message)
|
|
427
412
|
|
|
@@ -453,11 +438,8 @@ class IO {
|
|
|
453
438
|
|
|
454
439
|
signal?.throwIfAborted()
|
|
455
440
|
|
|
456
|
-
// one made now would be waited for by nobody: the Replies are abandoned already
|
|
457
|
-
if (this.#abandoning) throw new Abandoned()
|
|
458
|
-
|
|
459
441
|
const [buffer, contentType] = this.#encode(payload, terms.encoding)
|
|
460
|
-
const request = this.#createRequest(
|
|
442
|
+
const request = this.#createRequest(contentType, signal)
|
|
461
443
|
const properties = { ...request.properties }
|
|
462
444
|
|
|
463
445
|
if (expires !== undefined) {
|
|
@@ -524,13 +506,12 @@ class IO {
|
|
|
524
506
|
* The request holds no copy of what was sent: a retransmission encodes the
|
|
525
507
|
* payload anew, and an unanswered request would otherwise keep two of it.
|
|
526
508
|
*
|
|
527
|
-
* @param {string} queue
|
|
528
509
|
* @param {comq.Encoding} contentType
|
|
529
510
|
* @param {AbortSignal} [signal]
|
|
530
511
|
* @return {comq.Request}
|
|
531
512
|
*/
|
|
532
|
-
#createRequest (
|
|
533
|
-
const emitter = this.#
|
|
513
|
+
#createRequest (contentType, signal) {
|
|
514
|
+
const emitter = this.#emitter
|
|
534
515
|
const correlationId = emitter.next()
|
|
535
516
|
|
|
536
517
|
/** @type {comq.amqp.Properties} */
|
|
@@ -611,11 +592,7 @@ class IO {
|
|
|
611
592
|
* @return {Promise<comq.ReplyEmitter>}
|
|
612
593
|
*/
|
|
613
594
|
async #createControl () {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
await this.#consumeReplies(queue)
|
|
617
|
-
|
|
618
|
-
return this.#emitters.get(queue)
|
|
595
|
+
return await this.#consumeRepliesOf(CONTROL)
|
|
619
596
|
}
|
|
620
597
|
|
|
621
598
|
/**
|
|
@@ -724,6 +701,12 @@ const DEFAULT = 'application/json'
|
|
|
724
701
|
|
|
725
702
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
726
703
|
|
|
704
|
+
/** What this IO's replies arrive on, before the random suffix that makes the queue its own. */
|
|
705
|
+
const REPLY = 'comq.reply'
|
|
706
|
+
|
|
707
|
+
/** What a producer steers its reply streams from, likewise. */
|
|
708
|
+
const CONTROL = 'control'
|
|
709
|
+
|
|
727
710
|
/**
|
|
728
711
|
* What a caller passed, settled once: a timeout becomes the moment the Request expires, so that
|
|
729
712
|
* every attempt of it shares one deadline.
|
package/test/ReplyPipe.test.js
CHANGED
|
@@ -168,3 +168,43 @@ it('should heartbeat while idle', async () => {
|
|
|
168
168
|
|
|
169
169
|
pipe.destroy()
|
|
170
170
|
})
|
|
171
|
+
|
|
172
|
+
// the confirmation is published before the source is read, and a blocked broker holds the publish
|
|
173
|
+
// for as long as it is blocked: the source may fail in the meantime, and nobody listens to it yet
|
|
174
|
+
it('should survive a source that fails before the confirmation is sent', async () => {
|
|
175
|
+
const gate = new Promex()
|
|
176
|
+
|
|
177
|
+
reply.mockImplementation(async (message, properties) => {
|
|
178
|
+
sent.push([message, properties])
|
|
179
|
+
await gate
|
|
180
|
+
|
|
181
|
+
return true
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const source = new Readable({ objectMode: true, read () {} })
|
|
185
|
+
const creating = ReplyPipe.create(request, source, channel, feedback, reply)
|
|
186
|
+
|
|
187
|
+
source.destroy(new Error('nothing arrived'))
|
|
188
|
+
|
|
189
|
+
await timeout(10)
|
|
190
|
+
|
|
191
|
+
gate.resolve()
|
|
192
|
+
|
|
193
|
+
const pipe = await creating
|
|
194
|
+
|
|
195
|
+
await closing(pipe)
|
|
196
|
+
|
|
197
|
+
expect(source.destroyed).toStrictEqual(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should destroy the source if the confirmation cannot be sent', async () => {
|
|
201
|
+
reply.mockImplementation(async () => {
|
|
202
|
+
throw new Error('channel closed')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const source = new Readable({ objectMode: true, read () {} })
|
|
206
|
+
|
|
207
|
+
await expect(ReplyPipe.create(request, source, channel, feedback, reply)).rejects.toThrow('channel closed')
|
|
208
|
+
|
|
209
|
+
expect(source.destroyed).toStrictEqual(true)
|
|
210
|
+
})
|
package/test/channel.test.js
CHANGED
|
@@ -1040,6 +1040,7 @@ describe('release', () => {
|
|
|
1040
1040
|
describe('failed messages', () => {
|
|
1041
1041
|
const DELAY = 1000
|
|
1042
1042
|
const RETRY = 'comq.retry.' + DELAY
|
|
1043
|
+
const PARKED = 'comq.parked'
|
|
1043
1044
|
|
|
1044
1045
|
let queue
|
|
1045
1046
|
let exception
|
|
@@ -1108,10 +1109,18 @@ describe('failed messages', () => {
|
|
|
1108
1109
|
it('should assert the parked queue', async () => {
|
|
1109
1110
|
await channel.consume(queue, consumer)
|
|
1110
1111
|
|
|
1111
|
-
expect(chan.assertQueue).toHaveBeenCalledWith(
|
|
1112
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(PARKED,
|
|
1112
1113
|
expect.objectContaining({ durable: true }))
|
|
1113
1114
|
})
|
|
1114
1115
|
|
|
1116
|
+
it('should not name the parked queue after the queue it serves', async () => {
|
|
1117
|
+
await channel.consume(queue, consumer)
|
|
1118
|
+
|
|
1119
|
+
const named = chan.assertQueue.mock.calls.filter(([name]) => name.includes(queue))
|
|
1120
|
+
|
|
1121
|
+
expect(named).toHaveLength(1) // the source queue itself
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1115
1124
|
it('should assert the source queue first', async () => {
|
|
1116
1125
|
await channel.consume(queue, consumer)
|
|
1117
1126
|
|
|
@@ -1130,15 +1139,14 @@ describe('failed messages', () => {
|
|
|
1130
1139
|
expect(queues).toHaveLength(1)
|
|
1131
1140
|
})
|
|
1132
1141
|
|
|
1133
|
-
it('should assert
|
|
1134
|
-
|
|
1135
|
-
|
|
1142
|
+
it('should assert one parked queue however many queues are consumed', async () => {
|
|
1143
|
+
await channel.consume(generate(), consumer)
|
|
1144
|
+
await channel.consume(generate(), consumer)
|
|
1145
|
+
await channel.consume(generate(), consumer)
|
|
1136
1146
|
|
|
1137
|
-
|
|
1138
|
-
await channel.consume(another, consumer)
|
|
1147
|
+
const parked = chan.assertQueue.mock.calls.filter(([name]) => name === PARKED)
|
|
1139
1148
|
|
|
1140
|
-
expect(
|
|
1141
|
-
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + another, expect.anything())
|
|
1149
|
+
expect(parked).toHaveLength(1)
|
|
1142
1150
|
})
|
|
1143
1151
|
|
|
1144
1152
|
it('should not assert either without acknowledgments', async () => {
|
|
@@ -1156,7 +1164,9 @@ describe('failed messages', () => {
|
|
|
1156
1164
|
expect(chan.assertExchange).not.toHaveBeenCalled()
|
|
1157
1165
|
})
|
|
1158
1166
|
|
|
1159
|
-
it('should declare the parked queue
|
|
1167
|
+
it('should declare the parked queue durable whatever the channel is', async () => {
|
|
1168
|
+
// what a groupless subscriber could not process is waiting for a person, and
|
|
1169
|
+
// there is nobody left to tell once its own queue has gone with its connection
|
|
1160
1170
|
jest.clearAllMocks()
|
|
1161
1171
|
|
|
1162
1172
|
topology.durable = false
|
|
@@ -1166,9 +1176,10 @@ describe('failed messages', () => {
|
|
|
1166
1176
|
await channel.consume(queue, consumer)
|
|
1167
1177
|
|
|
1168
1178
|
const [, options] = chan.assertQueue.mock.calls
|
|
1169
|
-
.find(([name]) => name ===
|
|
1179
|
+
.find(([name]) => name === PARKED)
|
|
1170
1180
|
|
|
1171
|
-
expect(options).toMatchObject({
|
|
1181
|
+
expect(options).toMatchObject({ durable: true })
|
|
1182
|
+
expect(options.exclusive).toBeUndefined()
|
|
1172
1183
|
})
|
|
1173
1184
|
|
|
1174
1185
|
it('should re-assert after recovery', async () => {
|
|
@@ -1181,7 +1192,7 @@ describe('failed messages', () => {
|
|
|
1181
1192
|
const repl = await getCreatedChannel(replacement)
|
|
1182
1193
|
|
|
1183
1194
|
expect(repl.assertExchange).toHaveBeenCalledWith(RETRY, 'fanout', expect.anything())
|
|
1184
|
-
expect(repl.assertQueue).toHaveBeenCalledWith(
|
|
1195
|
+
expect(repl.assertQueue).toHaveBeenCalledWith(PARKED, expect.anything())
|
|
1185
1196
|
})
|
|
1186
1197
|
})
|
|
1187
1198
|
|
|
@@ -1471,7 +1482,7 @@ describe('failed messages', () => {
|
|
|
1471
1482
|
|
|
1472
1483
|
expect(asserted()).toStrictEqual(['comq.retry.1000'])
|
|
1473
1484
|
expect(publications().map(([exchange, key]) => exchange || key))
|
|
1474
|
-
.toStrictEqual(['comq.retry.1000',
|
|
1485
|
+
.toStrictEqual(['comq.retry.1000', PARKED])
|
|
1475
1486
|
})
|
|
1476
1487
|
})
|
|
1477
1488
|
|
|
@@ -1486,7 +1497,7 @@ describe('failed messages', () => {
|
|
|
1486
1497
|
const [exchange, key] = publications()[0]
|
|
1487
1498
|
|
|
1488
1499
|
expect(exchange).toStrictEqual('')
|
|
1489
|
-
expect(key).toStrictEqual(
|
|
1500
|
+
expect(key).toStrictEqual(PARKED)
|
|
1490
1501
|
})
|
|
1491
1502
|
|
|
1492
1503
|
it('should not retry once the attempts are spent', async () => {
|
|
@@ -1563,7 +1574,7 @@ describe('failed messages', () => {
|
|
|
1563
1574
|
|
|
1564
1575
|
const targets = publications().map(([, key]) => key)
|
|
1565
1576
|
|
|
1566
|
-
expect(targets).toStrictEqual([queue,
|
|
1577
|
+
expect(targets).toStrictEqual([queue, PARKED])
|
|
1567
1578
|
})
|
|
1568
1579
|
|
|
1569
1580
|
it('should give a message one delivery more than the ladder has rungs', async () => {
|
|
@@ -1581,7 +1592,7 @@ describe('failed messages', () => {
|
|
|
1581
1592
|
|
|
1582
1593
|
const targets = publications().map(([, key]) => key)
|
|
1583
1594
|
|
|
1584
|
-
expect(targets).toStrictEqual([queue, queue,
|
|
1595
|
+
expect(targets).toStrictEqual([queue, queue, PARKED])
|
|
1585
1596
|
})
|
|
1586
1597
|
})
|
|
1587
1598
|
|
|
@@ -1596,7 +1607,7 @@ describe('failed messages', () => {
|
|
|
1596
1607
|
const [exchange, key] = publications()[0]
|
|
1597
1608
|
|
|
1598
1609
|
expect(exchange).toStrictEqual('')
|
|
1599
|
-
expect(key).toStrictEqual(
|
|
1610
|
+
expect(key).toStrictEqual(PARKED)
|
|
1600
1611
|
expect(publications()).toHaveLength(1)
|
|
1601
1612
|
})
|
|
1602
1613
|
|
|
@@ -1684,7 +1695,7 @@ describe('failed messages', () => {
|
|
|
1684
1695
|
|
|
1685
1696
|
await deliver(delivery({}))
|
|
1686
1697
|
|
|
1687
|
-
expect(publications()[0][1]).toStrictEqual(
|
|
1698
|
+
expect(publications()[0][1]).toStrictEqual(PARKED)
|
|
1688
1699
|
})
|
|
1689
1700
|
})
|
|
1690
1701
|
|
package/test/io.request.test.js
CHANGED
|
@@ -41,7 +41,7 @@ let replies
|
|
|
41
41
|
|
|
42
42
|
let promise
|
|
43
43
|
|
|
44
|
-
const REPLY =
|
|
44
|
+
const REPLY = /^comq\.reply\.\.[0-9a-f]+$/
|
|
45
45
|
|
|
46
46
|
beforeEach(async () => {
|
|
47
47
|
promise = io.request(queue, payload)
|
|
@@ -62,6 +62,17 @@ it('should consume replies queue', async () => {
|
|
|
62
62
|
expect(replies.consume).toHaveBeenCalledWith(expect.stringMatching(REPLY), expect.any(Function))
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
it('should consume one replies queue however many queues are requested', async () => {
|
|
66
|
+
// a reply is found by its correlation identifier, so a queue per target would
|
|
67
|
+
// name what the identifier already tells apart
|
|
68
|
+
io.request(generate(), payload)
|
|
69
|
+
io.request(generate(), payload)
|
|
70
|
+
|
|
71
|
+
await immediate()
|
|
72
|
+
|
|
73
|
+
expect(replies.consume).toHaveBeenCalledTimes(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
65
76
|
describe('send', () => {
|
|
66
77
|
let call
|
|
67
78
|
|
|
@@ -84,9 +95,8 @@ describe('send', () => {
|
|
|
84
95
|
|
|
85
96
|
it('should set replyTo', async () => {
|
|
86
97
|
const properties = call[2]
|
|
87
|
-
const rx = new RegExp(`^${queue}..[a-z0-9]+`)
|
|
88
98
|
|
|
89
|
-
expect(properties.replyTo).toMatch(
|
|
99
|
+
expect(properties.replyTo).toMatch(REPLY)
|
|
90
100
|
})
|
|
91
101
|
|
|
92
102
|
it('should consume replyTo', async () => {
|
package/types/index.d.ts
CHANGED
|
@@ -38,9 +38,3 @@ export class Unroutable extends Error {
|
|
|
38
38
|
export class Interrupted extends Error {
|
|
39
39
|
readonly reason: string
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A Reply nobody is waiting for any more, or a Request made once the waiting has stopped.
|
|
44
|
-
* See `IO.abandon`.
|
|
45
|
-
*/
|
|
46
|
-
export class Abandoned extends Error {}
|
package/types/io.d.ts
CHANGED
|
@@ -119,12 +119,6 @@ declare namespace comq {
|
|
|
119
119
|
|
|
120
120
|
seal(): Promise<void>
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* Stops waiting for Replies: every one still outstanding is rejected with `Abandoned`, and a
|
|
124
|
-
* Request made from now on is refused with it. Callable while a close is already under way.
|
|
125
|
-
*/
|
|
126
|
-
abandon(): Promise<void>
|
|
127
|
-
|
|
128
122
|
close(): Promise<void>
|
|
129
123
|
|
|
130
124
|
diagnose(event: 'open', listener: (index?: number) => void): void
|
package/features/closing.feature
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
Feature: Abandoning the Replies a close is waiting for
|
|
2
|
-
|
|
3
|
-
A consumer callback may call out while it runs, and a close waits for every callback to return.
|
|
4
|
-
Where the answer that callback waits for is the one thing going away with the close, the two
|
|
5
|
-
wait for each other. Abandoning ends that wait.
|
|
6
|
-
|
|
7
|
-
Background:
|
|
8
|
-
Given an active connection to the broker
|
|
9
|
-
|
|
10
|
-
Scenario: Abandoning lets a close finish
|
|
11
|
-
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
12
|
-
When the consumer sends a request to the `upstream` queue
|
|
13
|
-
And the producer is handling the request
|
|
14
|
-
And the connection abandons its replies
|
|
15
|
-
Then the connection closes within 1000ms
|
|
16
|
-
And the producer was told its reply is abandoned
|
|
17
|
-
And the consumer was told its own reply is abandoned
|
|
18
|
-
|
|
19
|
-
Scenario: A close already under way is told to stop waiting
|
|
20
|
-
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
21
|
-
When the consumer sends a request to the `upstream` queue
|
|
22
|
-
And the producer is handling the request
|
|
23
|
-
And the connection has started closing
|
|
24
|
-
And the connection abandons its replies
|
|
25
|
-
Then the connection closes within 1000ms
|
|
26
|
-
|
|
27
|
-
Scenario: A Request made once the Replies are abandoned is refused
|
|
28
|
-
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
29
|
-
When the consumer sends a request to the `upstream` queue
|
|
30
|
-
And the producer is handling the request
|
|
31
|
-
And the connection abandons its replies
|
|
32
|
-
Then a request to the `downstream` queue is refused as abandoned
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const assert = require('node:assert')
|
|
4
|
-
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
5
|
-
const { Abandoned } = require('../../')
|
|
6
|
-
const { timeout } = require('../../test/helpers')
|
|
7
|
-
|
|
8
|
-
Given('a producer replying {token} queue by requesting the {token} queue',
|
|
9
|
-
/**
|
|
10
|
-
* @param {string} queue
|
|
11
|
-
* @param {string} downstream
|
|
12
|
-
* @this {comq.features.Context}
|
|
13
|
-
*/
|
|
14
|
-
async function (queue, downstream) {
|
|
15
|
-
this.handling = new Promise((resolve) => { this.handled = resolve })
|
|
16
|
-
|
|
17
|
-
const producer = async (request) => {
|
|
18
|
-
const reply = this.io.request(downstream, request)
|
|
19
|
-
|
|
20
|
-
this.handled()
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
return await reply
|
|
24
|
-
} catch (error) {
|
|
25
|
-
// recorded rather than rethrown: what this asserts is what the caller was told,
|
|
26
|
-
// and a rejected producer would have the delivery retried on top of it
|
|
27
|
-
this.abandoned = error
|
|
28
|
-
|
|
29
|
-
return null
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
await this.io.reply(queue, producer)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
When('the producer is handling the request',
|
|
37
|
-
/**
|
|
38
|
-
* @this {comq.features.Context}
|
|
39
|
-
*/
|
|
40
|
-
async function () {
|
|
41
|
-
await this.handling
|
|
42
|
-
|
|
43
|
-
// the consumer's own Request is outstanding as well, and abandoning abandons that one too;
|
|
44
|
-
// its outcome is taken here so that it is asserted rather than left to surface as an
|
|
45
|
-
// unhandled rejection
|
|
46
|
-
this.reply.catch((error) => { this.refused = error })
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
When('the connection has started closing',
|
|
50
|
-
/**
|
|
51
|
-
* @this {comq.features.Context}
|
|
52
|
-
*/
|
|
53
|
-
async function () {
|
|
54
|
-
this.closing = this.io.close()
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
When('the connection abandons its replies',
|
|
58
|
-
/**
|
|
59
|
-
* @this {comq.features.Context}
|
|
60
|
-
*/
|
|
61
|
-
async function () {
|
|
62
|
-
await this.io.abandon()
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
Then('the connection closes within {number}ms',
|
|
66
|
-
/**
|
|
67
|
-
* @param {number} ms
|
|
68
|
-
* @this {comq.features.Context}
|
|
69
|
-
*/
|
|
70
|
-
async function (ms) {
|
|
71
|
-
const io = this.io
|
|
72
|
-
|
|
73
|
-
// taken off the world so that the hook on the way out does not await a close
|
|
74
|
-
// this may have just proven does not end
|
|
75
|
-
this.io = undefined
|
|
76
|
-
this.connected = false
|
|
77
|
-
|
|
78
|
-
const closing = io.close().then(() => true, () => true)
|
|
79
|
-
const closed = await Promise.race([closing, timeout(ms).then(() => false)])
|
|
80
|
-
|
|
81
|
-
assert.ok(closed, `The connection did not close within ${ms}ms`)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
Then('the producer was told its reply is abandoned',
|
|
85
|
-
/**
|
|
86
|
-
* @this {comq.features.Context}
|
|
87
|
-
*/
|
|
88
|
-
async function () {
|
|
89
|
-
assert.ok(this.abandoned instanceof Abandoned,
|
|
90
|
-
`The producer was told ${this.abandoned ?? 'nothing'}`)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
Then('the consumer was told its own reply is abandoned',
|
|
94
|
-
/**
|
|
95
|
-
* @this {comq.features.Context}
|
|
96
|
-
*/
|
|
97
|
-
async function () {
|
|
98
|
-
assert.ok(this.refused instanceof Abandoned,
|
|
99
|
-
`The consumer was told ${this.refused ?? 'nothing'}`)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
Then('a request to the {token} queue is refused as abandoned',
|
|
103
|
-
/**
|
|
104
|
-
* @param {string} queue
|
|
105
|
-
* @this {comq.features.Context}
|
|
106
|
-
*/
|
|
107
|
-
async function (queue) {
|
|
108
|
-
await assert.rejects(() => this.io.request(queue, null), Abandoned)
|
|
109
|
-
})
|
package/source/abandoned.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A Reply nobody is waiting for any more, or a Request made once the waiting has stopped. The
|
|
5
|
-
* Request itself was sent and may well be processed; what was given up is the answer.
|
|
6
|
-
*/
|
|
7
|
-
class Abandoned extends Error {
|
|
8
|
-
constructor () {
|
|
9
|
-
super('The connection no longer waits for a Reply')
|
|
10
|
-
|
|
11
|
-
this.name = 'Abandoned'
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
exports.Abandoned = Abandoned
|