comq 0.22.0 → 0.24.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/amqp-1.0.md +127 -0
- 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.call.feature +23 -1
- package/features/rpc.call.recovery.feature +0 -21
- package/features/rpc.parked.feature +6 -6
- package/features/rpc.replies.feature +26 -0
- package/features/steps/brokers.js +25 -1
- package/features/steps/call.js +90 -30
- package/features/steps/events.js +1 -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 +6 -4
- package/readme.md +54 -75
- package/source/.io/ReplyPipe.js +16 -1
- package/source/channel.js +19 -27
- package/source/connection.js +1 -4
- package/source/index.js +0 -2
- package/source/io.js +48 -59
- package/test/ReplyPipe.test.js +40 -0
- package/test/amqplib.mock.js +1 -1
- package/test/channel.test.js +29 -18
- package/test/connection.test.js +1 -1
- package/test/{io.request.terms.test.js → io.call.terms.test.js} +31 -30
- package/test/io.call.test.js +0 -10
- package/test/io.request.test.js +26 -3
- package/test/memory.test.js +1 -1
- package/types/amqp.d.ts +1 -1
- package/types/channel.d.ts +1 -1
- package/types/index.d.ts +0 -6
- package/types/io.d.ts +9 -14
- package/features/closing.feature +0 -32
- package/features/rpc.timeout.feature +0 -19
- package/features/steps/closing.js +0 -109
- package/source/abandoned.js +0 -15
- package/source/batch.js +0 -62
- package/test/batch.test.js +0 -94
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# AMQP 1.0
|
|
2
|
+
|
|
3
|
+
**comq stays on AMQP 0-9-1 over [amqplib](https://github.com/amqp-node/amqplib).**
|
|
4
|
+
|
|
5
|
+
RabbitMQ 4 speaks AMQP 1.0 natively and keeps 0-9-1 as a first-class protocol; both are
|
|
6
|
+
supported out of the box and neither is on its way out. The choice is therefore comq's to make on
|
|
7
|
+
what each protocol and each client gives it, and the answer today is that 1.0 costs a rewrite of
|
|
8
|
+
the transport layer, changes comq's public API, breaks the wire between comq versions, and
|
|
9
|
+
returns nothing comq uses.
|
|
10
|
+
|
|
11
|
+
Two separate questions hide in "switch to the official client", and they have different answers.
|
|
12
|
+
The client is unusable for comq today. The protocol is usable and in places cleaner, and still
|
|
13
|
+
does not pay for itself.
|
|
14
|
+
|
|
15
|
+
Measured on 2026-09-20 against RabbitMQ 4.3.6, with `rabbitmq-amqp-js-client` 1.0.0, `rhea` 3.0.5
|
|
16
|
+
and `amqplib` 2.0.1.
|
|
17
|
+
|
|
18
|
+
## The client
|
|
19
|
+
|
|
20
|
+
[`rabbitmq-amqp-js-client`](https://github.com/coders51/rabbitmq-amqp-js-client) is listed among
|
|
21
|
+
RabbitMQ's AMQP 1.0 client libraries and is maintained by coders51 rather than by the RabbitMQ
|
|
22
|
+
team, unlike the Java, .NET, Go and Python clients. It is a thin wrapper over
|
|
23
|
+
[rhea](https://github.com/amqp/rhea), which does the protocol.
|
|
24
|
+
|
|
25
|
+
What it offers an application is a connection, a publisher, a consumer and a management link.
|
|
26
|
+
What comq needs from a client, it does not reach:
|
|
27
|
+
|
|
28
|
+
| comq | the client |
|
|
29
|
+
|---|---|
|
|
30
|
+
| 16 [diagnostic events](../readme.md#diagnostics) | connection, publisher and consumer state changes are unobservable ([#40](https://github.com/coders51/rabbitmq-amqp-js-client/issues/40)) |
|
|
31
|
+
| [`seal`](../readme.md#sealing) stops consuming and waits | `Consumer.close` is synchronous and returns nothing ([#34](https://github.com/coders51/rabbitmq-amqp-js-client/issues/34)) |
|
|
32
|
+
| topology recovered after a reconnect | the links come back, the topology does not ([#42](https://github.com/coders51/rabbitmq-amqp-js-client/issues/42)) |
|
|
33
|
+
| prefetch 300, per channel | rhea's default credit window of 1000, fixed |
|
|
34
|
+
| Requests and Replies are published unconfirmed | `publish` always awaits settlement |
|
|
35
|
+
| `amqp://user:pass@host/vhost`, heartbeat 15s | host, port, username, password |
|
|
36
|
+
|
|
37
|
+
Three defects it has today would each stop comq on its own:
|
|
38
|
+
|
|
39
|
+
- **A publish to a queue that does not exist never settles.** The broker refuses the attach with
|
|
40
|
+
`amqp:not-found`; `createPublisher` resolves regardless, and the `publish` promise stays
|
|
41
|
+
pending forever.
|
|
42
|
+
- **Attaching to another connection's exclusive queue kills the process.** `createConsumer`
|
|
43
|
+
resolves, the broker ends the session, and rhea emits an unhandled `error` on a container the
|
|
44
|
+
client keeps private. That is exactly what [`back`](../readme.md#addressed-requests) does on
|
|
45
|
+
every key already held.
|
|
46
|
+
- **Every body goes out as an `amqp-value` section**, Buffers included, so a 0-9-1 consumer
|
|
47
|
+
receives the section bytes in front of the payload and `type: 'amqp-1.0'` among the properties.
|
|
48
|
+
A process on 0-9-1 and a process on the client cannot exchange a message, which rules out a
|
|
49
|
+
rolling migration. rhea with an explicit `data_section` interoperates cleanly, so this is the
|
|
50
|
+
client's message model rather than the protocol.
|
|
51
|
+
|
|
52
|
+
Queue `arguments` are typed `Record<string, string>` while the broker requires integers for
|
|
53
|
+
`x-message-ttl` (`expected integer, got longstr`) and accepts the numbers the typing forbids. The
|
|
54
|
+
dependency on rhea is `github:amqp/rhea` — a git reference that resolves to `git+ssh` in a
|
|
55
|
+
lockfile and builds on install, which would land in comq's dependency tree and in every CI that
|
|
56
|
+
installs comq.
|
|
57
|
+
|
|
58
|
+
Adopted with those gaps filled locally, "the official client" becomes "rhea, plus a management
|
|
59
|
+
link we wrote" — which is the layer comq already is, over a client with fifteen years behind it.
|
|
60
|
+
|
|
61
|
+
## The protocol
|
|
62
|
+
|
|
63
|
+
Everything comq does is expressible over AMQP 1.0, and three mechanisms fit it better than 0-9-1
|
|
64
|
+
does:
|
|
65
|
+
|
|
66
|
+
- **An unroutable publish comes back as a `RELEASED` outcome on the send itself**, where 0-9-1
|
|
67
|
+
reports it asynchronously through `mandatory` and `basic.return` and comq has to correlate it.
|
|
68
|
+
- **The `ttl` header is honoured per message**, so a [`call`](../readme.md#timeout)'s expiration
|
|
69
|
+
carries unchanged.
|
|
70
|
+
- **Credit is granted per link**, which is what comq's [channel segregation](../readme.md#channels)
|
|
71
|
+
approximates with a prefetch count per channel.
|
|
72
|
+
|
|
73
|
+
The rest holds: an exclusive queue declared over the management link is refused to a second
|
|
74
|
+
connection (`400 cannot obtain exclusive access`), so `back` still holds a Key; numeric queue
|
|
75
|
+
arguments are accepted, so the [retry ladder](../readme.md#retries) and `comq.parked` are
|
|
76
|
+
unchanged. Streams with offsets and server-side filters are the one capability 0-9-1 cannot match,
|
|
77
|
+
and comq consumes no streams.
|
|
78
|
+
|
|
79
|
+
### What it costs
|
|
80
|
+
|
|
81
|
+
**comq's public API is shaped by 0-9-1.** `Properties` is in the consumer signature, `emit`,
|
|
82
|
+
`enqueue` and `route` take publish options, and `discard`, `retry` and `return` hand listeners a
|
|
83
|
+
raw amqplib message. Every one of those changes, and so does what reads them — `@toa.io/bindings.amqp`
|
|
84
|
+
reads `message.properties.correlationId` and `properties.headers`.
|
|
85
|
+
|
|
86
|
+
**Message properties land elsewhere.** A 0-9-1 header prefixed `x-` arrives over 1.0 as a message
|
|
87
|
+
annotation rather than an application property, beside the broker's own `x-exchange` and
|
|
88
|
+
`x-routing-key`; `replyTo: 'rq'` arrives as `/queues/rq`. comq's [`x-comq-*` headers](../docs/headers.md)
|
|
89
|
+
move with them.
|
|
90
|
+
|
|
91
|
+
**The management UI and API stop reporting message rates** for AMQP 1.0 connections; queue depth
|
|
92
|
+
survives, `message_stats` is null. Frame-intercepting plugins and transactions are 0-9-1 only,
|
|
93
|
+
and comq uses neither.
|
|
94
|
+
|
|
95
|
+
**The work is the transport layer and the unit suite.** `source/connection.js` and
|
|
96
|
+
`source/channel.js` are 1,256 lines written against amqplib's channel API, `source/io.js` another
|
|
97
|
+
797 that speak its vocabulary, and `test/` is built on `test/amqplib.mock.js` — some 6,400 lines
|
|
98
|
+
that go with the thing they mock. The 88 scenarios in `features/` are behaviour-level and would
|
|
99
|
+
carry over, which makes them the whole safety net for the change.
|
|
100
|
+
|
|
101
|
+
### What it does not buy
|
|
102
|
+
|
|
103
|
+
Throughput is level and the latency comq optimises for is worse. Same broker over loopback,
|
|
104
|
+
20,000 messages of ~180 bytes, three runs, `TCP_NODELAY` on both:
|
|
105
|
+
|
|
106
|
+
| | amqplib | rhea |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| publish, awaiting confirmation or settlement | 77–88k msg/s | 79–103k msg/s |
|
|
109
|
+
| consume and acknowledge | 121–143k msg/s | 110–128k msg/s |
|
|
110
|
+
|
|
111
|
+
Sequential request and reply, 2,000 round trips, one in flight:
|
|
112
|
+
|
|
113
|
+
| | p50 | p99 |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| amqplib | 0.20 ms | 0.36–0.41 ms |
|
|
116
|
+
| rhea | 0.26–0.29 ms | 0.51–0.57 ms |
|
|
117
|
+
|
|
118
|
+
rhea enables `TCP_NODELAY` when a receiver is attached and leaves it to the `tcp_no_delay` option
|
|
119
|
+
otherwise, so a connection that only publishes is left to Nagle — the same delay comq took out of
|
|
120
|
+
amqplib in 0.20.1, and worth 22 ms per round trip when it is missed.
|
|
121
|
+
|
|
122
|
+
## What would reverse this
|
|
123
|
+
|
|
124
|
+
- comq needs RabbitMQ streams with server-side filtering.
|
|
125
|
+
- The client exposes connection and link state, a consumer close that waits, a credit setting and
|
|
126
|
+
a vhost — the four things its own issues already name.
|
|
127
|
+
- RabbitMQ announces an end for AMQP 0-9-1.
|
|
@@ -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
|
|
@@ -65,7 +65,29 @@ Feature: Calls under a key
|
|
|
65
65
|
Scenario: A caller stops waiting for a holder that crashed
|
|
66
66
|
Given a holder never answering `void` under the `a` key
|
|
67
67
|
When the consumer calls `void` under the `a` key with a 1000ms timeout
|
|
68
|
-
And
|
|
68
|
+
And the silent holder has taken the call
|
|
69
69
|
And the silent holder crashes
|
|
70
70
|
Then the consumer stops waiting
|
|
71
71
|
And a call to `void` under the `a` key is refused within 10 seconds
|
|
72
|
+
|
|
73
|
+
Scenario: A caller stops waiting at its timeout
|
|
74
|
+
Given a holder on another connection answering `slow` under the `a` key in 1000ms
|
|
75
|
+
When the consumer calls `slow` under the `a` key with a 200ms timeout
|
|
76
|
+
Then the consumer stops waiting
|
|
77
|
+
|
|
78
|
+
Scenario: A call its holder has not taken in time is never processed
|
|
79
|
+
Given a holder not taking `later` under the `a` key
|
|
80
|
+
When the consumer calls `later` under the `a` key with a 200ms timeout
|
|
81
|
+
Then the consumer stops waiting
|
|
82
|
+
When after 300ms
|
|
83
|
+
And the idle holder starts taking
|
|
84
|
+
Then the idle holder takes nothing
|
|
85
|
+
|
|
86
|
+
Scenario: A call abandoned by its signal stays in its queue
|
|
87
|
+
Given a holder not taking `stray` under the `a` key
|
|
88
|
+
When the consumer calls `stray` under the `a` key with a signal
|
|
89
|
+
And after 100ms
|
|
90
|
+
And the signal aborts
|
|
91
|
+
Then the consumer stops waiting for the aborted call
|
|
92
|
+
When the idle holder starts taking
|
|
93
|
+
Then the idle holder takes the call
|
|
@@ -39,13 +39,6 @@ Feature: Calls under a key across failures
|
|
|
39
39
|
And the broker is up
|
|
40
40
|
Then the call ends
|
|
41
41
|
|
|
42
|
-
Scenario: A caller stops waiting for a request at its timeout while the broker is down
|
|
43
|
-
Given an active connection to the broker
|
|
44
|
-
And a producer replying `echo` queue
|
|
45
|
-
When the broker has crashed
|
|
46
|
-
And the consumer sends a request to the `echo` queue with a 1000ms timeout
|
|
47
|
-
Then the consumer stops waiting within 5 seconds
|
|
48
|
-
|
|
49
42
|
Scenario: A caller stops waiting for a call at its timeout while the broker is down
|
|
50
43
|
Given an active connection to the broker
|
|
51
44
|
And a holder on another connection answering `echo` under the `a` key
|
|
@@ -53,20 +46,6 @@ Feature: Calls under a key across failures
|
|
|
53
46
|
And the consumer calls `echo` under the `a` key with a 1000ms timeout
|
|
54
47
|
Then the consumer stops waiting within 5 seconds
|
|
55
48
|
|
|
56
|
-
Scenario: A request re-sent after the broker restarts expires at its caller's deadline
|
|
57
|
-
|
|
58
|
-
The request is lost with the broker and sent again once the connection is restored, carrying
|
|
59
|
-
the time its caller has left.
|
|
60
|
-
|
|
61
|
-
Given an active connection to the broker
|
|
62
|
-
When the consumer sends a request to the `resent` queue with a 30000ms timeout
|
|
63
|
-
And after 200ms
|
|
64
|
-
And the broker has crashed
|
|
65
|
-
And the broker is up
|
|
66
|
-
Then the consumer stops waiting within 40 seconds
|
|
67
|
-
When a producer counting requests to the `resent` queue
|
|
68
|
-
Then the producer receives nothing
|
|
69
|
-
|
|
70
49
|
Scenario: A call reaches its holder while one of the brokers is down
|
|
71
50
|
Given an active sharded connection
|
|
72
51
|
And a holder on another connection answering `echo` under the `a` key
|
|
@@ -5,13 +5,13 @@ Feature: Parked requests
|
|
|
5
5
|
|
|
6
6
|
Scenario: A parked request can still be answered
|
|
7
7
|
|
|
8
|
-
A caller
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
A caller is certain of an eventual answer for as long as it is alive, and parking
|
|
9
|
+
is what keeps that true when no consumer can produce one: the request is kept, along
|
|
10
|
+
with who was asking. Somebody else can answer it, and the caller — still waiting,
|
|
11
|
+
still holding an exclusive reply queue — receives it.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
This is why a request has no timeout and cannot be withdrawn: either would leave the
|
|
14
|
+
parked message addressed to a caller that is no longer listening.
|
|
15
15
|
|
|
16
16
|
Given a producer failing every request to the `unanswerable` queue
|
|
17
17
|
When a request is sent to the `unanswerable` 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/call.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
|
-
const amqplib = require('amqplib')
|
|
4
|
+
const amqplib = require('@toa.io/amqplib')
|
|
5
5
|
const { Given, When, Then, After } = require('@cucumber/cucumber')
|
|
6
6
|
|
|
7
7
|
const { connect, Unroutable } = require('../../')
|
|
@@ -84,11 +84,76 @@ Given('a holder never answering {token} under the {token} key',
|
|
|
84
84
|
await channel.assertExchange(exchange, 'direct', { durable: true })
|
|
85
85
|
await channel.assertQueue(queue, { exclusive: true })
|
|
86
86
|
await channel.bindQueue(queue, exchange, key)
|
|
87
|
-
await channel.consume(queue, () =>
|
|
87
|
+
await channel.consume(queue, () => (this.handed = true))
|
|
88
88
|
|
|
89
89
|
this.silent = connection
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
Then('the silent holder has taken the call',
|
|
93
|
+
/**
|
|
94
|
+
* The publication is awaited nowhere, and what the crash has to find already queued is what
|
|
95
|
+
* the holder has been handed.
|
|
96
|
+
*
|
|
97
|
+
* @this {comq.features.Context}
|
|
98
|
+
*/
|
|
99
|
+
async function () {
|
|
100
|
+
assert.equal(await until(() => this.handed === true, 10_000), true,
|
|
101
|
+
'The silent holder was handed nothing')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
Given('a holder not taking {token} under the {token} key',
|
|
105
|
+
/**
|
|
106
|
+
* Holds the key as comq does, and takes nothing until told to, so that a call waits in its
|
|
107
|
+
* queue. Each scenario holds a key of its own: the broker releases an exclusive queue once it
|
|
108
|
+
* has noticed the connection close, and a scenario that declared it again before then would be
|
|
109
|
+
* refused it.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} exchange
|
|
112
|
+
* @param {string} key
|
|
113
|
+
* @this {comq.features.Context}
|
|
114
|
+
*/
|
|
115
|
+
async function (exchange, key) {
|
|
116
|
+
const connection = await amqplib.connect(url(0))
|
|
117
|
+
const channel = await connection.createChannel()
|
|
118
|
+
const queue = exchange + '.' + key
|
|
119
|
+
|
|
120
|
+
await channel.assertExchange(exchange, 'direct', { durable: true })
|
|
121
|
+
await channel.assertQueue(queue, { exclusive: true })
|
|
122
|
+
await channel.bindQueue(queue, exchange, key)
|
|
123
|
+
|
|
124
|
+
this.idle = { connection, channel, queue, taken: 0 }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
When('the idle holder starts taking',
|
|
128
|
+
/**
|
|
129
|
+
* @this {comq.features.Context}
|
|
130
|
+
*/
|
|
131
|
+
async function () {
|
|
132
|
+
const idle = this.idle
|
|
133
|
+
|
|
134
|
+
await idle.channel.consume(idle.queue, () => idle.taken++, { noAck: true })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
When('the consumer calls {token} under the {token} key with a signal',
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} exchange
|
|
140
|
+
* @param {string} key
|
|
141
|
+
* @this {comq.features.Context}
|
|
142
|
+
*/
|
|
143
|
+
async function (exchange, key) {
|
|
144
|
+
this.controller = new AbortController()
|
|
145
|
+
|
|
146
|
+
call(this, this.io.call(exchange, key, { key }, { signal: this.controller.signal }))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
When('the signal aborts',
|
|
150
|
+
/**
|
|
151
|
+
* @this {comq.features.Context}
|
|
152
|
+
*/
|
|
153
|
+
function () {
|
|
154
|
+
this.controller.abort()
|
|
155
|
+
})
|
|
156
|
+
|
|
92
157
|
When('the silent holder crashes',
|
|
93
158
|
/**
|
|
94
159
|
* Its socket is destroyed without a word, as a process that dies leaves it.
|
|
@@ -149,16 +214,6 @@ When('the consumer calls {token} under the {token} key {number} times',
|
|
|
149
214
|
this.replies = await Promise.all(calls)
|
|
150
215
|
})
|
|
151
216
|
|
|
152
|
-
When('the consumer sends a request to the {token} queue with a {number}ms timeout',
|
|
153
|
-
/**
|
|
154
|
-
* @param {string} queue
|
|
155
|
-
* @param {number} ms
|
|
156
|
-
* @this {comq.features.Context}
|
|
157
|
-
*/
|
|
158
|
-
async function (queue, ms) {
|
|
159
|
-
call(this, this.io.request(queue, { queue }, { timeout: ms }))
|
|
160
|
-
})
|
|
161
|
-
|
|
162
217
|
When('the holder is sealed',
|
|
163
218
|
/**
|
|
164
219
|
* @this {comq.features.Context}
|
|
@@ -192,19 +247,32 @@ When('the first holder disconnects',
|
|
|
192
247
|
await this.holder.close()
|
|
193
248
|
})
|
|
194
249
|
|
|
195
|
-
|
|
250
|
+
Then('the idle holder takes nothing',
|
|
196
251
|
/**
|
|
197
|
-
* @param {string} queue
|
|
198
252
|
* @this {comq.features.Context}
|
|
199
253
|
*/
|
|
200
|
-
async function (
|
|
201
|
-
|
|
254
|
+
async function () {
|
|
255
|
+
await timeout(500)
|
|
202
256
|
|
|
203
|
-
|
|
204
|
-
|
|
257
|
+
assert.equal(this.idle.taken, 0, 'An expired call was taken')
|
|
258
|
+
})
|
|
205
259
|
|
|
206
|
-
|
|
207
|
-
|
|
260
|
+
Then('the idle holder takes the call',
|
|
261
|
+
/**
|
|
262
|
+
* @this {comq.features.Context}
|
|
263
|
+
*/
|
|
264
|
+
async function () {
|
|
265
|
+
await until(() => this.idle.taken > 0, 5000)
|
|
266
|
+
|
|
267
|
+
assert.equal(this.idle.taken, 1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
Then('the consumer stops waiting for the aborted call',
|
|
271
|
+
/**
|
|
272
|
+
* @this {comq.features.Context}
|
|
273
|
+
*/
|
|
274
|
+
async function () {
|
|
275
|
+
await assert.rejects(this.reply, (error) => error.name === 'AbortError')
|
|
208
276
|
})
|
|
209
277
|
|
|
210
278
|
Then('the call is refused as unroutable',
|
|
@@ -373,16 +441,6 @@ Then('every call is answered',
|
|
|
373
441
|
assert.deepEqual(this.replies, this.replies.map((_, n) => n))
|
|
374
442
|
})
|
|
375
443
|
|
|
376
|
-
Then('the producer receives nothing',
|
|
377
|
-
/**
|
|
378
|
-
* @this {comq.features.Context}
|
|
379
|
-
*/
|
|
380
|
-
async function () {
|
|
381
|
-
await timeout(500)
|
|
382
|
-
|
|
383
|
-
assert.equal(this.produced, 0, 'An expired request was processed')
|
|
384
|
-
})
|
|
385
|
-
|
|
386
444
|
After(
|
|
387
445
|
/**
|
|
388
446
|
* @this {comq.features.Context}
|
|
@@ -390,9 +448,11 @@ After(
|
|
|
390
448
|
async function () {
|
|
391
449
|
await Promise.all((this.holders ?? []).map((io) => io.close()))
|
|
392
450
|
await this.silent?.close().catch(() => undefined)
|
|
451
|
+
await this.idle?.connection.close().catch(() => undefined)
|
|
393
452
|
|
|
394
453
|
this.holders = []
|
|
395
454
|
this.silent = undefined
|
|
455
|
+
this.idle = undefined
|
|
396
456
|
})
|
|
397
457
|
|
|
398
458
|
/**
|
package/features/steps/events.js
CHANGED
|
@@ -225,7 +225,7 @@ async function consumed (group) {
|
|
|
225
225
|
/**
|
|
226
226
|
* @param {string} exchange
|
|
227
227
|
* @param {any} message
|
|
228
|
-
* @param {import('amqplib').Options.Publish} [properties]
|
|
228
|
+
* @param {import('@toa.io/amqplib').Options.Publish} [properties]
|
|
229
229
|
* @return {Promise<void>}
|
|
230
230
|
* @this {comq.features.Context}
|
|
231
231
|
*/
|