comq 0.23.0 → 0.24.1

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 (41) hide show
  1. package/discussions/amqp-1.0.md +127 -0
  2. package/features/heartbeat.feature +16 -0
  3. package/features/rpc.call.feature +23 -1
  4. package/features/rpc.call.recovery.feature +2 -23
  5. package/features/rpc.parked.feature +6 -6
  6. package/features/rpc.shards.retransmission.feature +46 -0
  7. package/features/shards.feature +1 -1
  8. package/features/silence.feature +3 -2
  9. package/features/steps/.types/context.d.ts +6 -1
  10. package/features/steps/brokers.js +1 -1
  11. package/features/steps/call.js +90 -30
  12. package/features/steps/connection.js +23 -0
  13. package/features/steps/context.js +1 -2
  14. package/features/steps/events.js +1 -1
  15. package/features/steps/hooks.js +0 -1
  16. package/features/steps/network.js +11 -2
  17. package/features/steps/networks.js +31 -2
  18. package/features/steps/parking.js +5 -5
  19. package/features/steps/retransmission.js +165 -0
  20. package/features/steps/rpc.js +0 -6
  21. package/package.json +20 -4
  22. package/readme.md +46 -46
  23. package/source/channel.js +3 -1
  24. package/source/connection.js +10 -90
  25. package/source/io.js +106 -25
  26. package/source/shards/channel.js +73 -12
  27. package/test/amqplib.mock.js +1 -1
  28. package/test/connection.mock.js +3 -2
  29. package/test/connection.test.js +16 -78
  30. package/test/{io.request.terms.test.js → io.call.terms.test.js} +31 -30
  31. package/test/io.call.test.js +5 -13
  32. package/test/io.reply.test.js +5 -3
  33. package/test/io.request.test.js +277 -2
  34. package/test/memory.test.js +1 -3
  35. package/test/shards/channel.test.js +168 -2
  36. package/types/amqp.d.ts +1 -1
  37. package/types/channel.d.ts +14 -5
  38. package/types/io.d.ts +18 -8
  39. package/features/rpc.timeout.feature +0 -19
  40. package/source/batch.js +0 -62
  41. 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,16 @@
1
+ @heavy
2
+ Feature: Idle connection tolerance
3
+
4
+ A connection that says nothing but heartbeats is as healthy as a busy one, and is kept for as
5
+ long as the broker goes on answering them, however long that is.
6
+
7
+ Background:
8
+ Given 1s AMQP heartbeat
9
+
10
+ Scenario: Keeping an idle connection
11
+ Given an active connection to the broker
12
+ Then the connection is not lost for 20 seconds
13
+
14
+ Scenario: Keeping an idle sharded connection
15
+ Given an active sharded connection
16
+ Then the connection is not lost for 20 seconds
@@ -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
@@ -17,14 +17,14 @@ Feature: Calls under a key across failures
17
17
  it the key. The holder finds the key taken until the broker lets that connection go, and
18
18
  claims it then.
19
19
 
20
- Given watchdog interval is set to 2000ms with 60s AMQP heartbeat
20
+ Given 1s AMQP heartbeat
21
21
  And a network that can go silent
22
22
  And an active connection to the broker
23
23
  And a holder answering `echo` under the `a` key:
24
24
  """
25
25
  (payload) => payload
26
26
  """
27
- When the network goes silent
27
+ When the network goes silent, with the broker still hearing heartbeats
28
28
  Then the connection is lost within 10 seconds
29
29
  And the holder finds its key taken while the broker holds the silent connection
30
30
  When the silent connection is let go
@@ -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,46 @@
1
+ @heavy
2
+ Feature: Re-sending the requests a lost shard held
3
+
4
+ A Request is re-sent when the shard it went through is lost, since its Reply may be lost with it.
5
+ One that went through a shard that stays is answered through that shard, and is never re-sent
6
+ because another one was lost: a Request is not assumed to be idempotent.
7
+
8
+ Each scenario first takes one broker away from the consumer, so that the Requests it sends go
9
+ through the other one, and gives the broker back before losing one of them again.
10
+
11
+ Background:
12
+ Given 1s AMQP heartbeat
13
+ And a network that can go silent
14
+ And an active sharded connection
15
+ And a producer replying `echo` queue
16
+ # the consumer's channels exist on both brokers before one of them is taken away
17
+ And the consumer sends a request to the `echo` queue
18
+ And the consumer receives the reply
19
+ And a producer on another connection counting the requests to the `numbered` queue, answering when told
20
+
21
+ Scenario: Requests through the broker that stays are not re-sent when the other one is lost
22
+ Given the consumer's connection to broker 1 is cut
23
+ When the consumer sends 10 numbered requests to the `numbered` queue
24
+ And the consumer's connection to broker 1 is restored
25
+ And the consumer's connection to broker 1 is cut
26
+ And the producer answers
27
+ Then every numbered request is answered within 10 seconds
28
+ And every numbered request has been executed once
29
+
30
+ Scenario: Replies to the requests through the broker that stays are not lost with the other one
31
+ Given the consumer's connection to broker 1 is cut
32
+ When the consumer sends 10 numbered requests to the `numbered` queue
33
+ And the consumer's connection to broker 1 is restored
34
+ And the consumer's network to broker 1 goes silent
35
+ And the producer answers
36
+ Then every numbered request is answered within 15 seconds
37
+ And every numbered request has been executed once
38
+
39
+ Scenario: Requests through the broker that is lost are re-sent and answered
40
+ Given the consumer's connection to broker 0 is cut
41
+ When the consumer sends 10 numbered requests to the `numbered` queue
42
+ And the consumer's connection to broker 0 is restored
43
+ And the consumer's network to broker 1 goes silent
44
+ And the producer answers
45
+ Then every numbered request is answered within 15 seconds
46
+ And every numbered request has been executed twice
@@ -49,7 +49,7 @@ Feature: Sharded Connection
49
49
  Then the broker is up
50
50
 
51
51
  Scenario: Shard stops responding while requests are sent
52
- Given watchdog interval is set to 3000ms with 1s AMQP heartbeat
52
+ Given 1s AMQP heartbeat
53
53
  And an active sharded connection
54
54
  And a producer replying `stalled` queue
55
55
  When the consumer sends 10 requests to the `stalled` queue as a broker freezes
@@ -2,10 +2,11 @@ Feature: Silent Connection Tolerance
2
2
 
3
3
  A connection that has gone silent is what a machine that woke from sleep is
4
4
  left with: the socket is open, neither the broker nor the operating system
5
- reports anything, and nothing but the watchdog tells it from an idle connection.
5
+ reports anything, and nothing but the missing heartbeats tell it from an idle
6
+ connection.
6
7
 
7
8
  Background:
8
- Given watchdog interval is set to 2000ms with 60s AMQP heartbeat
9
+ Given 1s AMQP heartbeat
9
10
  And a network that can go silent
10
11
 
11
12
  Scenario: Restoring a connection that has gone silent
@@ -49,7 +49,11 @@ declare namespace comq.features {
49
49
  readonly address: string
50
50
 
51
51
  open(): Promise<void>
52
- silence(): void
52
+ silence(heard?: boolean): void
53
+ refuse(): void
54
+ admit(): void
55
+ release(): void
56
+ cut(): void
53
57
  close(): Promise<void>
54
58
  }
55
59
 
@@ -57,6 +61,7 @@ declare namespace comq.features {
57
61
  client: Socket
58
62
  upstream: Socket
59
63
  silent: boolean
64
+ heartbeat?: NodeJS.Timeout
60
65
  }
61
66
 
62
67
  }
@@ -139,7 +139,7 @@ const actions = {
139
139
  await docker('docker', ['kill', `comq-rmq-${n}`])
140
140
  },
141
141
  // a frozen broker keeps its connections open while answering nothing, which is
142
- // what a publisher cannot tell from a working one until the watchdog fires
142
+ // what a publisher cannot tell from a working one until its heartbeats go missing
143
143
  frozen: async (n = 0) => {
144
144
  await docker('docker', ['pause', `comq-rmq-${n}`])
145
145
  }
@@ -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
  /**
@@ -5,6 +5,16 @@ const { timeout } = require('../../test/helpers')
5
5
 
6
6
  const { Given, When, Then } = require('@cucumber/cucumber')
7
7
 
8
+ Given('{number}s AMQP heartbeat',
9
+ /**
10
+ * A connection that has received nothing for two heartbeats is taken for lost.
11
+ *
12
+ * @param {number} heartbeat
13
+ */
14
+ function (heartbeat) {
15
+ global.COMQ_TESTING_AMQP_HEARTBEAT = heartbeat
16
+ })
17
+
8
18
  Given('an active connection to the broker',
9
19
  /**
10
20
  * @this {comq.features.Context}
@@ -176,6 +186,19 @@ Then('the connection is lost within {number} second(s)',
176
186
  await emitted.call(this, 'close', seconds, 'connection was not lost')
177
187
  })
178
188
 
189
+ Then('the connection is not lost for {number} seconds',
190
+ /**
191
+ * @param {number} seconds
192
+ * @this {comq.features.Context}
193
+ */
194
+ async function (seconds) {
195
+ delete this.events.close
196
+
197
+ await timeout(seconds * 1000)
198
+
199
+ assert.equal(this.events.close, undefined, 'The connection was lost')
200
+ })
201
+
179
202
  Then('publishing is {paused-event} within {number} second(s)',
180
203
  /**
181
204
  * @param {'paused' | 'resumed'} key
@@ -114,8 +114,7 @@ class Context extends World {
114
114
  const url = PROTOCOL + user + ':' + password + '@' + address
115
115
  const query = []
116
116
 
117
- // the watchdog measures silence, so it may only be shortened along with the
118
- // interval at which a healthy broker is expected to say something
117
+ // a connection gone silent is told by its missing heartbeats, so a short one tells it soon
119
118
  if (global.COMQ_TESTING_AMQP_HEARTBEAT !== undefined) {
120
119
  query.push('heartbeat=' + global.COMQ_TESTING_AMQP_HEARTBEAT)
121
120
  }
@@ -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
  */
@@ -12,7 +12,6 @@ After(
12
12
  * @this {comq.features.Context}
13
13
  */
14
14
  async function () {
15
- delete global.COMQ_TESTING_WATCHDOG_INTERVAL
16
15
  delete global.COMQ_TESTING_AMQP_HEARTBEAT
17
16
  delete global.COMQ_TESTING_AMQP_CHANNEL_MAX
18
17
 
@@ -26,6 +26,14 @@ When('the network goes silent',
26
26
  silence.call(this)
27
27
  })
28
28
 
29
+ When('the network goes silent, with the broker still hearing heartbeats',
30
+ /**
31
+ * @this {comq.features.Context}
32
+ */
33
+ function () {
34
+ silence.call(this, true)
35
+ })
36
+
29
37
  When('the network goes silent and refuses new connections',
30
38
  /**
31
39
  * @this {comq.features.Context}
@@ -66,11 +74,12 @@ When('the consumer sends a request to the {token} queue as the network goes sile
66
74
  })
67
75
 
68
76
  /**
77
+ * @param {boolean} [heard]
69
78
  * @this {comq.features.Context}
70
79
  */
71
- function silence () {
80
+ function silence (heard) {
72
81
  // only a connection lost from now on counts as lost
73
82
  delete this.events.close
74
83
 
75
- for (const network of this.networks) network.silence()
84
+ for (const network of this.networks) network.silence(heard)
76
85
  }