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.
- package/.husky/commit-msg +5 -0
- package/benchmarks/run.js +66 -4
- package/benchmarks/server.js +14 -0
- package/commitlint.config.js +6 -0
- package/features/heartbeat.feature +16 -0
- package/features/rpc.call.recovery.feature +2 -2
- package/features/rpc.shards.retransmission.feature +46 -0
- package/features/rpc.streams.feature +28 -0
- package/features/shards.feature +1 -1
- package/features/silence.feature +3 -2
- package/features/steps/.types/context.d.ts +6 -1
- package/features/steps/brokers.js +1 -1
- package/features/steps/connection.js +23 -0
- package/features/steps/context.js +1 -2
- package/features/steps/hooks.js +0 -1
- package/features/steps/network.js +11 -2
- package/features/steps/networks.js +31 -2
- package/features/steps/retransmission.js +165 -0
- package/features/steps/rpc.js +31 -7
- package/package.json +31 -9
- package/readme.md +15 -4
- package/source/.io/ReplyPipe.js +130 -11
- package/source/.io/ReplyStream.js +41 -2
- package/source/.io/const.js +6 -0
- package/source/attributes/track.test.js +1 -1
- package/source/channel.js +5 -3
- package/source/connection.js +11 -88
- package/source/io.js +91 -16
- package/source/promex.js +47 -0
- package/source/promex.test.js +79 -0
- package/source/retry.js +42 -0
- package/source/retry.test.js +120 -0
- package/source/shards/channel.js +74 -13
- package/test/.types/jest.d.ts +22 -0
- package/test/ReplyPipe.test.js +71 -2
- package/test/ReplyStream.test.js +33 -0
- package/test/connection.mock.js +3 -2
- package/test/connection.test.js +16 -78
- package/test/globals.js +143 -0
- package/test/io.call.test.js +5 -3
- package/test/io.close.test.js +1 -1
- package/test/io.reply.test.js +5 -3
- package/test/io.request.test.js +264 -2
- package/test/memory.test.js +0 -2
- package/test/shards/channel.test.js +169 -3
- package/test/shards/connection.test.js +2 -3
- package/types/channel.d.ts +13 -4
- package/types/io.d.ts +9 -0
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
|
-
|
|
55
|
-
|
|
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 = []
|
package/benchmarks/server.js
CHANGED
|
@@ -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,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
|
|
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
|
package/features/shards.feature
CHANGED
|
@@ -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
|
|
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
|
package/features/silence.feature
CHANGED
|
@@ -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
|
|
5
|
+
reports anything, and nothing but the missing heartbeats tell it from an idle
|
|
6
|
+
connection.
|
|
6
7
|
|
|
7
8
|
Background:
|
|
8
|
-
Given
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|
package/features/steps/hooks.js
CHANGED
|
@@ -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)
|
|
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
|