comq 0.21.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 -8
- package/source/.io/ReplyPipe.js +16 -1
- package/source/batch.js +3 -3
- package/source/channel.js +19 -27
- package/source/io.js +32 -18
- package/test/ReplyPipe.test.js +40 -0
- package/test/channel.test.js +29 -18
- package/test/io.request.test.js +13 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# One parking queue
|
|
2
|
+
|
|
3
|
+
> Supersedes the parking queue's naming and lifetime in
|
|
4
|
+
> [poison message handling](./poison-message-handling.md), Part 1. Everything else about
|
|
5
|
+
> parking — when a message is parked, what it carries, what is published before what — is
|
|
6
|
+
> unchanged.
|
|
7
|
+
|
|
8
|
+
Everything a consumer could not process is kept in one queue, `comq.parked`, whatever it was
|
|
9
|
+
consumed from. It is durable, it is declared once per channel beside the retry queues, and comq
|
|
10
|
+
never removes it or what is in it.
|
|
11
|
+
|
|
12
|
+
A parked message already says where it came from. `x-comq-queue` is the queue it was consumed
|
|
13
|
+
from, `x-comq-exchange` and `x-comq-key` where it was originally published, and all three are
|
|
14
|
+
recorded on the first failure rather than read at parking time. Reading one source's parked
|
|
15
|
+
messages is a match on `x-comq-queue`.
|
|
16
|
+
|
|
17
|
+
## What this changes
|
|
18
|
+
|
|
19
|
+
| | before | after |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| parking queues | one per consumed queue | one |
|
|
22
|
+
| its lifetime | the lifetime of the queue it served | durable, always |
|
|
23
|
+
| what a groupless subscriber parks | went with its connection | kept |
|
|
24
|
+
| scales with | how many queues the connection consumes | nothing |
|
|
25
|
+
|
|
26
|
+
## Why the queue per source goes
|
|
27
|
+
|
|
28
|
+
**It was measured.** The cost of this design was worked through when it was made, at 200 source
|
|
29
|
+
queues and 200 parking queues, and accepted. In a deployment of about forty processes the
|
|
30
|
+
parking queues came to 1318 of 2731 queues on the broker — 48 % — and every one of them was
|
|
31
|
+
empty, with no consumer and no message it had ever held. At about 70 KB of broker memory per
|
|
32
|
+
queue, that is the largest single item on a broker that had been blocking publishers on a memory
|
|
33
|
+
alarm.
|
|
34
|
+
|
|
35
|
+
The count is what makes it grow: one per consumed queue means the broker pays for how widely an
|
|
36
|
+
application is decomposed, forever, whether or not anything ever fails.
|
|
37
|
+
|
|
38
|
+
**Depth per source survives.** The reason recorded for the queue per source was that depth per
|
|
39
|
+
consumer is how anyone notices a problem. What notices a problem is the `discard` diagnostic,
|
|
40
|
+
which fires per parked message and names the queue it came from. Depth in `comq.parked` still
|
|
41
|
+
says how much is waiting, and `x-comq-queue` still says what for.
|
|
42
|
+
|
|
43
|
+
**The lifetime rule goes with it.** The parking queue mirrored its source's durability so that a
|
|
44
|
+
durable parking queue would not be left behind an exclusive `amq.gen-*` source — one leaked per
|
|
45
|
+
restart, forever. With a fixed name there is nothing to leak, and the rule that prevented the
|
|
46
|
+
leak was throwing away the evidence it was meant to keep: a groupless subscriber's failures
|
|
47
|
+
vanished with its connection, at the moment there was nobody left to tell.
|
|
48
|
+
|
|
49
|
+
## Upgrading
|
|
50
|
+
|
|
51
|
+
`comq.parked.<queue>` queues from an earlier version keep whatever they already hold. comq stops
|
|
52
|
+
declaring them and stops publishing to them, so they are drained and deleted once, by hand.
|
|
53
|
+
|
|
54
|
+
Nothing is redeclared with different arguments, so no assertion answers `PRECONDITION_FAILED`
|
|
55
|
+
and no broker has to be emptied. During a rollout the processes still on the old version go on
|
|
56
|
+
parking per source and the new ones park in `comq.parked`; both are readable, and both say
|
|
57
|
+
which queue a message came from.
|
|
58
|
+
|
|
59
|
+
## What comq says about it
|
|
60
|
+
|
|
61
|
+
That the queue is there, that a message in it names its source, and that comq removes neither.
|
|
62
|
+
What to do with a queue on a broker — a `max-length`, a TTL, an alert on depth — is read off
|
|
63
|
+
those facts by whoever can see the broker.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# One reply queue
|
|
2
|
+
|
|
3
|
+
An `IO` receives every reply on one queue, `comq.reply..<id>`, whatever it sent the request to.
|
|
4
|
+
|
|
5
|
+
A reply is found by its `correlationId`. `ReplyEmitter.next` builds one from eight random bytes
|
|
6
|
+
and a counter, and it is unique across processes — it has to be, because a producer's control
|
|
7
|
+
queue sees the requests of every consumer and tells them apart by nothing else. Routing a reply
|
|
8
|
+
reads that identifier and nothing else: the queue it arrived on was never consulted.
|
|
9
|
+
|
|
10
|
+
## What this changes
|
|
11
|
+
|
|
12
|
+
| | before | after |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| reply queues | one per distinct queue or exchange requested | one |
|
|
15
|
+
| its name | `<target>..<id>` | `comq.reply..<id>` |
|
|
16
|
+
| scales with | how widely the caller fans out | nothing |
|
|
17
|
+
|
|
18
|
+
Both are exclusive, so both go with the connection. A sharded connection holds one per shard, as
|
|
19
|
+
it held one per target per shard before.
|
|
20
|
+
|
|
21
|
+
## Why the queue per target goes
|
|
22
|
+
|
|
23
|
+
The count was the caller's to set and the broker's to pay: a connection that requests fifty
|
|
24
|
+
queues held fifty queues and fifty consumers for as long as it lived, whether or not it ever
|
|
25
|
+
requested them again. Nothing bounded it but how widely the application was decomposed.
|
|
26
|
+
|
|
27
|
+
The name was the argument for it — a queue named after what is being called says, to somebody
|
|
28
|
+
reading the broker, who is waiting on what. It says it of a queue that carries no messages
|
|
29
|
+
between requests, and the same thing is on every message that does pass through: `correlationId`
|
|
30
|
+
identifies the request, and the reply-side handler knows which one it belongs to.
|
|
31
|
+
|
|
32
|
+
## What is given up
|
|
33
|
+
|
|
34
|
+
Reading the broker no longer tells you which operations a connection calls. A reply queue names
|
|
35
|
+
its connection instead, and what it is waiting for is in flight rather than on the broker.
|
|
36
|
+
|
|
37
|
+
## Upgrading
|
|
38
|
+
|
|
39
|
+
Reply queues are exclusive and are asserted on the first request, so a process on the old
|
|
40
|
+
version and one on the new hold different queues and neither declares the other's. Nothing is
|
|
41
|
+
redeclared, and a rollout needs no order.
|
|
42
|
+
|
|
43
|
+
A parked request keeps the `replyTo` it was sent with, so one parked by an old process still
|
|
44
|
+
names the queue that process was listening on — which, as before, is gone once that process is.
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
> where it survives a retry.
|
|
8
8
|
>
|
|
9
9
|
> **All three parts are implemented.** Text below is written as a proposal because that is what
|
|
10
|
-
> it was; it describes what the code now does
|
|
10
|
+
> it was; it describes what the code now does — except for the parking queue's name and
|
|
11
|
+
> lifetime, which [one parking queue](./one-parking-queue.md) supersedes.
|
|
11
12
|
|
|
12
13
|
When a consumer callback rejects, comq is supposed to retry the message a few times and then
|
|
13
14
|
give up on it. What it actually does is kill the process, and take every other consumer in that
|
package/docs/headers.md
CHANGED
|
@@ -26,7 +26,7 @@ the message properties.
|
|
|
26
26
|
| `x-comq-attempt` | a retried message | Which attempt this delivery is, counting from one. Absent on the first, so read it as `headers?.['x-comq-attempt'] ?? 1`. The last attempt is one more than the number of rungs in the [backoff ladder](../readme.md#retries). |
|
|
27
27
|
| `x-comq-exchange` | a retried or parked message | The exchange it was originally published to. |
|
|
28
28
|
| `x-comq-key` | a retried or parked message | The routing key it was originally published with. |
|
|
29
|
-
| `x-comq-queue` | a parked message | The queue it was consumed from. |
|
|
29
|
+
| `x-comq-queue` | a parked message | The queue it was consumed from. One `comq.parked` queue holds what every source parked, so this is what tells them apart. |
|
|
30
30
|
| `x-comq-reason` | a parked message | The `message` of the exception that ended its attempts. |
|
|
31
31
|
| `x-comq-cause` | a parked message | The `message` of that exception's `cause`, when it has one — a verdict is usually thrown with the failure that prompted it. |
|
|
32
32
|
| `x-comq-at` | a parked message | When it was parked, as `Date.now()`. |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Feature: The parking queue
|
|
2
|
+
|
|
3
|
+
What a consumer could not process is kept in one queue, `comq.parked`, whatever it was
|
|
4
|
+
consumed from. The message says where it came from, so the queue does not have to.
|
|
5
|
+
|
|
6
|
+
Background:
|
|
7
|
+
Given an active connection to the broker
|
|
8
|
+
|
|
9
|
+
Scenario: Messages parked from two queues are kept in one
|
|
10
|
+
|
|
11
|
+
A queue per source would repeat what every parked message already carries, and
|
|
12
|
+
would stay on the broker for as long as the broker does.
|
|
13
|
+
|
|
14
|
+
Given that events from the `parking_left` exchange are causing exceptions
|
|
15
|
+
And that events from the `parking_right` exchange are causing exceptions
|
|
16
|
+
When an event is emitted to the `parking_left` exchange
|
|
17
|
+
And an event is emitted to the `parking_right` exchange
|
|
18
|
+
Then a message parked from the `parking_left` exchange is in the parking queue
|
|
19
|
+
And a message parked from the `parking_right` exchange is in the parking queue
|
|
20
|
+
|
|
21
|
+
Scenario: A message parked from a groupless subscription outlives its connection
|
|
22
|
+
|
|
23
|
+
A groupless subscriber consumes a queue of its own that the broker removes with the
|
|
24
|
+
connection. What it could not process is not ephemeral: it is waiting for a person,
|
|
25
|
+
and there is nobody left to tell.
|
|
26
|
+
|
|
27
|
+
Given that groupless events from the `parking_ephemeral` exchange are causing exceptions
|
|
28
|
+
When an event is emitted to the `parking_ephemeral` exchange
|
|
29
|
+
And the message is parked
|
|
30
|
+
And the connection is closed
|
|
31
|
+
And an active connection to the broker
|
|
32
|
+
Then a message parked from the `parking_ephemeral` exchange is in the parking queue
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Feature: Reply queues
|
|
2
|
+
|
|
3
|
+
A connection consumes its replies from one queue, whatever it sends requests to. The
|
|
4
|
+
reply is found by its correlation identifier, which is unique across processes, so the
|
|
5
|
+
queue does not have to name what was called.
|
|
6
|
+
|
|
7
|
+
Scenario: One reply queue however many queues are requested
|
|
8
|
+
|
|
9
|
+
Given an active connection to the broker
|
|
10
|
+
And a producer replying `replies_alpha` queue
|
|
11
|
+
And a producer replying `replies_beta` queue
|
|
12
|
+
When the consumer sends a request to the `replies_alpha` queue
|
|
13
|
+
Then the consumer receives the reply
|
|
14
|
+
When the consumer sends a request to the `replies_beta` queue
|
|
15
|
+
Then the consumer receives the reply
|
|
16
|
+
And the broker holds 1 reply queue
|
|
17
|
+
|
|
18
|
+
Scenario: One reply queue per shard
|
|
19
|
+
|
|
20
|
+
A shard is a broker of its own, and a request published to one is answered on it.
|
|
21
|
+
|
|
22
|
+
Given an active sharded connection
|
|
23
|
+
And a producer replying `replies_sharded` queue
|
|
24
|
+
When the consumer sends 10 requests to the `replies_sharded` queue
|
|
25
|
+
Then all replies have been received
|
|
26
|
+
And each broker holds 1 reply queue
|
|
@@ -12,8 +12,13 @@ const IMAGE = 'rabbitmq:4.3-management'
|
|
|
12
12
|
const USER = 'developer'
|
|
13
13
|
const PASSWORD = 'secret'
|
|
14
14
|
const AMQP_PORT = 5672
|
|
15
|
+
const MANAGEMENT_PORT = 15672
|
|
15
16
|
const BROKERS_AMOUNT = 2
|
|
16
17
|
const HOST_PORTS = [5673, 5674]
|
|
18
|
+
|
|
19
|
+
// what a scenario reads the topology from: a queue nothing holds open is
|
|
20
|
+
// invisible over AMQP, and its absence is what some of them are about
|
|
21
|
+
const MANAGEMENT_HOST_PORTS = [15673, 15674]
|
|
17
22
|
const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
|
|
18
23
|
|
|
19
24
|
/** @type {import('@testcontainers/rabbitmq').StartedRabbitMQContainer[]} */
|
|
@@ -33,7 +38,9 @@ async function startBrokers () {
|
|
|
33
38
|
})
|
|
34
39
|
.withAutoRemove(false)
|
|
35
40
|
.withName(`comq-rmq-${n}`)
|
|
36
|
-
.withExposedPorts(
|
|
41
|
+
.withExposedPorts(
|
|
42
|
+
{ container: AMQP_PORT, host: hostPort },
|
|
43
|
+
{ container: MANAGEMENT_PORT, host: MANAGEMENT_HOST_PORTS[n] })
|
|
37
44
|
.withHealthCheck({
|
|
38
45
|
test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping'],
|
|
39
46
|
interval: 5000,
|
|
@@ -88,6 +95,22 @@ function getAddress (n = 0) {
|
|
|
88
95
|
return `localhost:${HOST_PORTS[n]}`
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The queues on a broker, by name.
|
|
100
|
+
*
|
|
101
|
+
* @param {number} [n]
|
|
102
|
+
* @returns {Promise<string[]>}
|
|
103
|
+
*/
|
|
104
|
+
async function queues (n = 0) {
|
|
105
|
+
const authorization = 'Basic ' + Buffer.from(`${USER}:${PASSWORD}`).toString('base64')
|
|
106
|
+
const url = `http://localhost:${MANAGEMENT_HOST_PORTS[n]}/api/queues/%2F?columns=name`
|
|
107
|
+
const response = await fetch(url, { headers: { authorization } })
|
|
108
|
+
|
|
109
|
+
if (!response.ok) throw new Error(`Cannot read the queues of broker ${n}: ${response.status}`)
|
|
110
|
+
|
|
111
|
+
return (await response.json()).map((queue) => queue.name)
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
/**
|
|
92
115
|
* @param {number} [n]
|
|
93
116
|
*/
|
|
@@ -129,5 +152,6 @@ module.exports = {
|
|
|
129
152
|
startBrokers,
|
|
130
153
|
stopBrokers,
|
|
131
154
|
getAddress,
|
|
155
|
+
queues,
|
|
132
156
|
actions
|
|
133
157
|
}
|
package/features/steps/parked.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
|
-
const amqplib = require('amqplib')
|
|
5
4
|
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
6
5
|
|
|
7
|
-
const { getAddress, USER, PASSWORD } = require('./brokers')
|
|
8
6
|
const { until, timeout } = require('../../test/helpers')
|
|
7
|
+
const { reading, pick } = require('./parking')
|
|
9
8
|
|
|
10
9
|
Given('a producer failing every request to the {token} queue',
|
|
11
10
|
/**
|
|
@@ -52,18 +51,9 @@ When('the parked request from the {token} queue is answered by hand',
|
|
|
52
51
|
* @this {comq.features.Context}
|
|
53
52
|
*/
|
|
54
53
|
async function (queue) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
let parked
|
|
60
|
-
|
|
61
|
-
await channel.consume('comq.parked.' + queue, (message) => {
|
|
62
|
-
parked = message
|
|
63
|
-
channel.ack(message)
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
await until(() => parked !== undefined)
|
|
54
|
+
await reading(async (channel) => {
|
|
55
|
+
const parked = await pick(channel,
|
|
56
|
+
(one) => one.properties.headers?.['x-comq-queue'] === queue)
|
|
67
57
|
|
|
68
58
|
assert.notEqual(parked, undefined, `Nothing was parked from ${queue}`)
|
|
69
59
|
|
|
@@ -74,15 +64,14 @@ When('the parked request from the {token} queue is answered by hand',
|
|
|
74
64
|
|
|
75
65
|
this.answer = { answered: true, was: JSON.parse(parked.content.toString()) }
|
|
76
66
|
|
|
67
|
+
channel.ack(parked)
|
|
68
|
+
|
|
77
69
|
channel.sendToQueue(replyTo, Buffer.from(JSON.stringify(this.answer)),
|
|
78
70
|
{ correlationId, contentType })
|
|
79
71
|
|
|
80
72
|
// let the frame reach the broker before the connection goes
|
|
81
73
|
await timeout(100)
|
|
82
|
-
}
|
|
83
|
-
await channel.close()
|
|
84
|
-
await connection.close()
|
|
85
|
-
}
|
|
74
|
+
})
|
|
86
75
|
})
|
|
87
76
|
|
|
88
77
|
Then('the caller receives the reply',
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const amqplib = require('amqplib')
|
|
5
|
+
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
6
|
+
|
|
7
|
+
const { getAddress, USER, PASSWORD } = require('./brokers')
|
|
8
|
+
const { timeout } = require('../../test/helpers')
|
|
9
|
+
|
|
10
|
+
Given('(that )groupless events from the {token} exchange are causing exceptions',
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} exchange
|
|
13
|
+
* @this {comq.features.Context}
|
|
14
|
+
*/
|
|
15
|
+
async function (exchange) {
|
|
16
|
+
await this.io.consume(exchange, (payload, properties) => {
|
|
17
|
+
this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
|
|
18
|
+
|
|
19
|
+
throw new Error('Expected exception')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
When('the connection is closed',
|
|
24
|
+
/**
|
|
25
|
+
* @this {comq.features.Context}
|
|
26
|
+
*/
|
|
27
|
+
async function () {
|
|
28
|
+
await this.disconnect()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
Then('a message parked from the {token} exchange is in the parking queue',
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} exchange
|
|
34
|
+
* @this {comq.features.Context}
|
|
35
|
+
*/
|
|
36
|
+
async function (exchange) {
|
|
37
|
+
await reading(async (channel) => {
|
|
38
|
+
const message = await pick(channel,
|
|
39
|
+
(one) => one.properties.headers?.['x-comq-exchange'] === exchange)
|
|
40
|
+
|
|
41
|
+
assert.notEqual(message, undefined,
|
|
42
|
+
`Nothing in '${PARKED}' came from the '${exchange}' exchange`)
|
|
43
|
+
|
|
44
|
+
const headers = message.properties.headers
|
|
45
|
+
|
|
46
|
+
// a groupless subscriber consumes a name the broker chose, so what is asserted
|
|
47
|
+
// here is that the message says which queue, not which name that is
|
|
48
|
+
assert.notEqual(headers['x-comq-queue'], undefined,
|
|
49
|
+
'The parked message does not name the queue it came from')
|
|
50
|
+
|
|
51
|
+
assert.notEqual(headers['x-comq-reason'], undefined, 'The parked message has no reason')
|
|
52
|
+
|
|
53
|
+
channel.nack(message, false, true)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs `read` against a connection of its own, so that what it does to the parking
|
|
59
|
+
* queue is not what comq is doing to it.
|
|
60
|
+
*
|
|
61
|
+
* @param {(channel: import('amqplib').Channel) => Promise<void>} read
|
|
62
|
+
*/
|
|
63
|
+
async function reading (read) {
|
|
64
|
+
const connection = await amqplib.connect(`amqp://${USER}:${PASSWORD}@${getAddress(0)}`)
|
|
65
|
+
const channel = await connection.createChannel()
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await read(channel)
|
|
69
|
+
} finally {
|
|
70
|
+
await channel.close().catch(noop)
|
|
71
|
+
await connection.close().catch(noop)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The first message in the parking queue that `matches`, waited for. One queue holds what
|
|
77
|
+
* every scenario before this one left in it, so everything that does not match is given
|
|
78
|
+
* back; the match is left unacknowledged, for the caller to take or to return.
|
|
79
|
+
*
|
|
80
|
+
* @param {import('amqplib').Channel} channel
|
|
81
|
+
* @param {(message: import('amqplib').GetMessage) => boolean} matches
|
|
82
|
+
* @returns {Promise<import('amqplib').GetMessage | undefined>}
|
|
83
|
+
*/
|
|
84
|
+
async function pick (channel, matches) {
|
|
85
|
+
const deadline = Date.now() + DEADLINE
|
|
86
|
+
|
|
87
|
+
do {
|
|
88
|
+
const taken = []
|
|
89
|
+
|
|
90
|
+
let delivery
|
|
91
|
+
|
|
92
|
+
while ((delivery = await channel.get(PARKED, { noAck: false })) !== false) taken.push(delivery)
|
|
93
|
+
|
|
94
|
+
const match = taken.find(matches)
|
|
95
|
+
|
|
96
|
+
for (const one of taken) if (one !== match) channel.nack(one, false, true)
|
|
97
|
+
|
|
98
|
+
if (match !== undefined) return match
|
|
99
|
+
|
|
100
|
+
await timeout(50)
|
|
101
|
+
} while (Date.now() < deadline)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PARKED = 'comq.parked'
|
|
105
|
+
|
|
106
|
+
/** The ladder the suite runs on is half a second; this is room for it and the broker. */
|
|
107
|
+
const DEADLINE = 5000
|
|
108
|
+
|
|
109
|
+
function noop () {}
|
|
110
|
+
|
|
111
|
+
exports.PARKED = PARKED
|
|
112
|
+
exports.reading = reading
|
|
113
|
+
exports.pick = pick
|
package/features/steps/poison.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
4
|
const { until } = require('../../test/helpers')
|
|
5
5
|
const { Then } = require('@cucumber/cucumber')
|
|
6
|
+
const { PARKED, reading, pick } = require('./parking')
|
|
6
7
|
|
|
7
8
|
Then('the event is attempted {int} time(s)',
|
|
8
9
|
/**
|
|
@@ -35,32 +36,29 @@ Then('the parked message is kept, and says it came from the {token} exchange',
|
|
|
35
36
|
*/
|
|
36
37
|
async function (exchange) {
|
|
37
38
|
const queue = exchange + '..exceptions' // the group the throwing consumer uses
|
|
38
|
-
const parked = 'comq.parked.' + queue
|
|
39
39
|
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
await until(() => this.parked[queue] !== undefined)
|
|
40
|
+
await reading(async (channel) => {
|
|
41
|
+
const message = await pick(channel,
|
|
42
|
+
(one) => one.properties.headers?.['x-comq-queue'] === queue)
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
assert.notEqual(message, undefined, `Nothing in '${PARKED}' came from ${queue}`)
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
const headers = message.properties.headers
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
assert.notEqual(headers['x-comq-reason'], undefined, 'The parked message has no reason')
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
// the message came back through the default exchange on every retry, so this
|
|
51
|
+
// is only right if the origin was recorded on the first failure
|
|
52
|
+
assert.equal(headers['x-comq-exchange'], exchange,
|
|
53
|
+
`The parked message says it came from '${headers['x-comq-exchange']}'`)
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
// absent on a message parked from its first delivery, exactly as a consumer reads it;
|
|
56
|
+
// derived rather than stated, so it cannot drift from the configured ladder
|
|
57
|
+
assert.equal(headers['x-comq-attempt'] ?? 1, this.attempts.length,
|
|
58
|
+
'The parked message did not have as many attempts as the consumer saw')
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
assert.equal(headers['x-comq-attempt'] ?? 1, this.attempts.length,
|
|
63
|
-
'The parked message did not have as many attempts as the consumer saw')
|
|
60
|
+
channel.ack(message)
|
|
61
|
+
})
|
|
64
62
|
})
|
|
65
63
|
|
|
66
64
|
Then('{token} has received {int} event(s)',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const { Then } = require('@cucumber/cucumber')
|
|
5
|
+
|
|
6
|
+
const { queues, BROKERS_AMOUNT } = require('./brokers')
|
|
7
|
+
|
|
8
|
+
Then('the broker holds {int} reply queue(s)',
|
|
9
|
+
/**
|
|
10
|
+
* @param {number} count
|
|
11
|
+
*/
|
|
12
|
+
async function (count) {
|
|
13
|
+
await held(0, count)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
Then('each broker holds {int} reply queue(s)',
|
|
17
|
+
/**
|
|
18
|
+
* @param {number} count
|
|
19
|
+
*/
|
|
20
|
+
async function (count) {
|
|
21
|
+
for (let n = 0; n < BROKERS_AMOUNT; n++) await held(n, count)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {number} broker
|
|
26
|
+
* @param {number} count
|
|
27
|
+
*/
|
|
28
|
+
async function held (broker, count) {
|
|
29
|
+
const replies = (await queues(broker)).filter((name) => name.startsWith(PREFIX))
|
|
30
|
+
|
|
31
|
+
assert.equal(replies.length, count,
|
|
32
|
+
`Broker ${broker} holds ${replies.length} reply queue(s): ${replies.join(', ')}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PREFIX = 'comq.reply'
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -113,7 +113,8 @@ Send encoded Request message with `replyTo` and `correlationId` properties set a
|
|
|
113
113
|
return decoded Reply content. The promise stays pending until the Reply arrives, or until the
|
|
114
114
|
[timeout](#timeout) passes.
|
|
115
115
|
|
|
116
|
-
On the initial call,
|
|
116
|
+
On the initial call, the queue for Requests is asserted, and so is the one this `IO` receives
|
|
117
|
+
every Reply on.
|
|
117
118
|
|
|
118
119
|
`options` is the encoding, or an object:
|
|
119
120
|
|
|
@@ -550,7 +551,9 @@ requests and are expecting replies.
|
|
|
550
551
|
- An exchange is asserted as _fanout_ for [Emission](#emission)
|
|
551
552
|
and [Consumption](#consumption), and as _direct_ for [Routing](#routing). One name is one or
|
|
552
553
|
the other: asserting it as both is what the broker refuses.
|
|
553
|
-
-
|
|
554
|
+
- One queue takes every Reply an `IO` receives, `comq.reply..<id>`, _exclusive_ and gone with the
|
|
555
|
+
connection. A Reply is matched by its `correlationId`, which is unique across processes, so the
|
|
556
|
+
queue names neither what was called nor what is waiting.
|
|
554
557
|
- A queue [`back`](#addressed-requests) holds is _exclusive_, bound under its Key to a _direct_
|
|
555
558
|
exchange.
|
|
556
559
|
|
|
@@ -558,7 +561,8 @@ comq declares two kinds of queue of its own, for [failed messages](#retries):
|
|
|
558
561
|
|
|
559
562
|
- `comq.retry.<delay>`, with a fanout exchange of the same name, one pair per distinct rung of
|
|
560
563
|
the [backoff ladder](#retries), shared by every queue that uses it.
|
|
561
|
-
- `comq.parked
|
|
564
|
+
- `comq.parked`, one queue for everything parked, whatever it was consumed from, always
|
|
565
|
+
_durable_.
|
|
562
566
|
|
|
563
567
|
See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.html#channel_assertQueue).
|
|
564
568
|
|
|
@@ -638,8 +642,10 @@ verdict's own message.
|
|
|
638
642
|
|
|
639
643
|
#### Parked messages
|
|
640
644
|
|
|
641
|
-
A message that has run out of attempts is published to `comq.parked
|
|
642
|
-
only once the broker confirms it.
|
|
645
|
+
A message that has run out of attempts is published to `comq.parked` and acknowledged
|
|
646
|
+
only once the broker confirms it. There is one such queue, and it holds what every queue this
|
|
647
|
+
connection consumes could not process — including a groupless subscription's, which the broker
|
|
648
|
+
removes with the connection while what it could not process stays. It is not deleted, and it does not depend on a broker-side
|
|
643
649
|
policy. The [`discard`](#diagnostics) diagnostic event is emitted when it happens, and
|
|
644
650
|
[`retry`](#diagnostics) on every attempt before it.
|
|
645
651
|
|
|
@@ -648,9 +654,12 @@ name where it was originally published, `x-comq-queue` the queue it was consumed
|
|
|
648
654
|
`x-comq-reason` the exception's message, and `x-comq-at` when it was parked. Its original
|
|
649
655
|
properties are kept as they were.
|
|
650
656
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
657
|
+
A message is found by the queue it names rather than by the queue it is in: `comq.parked` holds
|
|
658
|
+
messages from every source, so reading one source's means matching on `x-comq-queue`.
|
|
659
|
+
|
|
660
|
+
The queue grows until somebody drains it, which is deliberate — the alternative is deleting
|
|
661
|
+
evidence. Nothing comq does removes it or what it holds. Alert on [`discard`](#diagnostics), and
|
|
662
|
+
do not delete `comq.retry.*` or `comq.parked` on a running system.
|
|
654
663
|
|
|
655
664
|
> **A parked Request is never answered.** A Consumer awaiting its Reply waits until its
|
|
656
665
|
> [timeout](#timeout), or indefinitely without one, and with a limited prefetch that can deadlock
|
package/source/.io/ReplyPipe.js
CHANGED
|
@@ -60,12 +60,25 @@ class ReplyPipe extends EventEmitter {
|
|
|
60
60
|
ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// The source is not read until the confirmation is published, and a blocked broker holds the
|
|
64
|
+
// publish for as long as it stays blocked. A source failing in the meantime — a reply stream
|
|
65
|
+
// being relayed times out — would raise an `error` nobody listens to. How it failed is not
|
|
66
|
+
// reported to the consumer anyway: `#pump` ends the stream once it finds the source destroyed.
|
|
67
|
+
stream.on('error', noop)
|
|
68
|
+
|
|
63
69
|
channel.diagnose('return', this.#onReturn)
|
|
64
70
|
feedback.on(correlationId, this.#control)
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
async pipe () {
|
|
68
|
-
|
|
74
|
+
try {
|
|
75
|
+
await this.#transmit(control.ok, this.#properties.ok)
|
|
76
|
+
} catch (exception) {
|
|
77
|
+
// nobody is going to read the source now
|
|
78
|
+
this.#interrupt()
|
|
79
|
+
|
|
80
|
+
throw exception
|
|
81
|
+
}
|
|
69
82
|
|
|
70
83
|
if (this.#closed) return
|
|
71
84
|
|
|
@@ -198,4 +211,6 @@ const CONTROL = { type: 'control', mandatory: true }
|
|
|
198
211
|
|
|
199
212
|
const FLOW = { [FLOW_HEADER]: true }
|
|
200
213
|
|
|
214
|
+
function noop () {}
|
|
215
|
+
|
|
201
216
|
exports.ReplyPipe = ReplyPipe
|
package/source/batch.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* end of the same turn: nothing waits for a timer, and nothing waits for a write that may never
|
|
11
11
|
* come.
|
|
12
12
|
*
|
|
13
|
-
* This belongs in amqplib, whose
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* This belongs in amqplib, whose `Mux` writes the frames one at a time, and where corking that
|
|
14
|
+
* loop is six lines and needs no wrapping of a socket the library does not own. It is done here
|
|
15
|
+
* until amqplib carries it, and goes when it does.
|
|
16
16
|
*
|
|
17
17
|
* @param {import('node:net').Socket} socket
|
|
18
18
|
*/
|
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/io.js
CHANGED
|
@@ -30,8 +30,15 @@ class IO {
|
|
|
30
30
|
/** @type {comq.Channel} */
|
|
31
31
|
#events
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
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
|
|
35
42
|
|
|
36
43
|
/** @type {comq.ReplyEmitter | null} */
|
|
37
44
|
#control = null
|
|
@@ -312,13 +319,20 @@ class IO {
|
|
|
312
319
|
return [buffer, properties]
|
|
313
320
|
}
|
|
314
321
|
|
|
315
|
-
async #consumeReplies (
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
async #consumeReplies () {
|
|
323
|
+
this.#emitter = await this.#consumeRepliesOf(REPLY)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @param {string} label
|
|
328
|
+
* @returns {Promise<comq.ReplyEmitter>}
|
|
329
|
+
*/
|
|
330
|
+
async #consumeRepliesOf (label) {
|
|
331
|
+
const emitter = io.createReplyEmitter(label)
|
|
318
332
|
|
|
319
|
-
this.#
|
|
333
|
+
await this.#replies.consume(emitter.queue, this.#getReplyConsumer(emitter))
|
|
320
334
|
|
|
321
|
-
|
|
335
|
+
return emitter
|
|
322
336
|
}
|
|
323
337
|
|
|
324
338
|
// endregion
|
|
@@ -389,11 +403,10 @@ class IO {
|
|
|
389
403
|
})
|
|
390
404
|
|
|
391
405
|
/**
|
|
392
|
-
* @param {string} queue
|
|
393
406
|
* @param {comq.ReplyEmitter} emitter
|
|
394
407
|
* @returns {comq.channels.Consumer}
|
|
395
408
|
*/
|
|
396
|
-
#getReplyConsumer = (
|
|
409
|
+
#getReplyConsumer = (emitter) =>
|
|
397
410
|
(message) => {
|
|
398
411
|
const payload = decode(message)
|
|
399
412
|
|
|
@@ -426,7 +439,7 @@ class IO {
|
|
|
426
439
|
signal?.throwIfAborted()
|
|
427
440
|
|
|
428
441
|
const [buffer, contentType] = this.#encode(payload, terms.encoding)
|
|
429
|
-
const request = this.#createRequest(
|
|
442
|
+
const request = this.#createRequest(contentType, signal)
|
|
430
443
|
const properties = { ...request.properties }
|
|
431
444
|
|
|
432
445
|
if (expires !== undefined) {
|
|
@@ -493,13 +506,12 @@ class IO {
|
|
|
493
506
|
* The request holds no copy of what was sent: a retransmission encodes the
|
|
494
507
|
* payload anew, and an unanswered request would otherwise keep two of it.
|
|
495
508
|
*
|
|
496
|
-
* @param {string} queue
|
|
497
509
|
* @param {comq.Encoding} contentType
|
|
498
510
|
* @param {AbortSignal} [signal]
|
|
499
511
|
* @return {comq.Request}
|
|
500
512
|
*/
|
|
501
|
-
#createRequest (
|
|
502
|
-
const emitter = this.#
|
|
513
|
+
#createRequest (contentType, signal) {
|
|
514
|
+
const emitter = this.#emitter
|
|
503
515
|
const correlationId = emitter.next()
|
|
504
516
|
|
|
505
517
|
/** @type {comq.amqp.Properties} */
|
|
@@ -580,11 +592,7 @@ class IO {
|
|
|
580
592
|
* @return {Promise<comq.ReplyEmitter>}
|
|
581
593
|
*/
|
|
582
594
|
async #createControl () {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
await this.#consumeReplies(queue)
|
|
586
|
-
|
|
587
|
-
return this.#emitters.get(queue)
|
|
595
|
+
return await this.#consumeRepliesOf(CONTROL)
|
|
588
596
|
}
|
|
589
597
|
|
|
590
598
|
/**
|
|
@@ -693,6 +701,12 @@ const DEFAULT = 'application/json'
|
|
|
693
701
|
|
|
694
702
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
695
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
|
+
|
|
696
710
|
/**
|
|
697
711
|
* What a caller passed, settled once: a timeout becomes the moment the Request expires, so that
|
|
698
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 () => {
|