comq 0.24.0 → 0.25.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 (48) hide show
  1. package/.husky/commit-msg +5 -0
  2. package/benchmarks/run.js +66 -4
  3. package/benchmarks/server.js +14 -0
  4. package/commitlint.config.js +6 -0
  5. package/features/heartbeat.feature +16 -0
  6. package/features/rpc.call.recovery.feature +2 -2
  7. package/features/rpc.shards.retransmission.feature +46 -0
  8. package/features/rpc.streams.feature +28 -0
  9. package/features/shards.feature +1 -1
  10. package/features/silence.feature +3 -2
  11. package/features/steps/.types/context.d.ts +6 -1
  12. package/features/steps/brokers.js +1 -1
  13. package/features/steps/connection.js +23 -0
  14. package/features/steps/context.js +1 -2
  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/retransmission.js +165 -0
  19. package/features/steps/rpc.js +31 -7
  20. package/package.json +31 -9
  21. package/readme.md +15 -4
  22. package/source/.io/ReplyPipe.js +130 -11
  23. package/source/.io/ReplyStream.js +41 -2
  24. package/source/.io/const.js +6 -0
  25. package/source/attributes/track.test.js +1 -1
  26. package/source/channel.js +5 -3
  27. package/source/connection.js +11 -88
  28. package/source/io.js +91 -16
  29. package/source/promex.js +47 -0
  30. package/source/promex.test.js +79 -0
  31. package/source/retry.js +42 -0
  32. package/source/retry.test.js +120 -0
  33. package/source/shards/channel.js +74 -13
  34. package/test/.types/jest.d.ts +22 -0
  35. package/test/ReplyPipe.test.js +71 -2
  36. package/test/ReplyStream.test.js +33 -0
  37. package/test/connection.mock.js +3 -2
  38. package/test/connection.test.js +16 -78
  39. package/test/globals.js +143 -0
  40. package/test/io.call.test.js +5 -3
  41. package/test/io.close.test.js +1 -1
  42. package/test/io.reply.test.js +5 -3
  43. package/test/io.request.test.js +264 -2
  44. package/test/memory.test.js +0 -2
  45. package/test/shards/channel.test.js +169 -3
  46. package/test/shards/connection.test.js +2 -3
  47. package/types/channel.d.ts +13 -4
  48. package/types/io.d.ts +9 -0
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env sh
2
+
3
+ [ -n "$CI" ] && exit 0
4
+
5
+ npx --no -- commitlint --edit "$1"
package/benchmarks/run.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What one request costs the process that answers it. See readme.md.
2
2
  //
3
3
  // node benchmarks/run.js [--rates 1000,5000] [--concurrency 1,8] [--messages 20000]
4
- // [--seconds 6] [--rounds 2]
4
+ // [--seconds 6] [--rounds 2] [--streams 8000]
5
5
 
6
6
  import { fork } from 'node:child_process'
7
7
  import { parseArgs } from 'node:util'
@@ -17,7 +17,8 @@ const { values } = parseArgs({
17
17
  concurrency: { type: 'string', default: '1,8,64,256' },
18
18
  messages: { type: 'string', default: '20000' },
19
19
  seconds: { type: 'string', default: '6' },
20
- rounds: { type: 'string', default: '2' }
20
+ rounds: { type: 'string', default: '2' },
21
+ streams: { type: 'string', default: '' }
21
22
  }
22
23
  })
23
24
 
@@ -27,6 +28,7 @@ const concurrencies = numbers(values.concurrency)
27
28
  const messages = Number(values.messages)
28
29
  const seconds = Number(values.seconds)
29
30
  const rounds = Number(values.rounds)
31
+ const streams = values.streams === '' ? [] : numbers(values.streams)
30
32
 
31
33
  const durations = []
32
34
 
@@ -45,14 +47,24 @@ const io = await connect(url)
45
47
  try {
46
48
  const open = []
47
49
  const closed = []
50
+ const streamed = []
48
51
 
49
52
  for (let round = 1; round <= rounds; round++) {
53
+ if (streams.length > 0) {
54
+ for (const length of streams) streamed.push(await stream(length, round))
55
+
56
+ continue
57
+ }
58
+
50
59
  for (const rate of rates) open.push(await atRate(rate, round))
51
60
  for (const concurrency of concurrencies) closed.push(await inFlight(concurrency, round))
52
61
  }
53
62
 
54
- report('At a fixed rate', ['rate'], open)
55
- report('With requests in flight', ['in flight'], closed)
63
+ if (streams.length > 0) reportStreams(streamed)
64
+ else {
65
+ report('At a fixed rate', ['rate'], open)
66
+ report('With requests in flight', ['in flight'], closed)
67
+ }
56
68
  } finally {
57
69
  await io.close()
58
70
 
@@ -105,6 +117,56 @@ async function load (work) {
105
117
  }
106
118
  }
107
119
 
120
+ /**
121
+ * A reply stream of `length` values, read to its end: what it cost the peer that sent it and this
122
+ * process that read it, per value, and how long it took.
123
+ */
124
+ async function stream (length, round) {
125
+ await readStream(Math.min(length, 1000))
126
+
127
+ const before = await io.request('comq.benchmark.meter', null)
128
+ const cpu = process.cpuUsage()
129
+ const started = process.hrtime.bigint()
130
+
131
+ await readStream(length)
132
+
133
+ const elapsed = Number(process.hrtime.bigint() - started) / 1e6
134
+ const consumer = process.cpuUsage(cpu)
135
+ const after = await io.request('comq.benchmark.meter', null)
136
+ const producer = after.cpu.user + after.cpu.system - before.cpu.user - before.cpu.system
137
+
138
+ return {
139
+ round,
140
+ of: length,
141
+ ms: round2(elapsed),
142
+ producer: round2(producer / length),
143
+ consumer: round2((consumer.user + consumer.system) / length),
144
+ writes: round3((after.write + after.writev - before.write - before.writev) / length)
145
+ }
146
+ }
147
+
148
+ async function readStream (length) {
149
+ const reply = await io.request('comq.benchmark.stream', { length })
150
+
151
+ let read = 0
152
+
153
+ // eslint-disable-next-line no-unused-vars
154
+ for await (const _ of reply) read++
155
+
156
+ if (read !== length) throw new Error(`The stream held ${read} values of ${length}`)
157
+ }
158
+
159
+ function reportStreams (rows) {
160
+ console.log('\n## A reply stream\n')
161
+ console.log('| values | round | ms | producer CPU µs | consumer CPU µs | writes |')
162
+ console.log('| ---: | ---: | ---: | ---: | ---: | ---: |')
163
+
164
+ for (const row of rows) {
165
+ console.log(`| ${row.of} | ${row.round} | ${row.ms} | ${row.producer} | ${row.consumer} | ` +
166
+ `${row.writes} |`)
167
+ }
168
+ }
169
+
108
170
  async function send (rate, duration, record) {
109
171
  const started = process.hrtime.bigint()
110
172
  const pending = []
@@ -32,6 +32,15 @@ const url = process.env.COMQ_BENCHMARK_URL ?? 'amqp://developer:secret@localhost
32
32
 
33
33
  let answered = 0
34
34
 
35
+ /** What a value of a reply stream carries: a record, as a set read whole is made of. */
36
+ const VALUE = {
37
+ id: '4c4759e6f9c74da989d64511df42d6f4',
38
+ title: 'First pot',
39
+ rank: 7,
40
+ public: true,
41
+ tags: ['one', 'two', 'three']
42
+ }
43
+
35
44
  const io = await connect(url)
36
45
 
37
46
  await io.reply('comq.benchmark.echo', (payload) => {
@@ -40,6 +49,11 @@ await io.reply('comq.benchmark.echo', (payload) => {
40
49
  return payload
41
50
  })
42
51
 
52
+ // a reply stream of the values a caller asks for, each the size of an echo's payload
53
+ await io.reply('comq.benchmark.stream', function * ({ length }) {
54
+ for (let i = 0; i < length; i++) yield { ...VALUE, i }
55
+ })
56
+
43
57
  // read before and after a window, so what is reported is what that window cost
44
58
  await io.reply('comq.benchmark.meter', () => ({ answered, cpu: process.cpuUsage(), ...counters }))
45
59
 
@@ -0,0 +1,6 @@
1
+ 'use strict'
2
+
3
+ // the same convention semantic-release reads the history with (see .releaserc)
4
+ module.exports = {
5
+ extends: ['@commitlint/config-conventional']
6
+ }
@@ -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
@@ -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
@@ -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
@@ -21,6 +21,34 @@ Feature: Reply streams
21
21
  [0, 1, 2, 3, 4]
22
22
  """
23
23
 
24
+ Scenario: Requesting a stream of many values at once
25
+ Given function replying `get_numbers` queue:
26
+ """
27
+ function * ({ limit }) {
28
+ for (let i = 0; i < limit; i++) yield { i }
29
+ }
30
+ """
31
+ When the consumer requests a stream with the following request to the `get_numbers` queue:
32
+ """yaml
33
+ limit: 300
34
+ """
35
+ Then the consumer receives a stream of 300 values in order
36
+
37
+ Scenario: Requesting a stream of buffers
38
+ Given function replying `get_bytes` queue:
39
+ """
40
+ function * () {
41
+ yield Buffer.from('first')
42
+ yield Buffer.alloc(0)
43
+ yield Buffer.from('third')
44
+ }
45
+ """
46
+ When the consumer requests a stream with request to the `get_bytes` queue
47
+ Then the consumer receives the stream of buffers:
48
+ """yaml
49
+ [first, '', third]
50
+ """
51
+
24
52
  Scenario: Requesting streams concurrently
25
53
  Given a number generator with 10ms increasing delay replying `get_numbers` queue
26
54
  When the consumer0 requests a stream with request to the `get_numbers` queue
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -48,9 +48,19 @@ class Network {
48
48
  /**
49
49
  * Only the tunnels that are already open go silent: a connection established
50
50
  * afterwards is forwarded as usual, just as it is once a machine is awake.
51
+ *
52
+ * The broker notices a silent connection by its heartbeat as soon as the client does. One
53
+ * that is to be held by the broker for as long as the scenario needs is kept heard from:
54
+ * heartbeats are sent to the broker in the client's stead, until the network lets it go.
55
+ *
56
+ * @param {boolean} [heard] whether the broker goes on hearing heartbeats
51
57
  */
52
- silence () {
53
- for (const tunnel of this.#tunnels) tunnel.silent = true
58
+ silence (heard = false) {
59
+ for (const tunnel of this.#tunnels) {
60
+ tunnel.silent = true
61
+
62
+ if (heard) tunnel.heartbeat ??= setInterval(() => tunnel.upstream.write(HEARTBEAT), HEARTBEAT_MS)
63
+ }
54
64
  }
55
65
 
56
66
  /**
@@ -65,6 +75,17 @@ class Network {
65
75
  this.#refusing = false
66
76
  }
67
77
 
78
+ /**
79
+ * Takes the broker away from whoever is connected through this network: the tunnels that are
80
+ * open are closed at both ends, and a connection attempted from now on is refused until the
81
+ * network admits it.
82
+ */
83
+ cut () {
84
+ this.refuse()
85
+
86
+ for (const tunnel of this.#tunnels) this.#collapse(tunnel)
87
+ }
88
+
68
89
  /**
69
90
  * Closes the tunnels that went silent, at both ends, so the broker learns that the
70
91
  * connection it was still holding is gone — the moment its heartbeat would otherwise tell it.
@@ -117,10 +138,18 @@ class Network {
117
138
  * @param {comq.features.Tunnel} tunnel
118
139
  */
119
140
  #collapse (tunnel) {
141
+ clearInterval(tunnel.heartbeat)
142
+
120
143
  this.#tunnels.delete(tunnel)
121
144
  tunnel.client.destroy()
122
145
  tunnel.upstream.destroy()
123
146
  }
124
147
  }
125
148
 
149
+ /** An AMQP heartbeat frame: type 8, channel 0, no payload, and the frame end. */
150
+ const HEARTBEAT = Buffer.from([8, 0, 0, 0, 0, 0, 0, 0xCE])
151
+
152
+ /** Often enough for the shortest heartbeat a scenario asks for. */
153
+ const HEARTBEAT_MS = 250
154
+
126
155
  exports.Network = Network
@@ -0,0 +1,165 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { Promex } = require('../../source/promex')
5
+ const { Given, When, Then, After } = require('@cucumber/cucumber')
6
+
7
+ const { connect } = require('../../')
8
+ const { getAddress, USER, PASSWORD, BROKERS_AMOUNT } = require('./brokers')
9
+ const { timeout, until } = require('../../test/helpers')
10
+
11
+ Given('a producer on another connection counting the requests to the {token} queue, answering when told',
12
+ /**
13
+ * Connected to every broker directly, so that nothing done to the consumer's network reaches it.
14
+ * It takes every Request as it comes, counts how many times it has been handed each of them, and
15
+ * holds the replies back until it is told to answer: whatever the consumer re-sends by then is
16
+ * counted, however quickly the first attempt would have been answered.
17
+ *
18
+ * @param {string} queue
19
+ * @this {comq.features.Context}
20
+ */
21
+ async function (queue) {
22
+ const urls = Array.from({ length: BROKERS_AMOUNT }, (_, n) =>
23
+ `amqp://${USER}:${PASSWORD}@${getAddress(n)}`)
24
+
25
+ this.producer = await connect(...urls)
26
+ this.executions = new Map()
27
+ this.answering = new Promex()
28
+
29
+ await this.producer.reply(queue, async ({ id }) => {
30
+ this.executions.set(id, (this.executions.get(id) ?? 0) + 1)
31
+
32
+ await this.answering
33
+
34
+ return id
35
+ })
36
+ })
37
+
38
+ When('the producer answers',
39
+ /**
40
+ * @this {comq.features.Context}
41
+ */
42
+ function () {
43
+ this.answering.resolve()
44
+ })
45
+
46
+ When('the consumer sends {number} numbered requests to the {token} queue',
47
+ /**
48
+ * @param {number} amount
49
+ * @param {string} queue
50
+ * @this {comq.features.Context}
51
+ */
52
+ function (amount, queue) {
53
+ this.numbered = Array.from({ length: amount }, (_, id) => {
54
+ const reply = this.io.request(queue, { id })
55
+
56
+ // asserted on by a later step, and an unhandled rejection would end the run before it
57
+ reply.catch(() => undefined)
58
+
59
+ return reply
60
+ })
61
+ })
62
+
63
+ When('the consumer\'s connection to broker {number} is cut',
64
+ /**
65
+ * The broker is taken away from the consumer alone: the connection closes at both ends, and the
66
+ * consumer is refused until it is restored. Returns once the consumer has noticed, so that what
67
+ * it sends next goes to the other broker.
68
+ *
69
+ * @param {number} broker
70
+ * @this {comq.features.Context}
71
+ */
72
+ async function (broker) {
73
+ let lost = false
74
+
75
+ this.io.diagnose('close', (_error, index) => { if (index === broker) lost = true })
76
+ this.networks[broker].cut()
77
+
78
+ assert.equal(await until(() => lost, 10_000), true,
79
+ `The consumer's connection to broker ${broker} was not lost`)
80
+ })
81
+
82
+ When('the consumer\'s connection to broker {number} is restored',
83
+ /**
84
+ * Returns once the consumer's channels to that broker have recovered, which is when the
85
+ * requests it may have lost with it are re-sent.
86
+ *
87
+ * @param {number} broker
88
+ * @this {comq.features.Context}
89
+ */
90
+ async function (broker) {
91
+ const recovered = new Set()
92
+
93
+ this.io.diagnose('recover', (type, index) => { if (index === broker) recovered.add(type) })
94
+ this.networks[broker].admit()
95
+
96
+ const restored = () => recovered.has('request') && recovered.has('reply')
97
+
98
+ assert.equal(await until(restored, 20_000), true,
99
+ `The consumer's channels to broker ${broker} have not recovered`)
100
+ })
101
+
102
+ When('the consumer\'s network to broker {number} goes silent',
103
+ /**
104
+ * @param {number} broker
105
+ * @this {comq.features.Context}
106
+ */
107
+ function (broker) {
108
+ this.networks[broker].silence()
109
+ })
110
+
111
+ Then('every numbered request is answered within {number} seconds',
112
+ /**
113
+ * @param {number} seconds
114
+ * @this {comq.features.Context}
115
+ */
116
+ async function (seconds) {
117
+ const answered = new Array(this.numbered.length).fill(false)
118
+
119
+ this.numbered.forEach((reply, id) => reply.then(() => { answered[id] = true }, () => undefined))
120
+
121
+ await until(() => answered.every(Boolean), seconds * 1000)
122
+
123
+ const unanswered = answered.flatMap((yes, id) => yes ? [] : [id])
124
+
125
+ assert.deepEqual(unanswered, [], `Requests ${unanswered.join(', ')} are not answered`)
126
+
127
+ for (const [id, reply] of this.numbered.entries()) assert.equal(await reply, id, 'Reply mismatch')
128
+ })
129
+
130
+ Then('every numbered request has been executed {word}',
131
+ /**
132
+ * A re-sent Request is taken as soon as it arrives, so a moment is given for any copy still on
133
+ * its way to the producer to be counted.
134
+ *
135
+ * @param {'once' | 'twice'} times
136
+ * @this {comq.features.Context}
137
+ */
138
+ async function (times) {
139
+ const expected = TIMES[times]
140
+
141
+ await timeout(SETTLE_MS)
142
+
143
+ const executed = this.numbered.map((_, id) => this.executions.get(id) ?? 0)
144
+ const wrong = executed.flatMap((count, id) => count === expected ? [] : [`${id}: ${count}`])
145
+
146
+ assert.deepEqual(wrong, [], `Requests executed other than ${times} (id: times): ${wrong.join(', ')}`)
147
+ })
148
+
149
+ After(
150
+ /**
151
+ * @this {comq.features.Context}
152
+ */
153
+ async function () {
154
+ this.answering?.resolve()
155
+ await this.producer?.close()
156
+
157
+ this.producer = undefined
158
+ this.executions = undefined
159
+ this.answering = undefined
160
+ this.numbered = undefined
161
+ })
162
+
163
+ const TIMES = { once: 1, twice: 2 }
164
+
165
+ const SETTLE_MS = 1000