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.
Files changed (42) hide show
  1. package/discussions/amqp-1.0.md +127 -0
  2. package/discussions/one-parking-queue.md +63 -0
  3. package/discussions/one-reply-queue.md +44 -0
  4. package/discussions/poison-message-handling.md +2 -1
  5. package/docs/headers.md +1 -1
  6. package/features/parking.feature +32 -0
  7. package/features/rpc.call.feature +23 -1
  8. package/features/rpc.call.recovery.feature +0 -21
  9. package/features/rpc.parked.feature +6 -6
  10. package/features/rpc.replies.feature +26 -0
  11. package/features/steps/brokers.js +25 -1
  12. package/features/steps/call.js +90 -30
  13. package/features/steps/events.js +1 -1
  14. package/features/steps/parked.js +7 -18
  15. package/features/steps/parking.js +113 -0
  16. package/features/steps/poison.js +17 -19
  17. package/features/steps/replies.js +35 -0
  18. package/package.json +6 -4
  19. package/readme.md +54 -75
  20. package/source/.io/ReplyPipe.js +16 -1
  21. package/source/channel.js +19 -27
  22. package/source/connection.js +1 -4
  23. package/source/index.js +0 -2
  24. package/source/io.js +48 -59
  25. package/test/ReplyPipe.test.js +40 -0
  26. package/test/amqplib.mock.js +1 -1
  27. package/test/channel.test.js +29 -18
  28. package/test/connection.test.js +1 -1
  29. package/test/{io.request.terms.test.js → io.call.terms.test.js} +31 -30
  30. package/test/io.call.test.js +0 -10
  31. package/test/io.request.test.js +26 -3
  32. package/test/memory.test.js +1 -1
  33. package/types/amqp.d.ts +1 -1
  34. package/types/channel.d.ts +1 -1
  35. package/types/index.d.ts +0 -6
  36. package/types/io.d.ts +9 -14
  37. package/features/closing.feature +0 -32
  38. package/features/rpc.timeout.feature +0 -19
  39. package/features/steps/closing.js +0 -109
  40. package/source/abandoned.js +0 -15
  41. package/source/batch.js +0 -62
  42. 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 after 100ms
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 that sets no timeout is certain of an eventual answer for as long as it is
9
- alive, and parking is what keeps that true when no consumer can produce one: the
10
- request is kept, along with who was asking. Somebody else can answer it, and the
11
- caller — still waiting, still holding an exclusive reply queue — receives it.
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
- A caller that sets a timeout trades that for an end to the wait: once it stops
14
- waiting, a parked request is addressed to a caller that has gone.
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({ container: AMQP_PORT, host: hostPort })
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
  }
@@ -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, () => undefined)
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
- When('a producer counting requests to the {token} queue',
250
+ Then('the idle holder takes nothing',
196
251
  /**
197
- * @param {string} queue
198
252
  * @this {comq.features.Context}
199
253
  */
200
- async function (queue) {
201
- this.produced = 0
254
+ async function () {
255
+ await timeout(500)
202
256
 
203
- await this.io.reply(queue, () => {
204
- this.produced++
257
+ assert.equal(this.idle.taken, 0, 'An expired call was taken')
258
+ })
205
259
 
206
- return null
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
  /**
@@ -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
  */