comq 0.20.1 → 0.21.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/pre-commit +1 -3
- package/benchmarks/package.json +3 -0
- package/benchmarks/readme.md +65 -0
- package/benchmarks/run.js +196 -0
- package/benchmarks/server.js +47 -0
- package/features/rpc.streams.feature +10 -12
- package/features/rpc.streams.shards.feature +5 -5
- package/features/steps/.types/context.d.ts +2 -0
- package/features/steps/rpc.js +64 -1
- package/package.json +2 -1
- package/readme.md +12 -1
- package/source/.io/ReplyStream.js +16 -5
- package/source/batch.js +62 -0
- package/source/connection.js +4 -0
- package/source/index.js +2 -0
- package/source/interrupted.js +23 -0
- package/test/ReplyStream.test.js +45 -0
- package/test/batch.test.js +94 -0
- package/types/index.d.ts +8 -0
package/.husky/pre-commit
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Benchmarks
|
|
2
|
+
|
|
3
|
+
What one request costs the peer that answers it: the CPU it spends, the socket writes it makes,
|
|
4
|
+
and what the caller waits.
|
|
5
|
+
|
|
6
|
+
```shell
|
|
7
|
+
$ docker compose up -d
|
|
8
|
+
$ npm run benchmark
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The peer runs in a process of its own — that is where its CPU is read from — and answers an echo
|
|
12
|
+
of a small object over `request`/`reply` on the broker `docker-compose.yaml` starts.
|
|
13
|
+
|
|
14
|
+
## What it reports
|
|
15
|
+
|
|
16
|
+
Two tables, because the two say different things.
|
|
17
|
+
|
|
18
|
+
**At a fixed rate** sends what a rate calls for and does not wait for the answers, which is how
|
|
19
|
+
traffic arrives. A rate below what the peer can serve is the honest place to read latency.
|
|
20
|
+
|
|
21
|
+
**With requests in flight** keeps a fixed number of them outstanding, which is how a queue of
|
|
22
|
+
callers behaves, and at a high enough number it is where the peer saturates: the rate column is
|
|
23
|
+
then what the peer can serve, and the CPU column is what one message costs it there.
|
|
24
|
+
|
|
25
|
+
Each row carries:
|
|
26
|
+
|
|
27
|
+
| column | what it is |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `messages/s` | answered in the window, over its length |
|
|
30
|
+
| `CPU µs` | user and system time of the answering process, per message |
|
|
31
|
+
| `writes`, `writev` | socket write calls of the answering process, per message |
|
|
32
|
+
| `p50`, `p99` | what the caller waited, milliseconds |
|
|
33
|
+
|
|
34
|
+
`writes` and `writev` are the mechanism, not the outcome: a message the library writes as its own
|
|
35
|
+
syscall costs more than one written together with its neighbours.
|
|
36
|
+
|
|
37
|
+
## Reading a result
|
|
38
|
+
|
|
39
|
+
A number here means something only beside another number from the same machine. Run it on the
|
|
40
|
+
revision you are changing, then on your change, and compare the columns; a difference under about
|
|
41
|
+
5% between two rounds of the same revision is what this machine's noise looks like.
|
|
42
|
+
|
|
43
|
+
Both rounds are printed rather than averaged, so a run that drifted is visible as a run that
|
|
44
|
+
drifted.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| option | default | what it does |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `--rates` | `1000,5000,20000,40000` | messages a second for the fixed-rate table |
|
|
51
|
+
| `--concurrency` | `1,8,64,256` | requests in flight for the second table |
|
|
52
|
+
| `--messages` | `20000` | messages per window, in flight mode |
|
|
53
|
+
| `--seconds` | `6` | length of a window, fixed-rate mode |
|
|
54
|
+
| `--rounds` | `2` | how many times the whole matrix is measured |
|
|
55
|
+
|
|
56
|
+
`COMQ_BENCHMARK_URL` points the run at another broker.
|
|
57
|
+
|
|
58
|
+
```shell
|
|
59
|
+
$ npm run benchmark -- --rates 5000 --concurrency 64 --rounds 3
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What it does not measure
|
|
63
|
+
|
|
64
|
+
Streams, confirms, sharded or singleton connections, and recovery. It measures the path a request
|
|
65
|
+
and its reply take, which is the one every other path is built on.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// What one request costs the process that answers it. See readme.md.
|
|
2
|
+
//
|
|
3
|
+
// node benchmarks/run.js [--rates 1000,5000] [--concurrency 1,8] [--messages 20000]
|
|
4
|
+
// [--seconds 6] [--rounds 2]
|
|
5
|
+
|
|
6
|
+
import { fork } from 'node:child_process'
|
|
7
|
+
import { parseArgs } from 'node:util'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
const { connect } = require('../source/index.js')
|
|
13
|
+
|
|
14
|
+
const { values } = parseArgs({
|
|
15
|
+
options: {
|
|
16
|
+
rates: { type: 'string', default: '1000,5000,20000,40000' },
|
|
17
|
+
concurrency: { type: 'string', default: '1,8,64,256' },
|
|
18
|
+
messages: { type: 'string', default: '20000' },
|
|
19
|
+
seconds: { type: 'string', default: '6' },
|
|
20
|
+
rounds: { type: 'string', default: '2' }
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const url = process.env.COMQ_BENCHMARK_URL ?? 'amqp://developer:secret@localhost:5673'
|
|
25
|
+
const rates = numbers(values.rates)
|
|
26
|
+
const concurrencies = numbers(values.concurrency)
|
|
27
|
+
const messages = Number(values.messages)
|
|
28
|
+
const seconds = Number(values.seconds)
|
|
29
|
+
const rounds = Number(values.rounds)
|
|
30
|
+
|
|
31
|
+
const durations = []
|
|
32
|
+
|
|
33
|
+
/** What a request carries, which is small, as a call between components is. */
|
|
34
|
+
const payload = {
|
|
35
|
+
id: '4c4759e6f9c74da989d64511df42d6f4',
|
|
36
|
+
title: 'First pot',
|
|
37
|
+
rank: 7,
|
|
38
|
+
public: true,
|
|
39
|
+
tags: ['one', 'two', 'three']
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const server = await start()
|
|
43
|
+
const io = await connect(url)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const open = []
|
|
47
|
+
const closed = []
|
|
48
|
+
|
|
49
|
+
for (let round = 1; round <= rounds; round++) {
|
|
50
|
+
for (const rate of rates) open.push(await atRate(rate, round))
|
|
51
|
+
for (const concurrency of concurrencies) closed.push(await inFlight(concurrency, round))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
report('At a fixed rate', ['rate'], open)
|
|
55
|
+
report('With requests in flight', ['in flight'], closed)
|
|
56
|
+
} finally {
|
|
57
|
+
await io.close()
|
|
58
|
+
|
|
59
|
+
server.kill()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Requests sent at a rate, as traffic arrives: a millisecond's worth at a time, none of them
|
|
64
|
+
* waited for. What a peer costs per message is read from the peer itself.
|
|
65
|
+
*/
|
|
66
|
+
async function atRate (rate, round) {
|
|
67
|
+
await load(() => send(rate, 2, false))
|
|
68
|
+
|
|
69
|
+
const measured = await load(() => send(rate, seconds, true))
|
|
70
|
+
|
|
71
|
+
return { round, of: rate, ...measured }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Requests kept in flight, as a queue of callers is: the peer is never idle between them. */
|
|
75
|
+
async function inFlight (concurrency, round) {
|
|
76
|
+
await load(() => saturate(concurrency, Math.min(5000, messages), false))
|
|
77
|
+
|
|
78
|
+
const measured = await load(() => saturate(concurrency, messages, true))
|
|
79
|
+
|
|
80
|
+
return { round, of: concurrency, ...measured }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A window: what the peer spent, and what the caller waited, between two readings. */
|
|
84
|
+
async function load (work) {
|
|
85
|
+
durations.length = 0
|
|
86
|
+
|
|
87
|
+
const before = await io.request('comq.benchmark.meter', null)
|
|
88
|
+
const started = process.hrtime.bigint()
|
|
89
|
+
|
|
90
|
+
await work()
|
|
91
|
+
|
|
92
|
+
const elapsed = Number(process.hrtime.bigint() - started) / 1e9
|
|
93
|
+
const after = await io.request('comq.benchmark.meter', null)
|
|
94
|
+
const answered = after.answered - before.answered
|
|
95
|
+
const cpu = after.cpu.user + after.cpu.system - before.cpu.user - before.cpu.system
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
answered,
|
|
99
|
+
rate: Math.round(answered / elapsed),
|
|
100
|
+
cpu: round2(cpu / answered),
|
|
101
|
+
writes: round3((after.write - before.write) / answered),
|
|
102
|
+
writevs: round3((after.writev - before.writev) / answered),
|
|
103
|
+
p50: percentile(0.5),
|
|
104
|
+
p99: percentile(0.99)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function send (rate, duration, record) {
|
|
109
|
+
const started = process.hrtime.bigint()
|
|
110
|
+
const pending = []
|
|
111
|
+
|
|
112
|
+
let sent = 0
|
|
113
|
+
|
|
114
|
+
while (elapsed(started) < duration) {
|
|
115
|
+
const due = Math.round(elapsed(started) * rate)
|
|
116
|
+
|
|
117
|
+
while (sent < due) {
|
|
118
|
+
sent++
|
|
119
|
+
pending.push(request(record))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await sleep(1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await Promise.all(pending)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function saturate (concurrency, count, record) {
|
|
129
|
+
let sent = 0
|
|
130
|
+
|
|
131
|
+
const caller = async () => {
|
|
132
|
+
while (sent < count) {
|
|
133
|
+
sent++
|
|
134
|
+
|
|
135
|
+
await request(record)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await Promise.all(Array.from({ length: concurrency }, caller))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function request (record) {
|
|
143
|
+
const at = process.hrtime.bigint()
|
|
144
|
+
|
|
145
|
+
await io.request('comq.benchmark.echo', payload)
|
|
146
|
+
|
|
147
|
+
if (record) durations.push(Number(process.hrtime.bigint() - at) / 1e6)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function report (title, columns, rows) {
|
|
151
|
+
console.log(`\n## ${title}\n`)
|
|
152
|
+
console.log(`| ${columns[0]} | round | messages/s | CPU µs | writes | writev | p50 ms | p99 ms |`)
|
|
153
|
+
console.log('| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
|
|
154
|
+
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
console.log(`| ${row.of} | ${row.round} | ${row.rate} | ${row.cpu} | ${row.writes} | ` +
|
|
157
|
+
`${row.writevs} | ${row.p50} | ${row.p99} |`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The peer in a process of its own, which is where its CPU is measured. */
|
|
162
|
+
async function start () {
|
|
163
|
+
const child = fork(new URL('server.js', import.meta.url), { stdio: 'inherit' })
|
|
164
|
+
|
|
165
|
+
await new Promise((resolve, reject) => {
|
|
166
|
+
child.once('message', resolve)
|
|
167
|
+
child.once('exit', (code) => reject(new Error(`The peer exited with ${code}`)))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return child
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function percentile (share) {
|
|
174
|
+
// a warm-up window records nothing, and its result is discarded
|
|
175
|
+
if (durations.length === 0) return 0
|
|
176
|
+
|
|
177
|
+
const sorted = durations.slice().sort((a, b) => a - b)
|
|
178
|
+
|
|
179
|
+
return round3(sorted[Math.floor(sorted.length * share)])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function elapsed (since) {
|
|
183
|
+
return Number(process.hrtime.bigint() - since) / 1e9
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function numbers (list) {
|
|
187
|
+
return list.split(',').map(Number)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function round2 (value) {
|
|
191
|
+
return Number(value.toFixed(2))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function round3 (value) {
|
|
195
|
+
return Number(value.toFixed(3))
|
|
196
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The peer under measurement: it answers an echo, and reports what answering cost it.
|
|
2
|
+
//
|
|
3
|
+
// The socket's write methods are counted here rather than in the library, because what a message
|
|
4
|
+
// costs a process is mostly the syscalls it makes: a reply and the acknowledgement of the request
|
|
5
|
+
// it answers are two writes unless something puts them together.
|
|
6
|
+
|
|
7
|
+
import net from 'node:net'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const { connect } = require('../source/index.js')
|
|
12
|
+
|
|
13
|
+
const counters = { write: 0, writev: 0, chunks: 0 }
|
|
14
|
+
const write = net.Socket.prototype._write
|
|
15
|
+
const writev = net.Socket.prototype._writev
|
|
16
|
+
|
|
17
|
+
net.Socket.prototype._write = function (...args) {
|
|
18
|
+
counters.write++
|
|
19
|
+
counters.chunks++
|
|
20
|
+
|
|
21
|
+
return write.apply(this, args)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
net.Socket.prototype._writev = function (chunks, ...rest) {
|
|
25
|
+
counters.writev++
|
|
26
|
+
counters.chunks += chunks.length
|
|
27
|
+
|
|
28
|
+
return writev.apply(this, [chunks, ...rest])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = process.env.COMQ_BENCHMARK_URL ?? 'amqp://developer:secret@localhost:5673'
|
|
32
|
+
|
|
33
|
+
let answered = 0
|
|
34
|
+
|
|
35
|
+
const io = await connect(url)
|
|
36
|
+
|
|
37
|
+
await io.reply('comq.benchmark.echo', (payload) => {
|
|
38
|
+
answered++
|
|
39
|
+
|
|
40
|
+
return payload
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// read before and after a window, so what is reported is what that window cost
|
|
44
|
+
await io.reply('comq.benchmark.meter', () => ({ answered, cpu: process.cpuUsage(), ...counters }))
|
|
45
|
+
|
|
46
|
+
process.send?.('ready')
|
|
47
|
+
console.log('ready')
|
|
@@ -49,12 +49,13 @@ Feature: Reply streams
|
|
|
49
49
|
Given heartbeat interval is set to 300ms
|
|
50
50
|
And a number generator with 100ms increasing delay replying `get_numbers` queue
|
|
51
51
|
When the consumer requests a stream with request to the `get_numbers` queue
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
And
|
|
57
|
-
|
|
52
|
+
And the consumer receives the stream
|
|
53
|
+
# a stream that stopped arriving is not a stream that ended
|
|
54
|
+
Then the consumer's stream terminates
|
|
55
|
+
And a stream that lost values has raised
|
|
56
|
+
And the consumer has received a prefix of the stream
|
|
57
|
+
And after 200ms
|
|
58
|
+
And the generator is destroyed
|
|
58
59
|
|
|
59
60
|
Scenario: Reply stream heartbeat
|
|
60
61
|
# delay will exceed the idle timeout on the 3rd reply
|
|
@@ -71,12 +72,9 @@ Feature: Reply streams
|
|
|
71
72
|
Then the consumer receives the stream
|
|
72
73
|
And after 300ms
|
|
73
74
|
Then the broker has crashed
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"""yaml
|
|
78
|
-
[0, 1, 2]
|
|
79
|
-
"""
|
|
75
|
+
Then the consumer's stream terminates
|
|
76
|
+
And a stream that lost values has raised
|
|
77
|
+
And the consumer has received a prefix of the stream
|
|
80
78
|
Then after 200ms
|
|
81
79
|
Then the broker is up
|
|
82
80
|
And the generator is destroyed
|
|
@@ -29,10 +29,10 @@ Feature: Reply streams over shards
|
|
|
29
29
|
And after 300ms
|
|
30
30
|
And the broker has crashed
|
|
31
31
|
And the broker is up
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
# a stream whose shard carried a value away with it is cut short; one over a shard that
|
|
33
|
+
# survived is not, and neither of them ends as though it had been answered whole
|
|
34
|
+
Then the consumer's stream terminates
|
|
35
|
+
And a stream that lost values has raised
|
|
36
|
+
And the consumer has received a prefix of the stream
|
|
37
37
|
And after 1000ms
|
|
38
38
|
And the generator is destroyed
|
|
@@ -31,6 +31,8 @@ declare namespace comq.features {
|
|
|
31
31
|
stream: Readable
|
|
32
32
|
streamValues: any[]
|
|
33
33
|
streamEnded: boolean
|
|
34
|
+
streamError?: Error
|
|
35
|
+
streamLength?: number
|
|
34
36
|
streams: Record<number, Readable>
|
|
35
37
|
streamsValues: Record<number, any[]>
|
|
36
38
|
streamsEnded: Record<number, boolean>
|
package/features/steps/rpc.js
CHANGED
|
@@ -48,8 +48,10 @@ Given('a number generator with {number}ms increasing delay replying {token} queu
|
|
|
48
48
|
async function (delay, queue) {
|
|
49
49
|
const that = this
|
|
50
50
|
|
|
51
|
+
this.streamLength = MAX
|
|
52
|
+
|
|
51
53
|
class Stream extends Readable {
|
|
52
|
-
#MAX =
|
|
54
|
+
#MAX = MAX
|
|
53
55
|
#count = 0
|
|
54
56
|
|
|
55
57
|
constructor () {
|
|
@@ -305,6 +307,62 @@ Then('the consumer receives the stream',
|
|
|
305
307
|
async function () {
|
|
306
308
|
this.stream.on('data', (data) => this.streamValues.push(data))
|
|
307
309
|
this.stream.on('end', () => (this.streamEnded = true))
|
|
310
|
+
this.stream.on('error', (error) => (this.streamError = error))
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
Then('the consumer\'s stream terminates',
|
|
314
|
+
/**
|
|
315
|
+
* Either way it terminates: whole, or cut short. Bounded, so that a stream that never does
|
|
316
|
+
* fails the scenario rather than hanging it.
|
|
317
|
+
*
|
|
318
|
+
* @this {comq.features.Context}
|
|
319
|
+
*/
|
|
320
|
+
async function () {
|
|
321
|
+
if (this.stream.destroyed || this.streamEnded === true) return
|
|
322
|
+
|
|
323
|
+
// both ways it terminates, and `once` would reject on the one that raises
|
|
324
|
+
const terminated = new Promise((resolve) => {
|
|
325
|
+
this.stream.once('close', resolve)
|
|
326
|
+
this.stream.once('error', resolve)
|
|
327
|
+
})
|
|
328
|
+
const expired = timeout(TERMINATION_MS).then(() => 'expired')
|
|
329
|
+
|
|
330
|
+
assert.notEqual(await Promise.race([terminated, expired]), 'expired',
|
|
331
|
+
'The stream has neither ended nor raised')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
Then('the consumer has received a prefix of the stream',
|
|
335
|
+
/**
|
|
336
|
+
* The order of yielded values is preserved and the tail may be lost, so what arrived is the
|
|
337
|
+
* sequence from its start, with nothing missing in between and nothing twice.
|
|
338
|
+
*
|
|
339
|
+
* @this {comq.features.Context}
|
|
340
|
+
*/
|
|
341
|
+
async function () {
|
|
342
|
+
const expected = this.streamValues.map((_, index) => index)
|
|
343
|
+
|
|
344
|
+
assert.deepEqual(this.streamValues, expected,
|
|
345
|
+
`Received ${JSON.stringify(this.streamValues)}, which is not a prefix of the stream`)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
Then('a stream that lost values has raised',
|
|
349
|
+
/**
|
|
350
|
+
* A stream that did not deliver everything its producer yielded must not end as one that did:
|
|
351
|
+
* a caller cannot tell them apart otherwise.
|
|
352
|
+
*
|
|
353
|
+
* @this {comq.features.Context}
|
|
354
|
+
*/
|
|
355
|
+
async function () {
|
|
356
|
+
const whole = this.streamValues.length === this.streamLength
|
|
357
|
+
|
|
358
|
+
if (whole) assert.equal(this.streamError, undefined, 'A whole stream has raised')
|
|
359
|
+
else {
|
|
360
|
+
assert.notEqual(this.streamError, undefined,
|
|
361
|
+
`The stream ended with ${this.streamValues.length} of ${this.streamLength} values and did not raise`)
|
|
362
|
+
|
|
363
|
+
assert.equal(this.streamError.name, 'Interrupted',
|
|
364
|
+
`The stream raised ${this.streamError.name}`)
|
|
365
|
+
}
|
|
308
366
|
})
|
|
309
367
|
|
|
310
368
|
Then('the consumer{number} receives the stream',
|
|
@@ -508,5 +566,10 @@ async function fetch (queue, payload, number) {
|
|
|
508
566
|
}
|
|
509
567
|
}
|
|
510
568
|
|
|
569
|
+
const MAX = 10
|
|
570
|
+
|
|
571
|
+
/** What a stream is given to terminate in, which is longer than this generator takes. */
|
|
572
|
+
const TERMINATION_MS = 5000
|
|
573
|
+
|
|
511
574
|
global.COMQ_TESTING_IDLE_INTERVAL = 150
|
|
512
575
|
global.COMQ_TESTING_HEARTBEAT_INTERVAL = 100
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"test": "standard && jest",
|
|
59
59
|
"features": "cucumber-js --fail-fast -t 'not @manual'",
|
|
60
60
|
"features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
|
|
61
|
+
"benchmark": "node benchmarks/run.js",
|
|
61
62
|
"lint": "standard --fix --verbose | snazzy"
|
|
62
63
|
},
|
|
63
64
|
"standard": {
|
package/readme.md
CHANGED
|
@@ -377,7 +377,7 @@ the request will be retransmitted upon reconnection.
|
|
|
377
377
|
|
|
378
378
|
A heartbeat message is sent to the `replyTo` queue whenever a Reply stream idles for 5 seconds.
|
|
379
379
|
If the Consumer of the reply stream doesn't receive a reply or a heartbeat message for 12 seconds, the stream returned
|
|
380
|
-
by `IO.request` is destroyed.
|
|
380
|
+
by `IO.request` is destroyed with an `Interrupted` error.
|
|
381
381
|
These intervals are not configurable.
|
|
382
382
|
|
|
383
383
|
An "end stream" message is sent to the `replyTo` queue when the Reply stream is finished.
|
|
@@ -397,6 +397,17 @@ While consuming the Reply stream if the broker connection is lost,
|
|
|
397
397
|
or if the Consumer crashes or destroys the stream returned by `IO.request`,
|
|
398
398
|
some of the values yielded by the Reply stream may be lost.
|
|
399
399
|
|
|
400
|
+
A stream that lost values raises `Interrupted` rather than ending, so that a stream cut short is never taken for one
|
|
401
|
+
that was answered whole. What it delivered before that is the sequence from its start, in order.
|
|
402
|
+
|
|
403
|
+
```javascript
|
|
404
|
+
try {
|
|
405
|
+
for await (const number of stream) console.log(number)
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error instanceof Interrupted) console.log('the rest is not coming')
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
400
411
|
To avoid inconsistency, it is strongly recommended to use the Reply stream only with _safe_ Producers, which do not
|
|
401
412
|
change the application state.
|
|
402
413
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { Readable } = require('node:stream')
|
|
4
4
|
const { Promex } = require('promex')
|
|
5
5
|
const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
|
|
6
|
+
const { Interrupted } = require('../interrupted')
|
|
6
7
|
|
|
7
8
|
class ReplyStream extends Readable {
|
|
8
9
|
confirmation = new Promex()
|
|
@@ -36,6 +37,9 @@ class ReplyStream extends Readable {
|
|
|
36
37
|
/** Whether the producer honours `pause` and `resume`. */
|
|
37
38
|
#flow = false
|
|
38
39
|
|
|
40
|
+
/** Whether this stream has been handed to whoever asked for it, which is what confirms it. */
|
|
41
|
+
#confirmed = false
|
|
42
|
+
|
|
39
43
|
/** Whether the producer has been asked to pause. */
|
|
40
44
|
#throttled = false
|
|
41
45
|
|
|
@@ -68,13 +72,16 @@ class ReplyStream extends Readable {
|
|
|
68
72
|
this._clear()
|
|
69
73
|
this.push(null)
|
|
70
74
|
|
|
71
|
-
// a no-op once control.ok has been received
|
|
72
|
-
|
|
75
|
+
// a no-op once control.ok has been received, and until then this is what it says: the
|
|
76
|
+
// stream never started, so the Request is re-sent rather than raised to anybody
|
|
77
|
+
this.confirmation.reject(new Error(UNCONFIRMED))
|
|
73
78
|
|
|
74
79
|
if (this.#control !== undefined)
|
|
75
80
|
void this.#reply(this.#control, control.end)
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
// a stream nobody has received yet cannot raise to anybody: the Request is re-sent instead,
|
|
83
|
+
// and the rejected confirmation is what says so
|
|
84
|
+
super._destroy(this.#confirmed ? error : null, callback)
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
/**
|
|
@@ -142,7 +149,7 @@ class ReplyStream extends Readable {
|
|
|
142
149
|
*/
|
|
143
150
|
_buffer (payload, properties, size) {
|
|
144
151
|
if (this.#buffered > this.#maxBufferSize || this.#bufferedBytes + size > this.#maxBufferBytes) {
|
|
145
|
-
this.destroy()
|
|
152
|
+
this.destroy(new Interrupted('the values held until the missing one arrives no longer fit'))
|
|
146
153
|
|
|
147
154
|
return
|
|
148
155
|
}
|
|
@@ -178,6 +185,7 @@ class ReplyStream extends Readable {
|
|
|
178
185
|
case control.ok:
|
|
179
186
|
this.#control = { properties }
|
|
180
187
|
this.#flow = properties.headers?.[FLOW_HEADER] === true
|
|
188
|
+
this.#confirmed = true
|
|
181
189
|
this.confirmation.resolve()
|
|
182
190
|
break
|
|
183
191
|
case control.heartbeat:
|
|
@@ -193,7 +201,10 @@ class ReplyStream extends Readable {
|
|
|
193
201
|
_heartbeat () {
|
|
194
202
|
if (this.#timeout !== null) clearTimeout(this.#timeout)
|
|
195
203
|
|
|
196
|
-
this.#timeout = setTimeout(
|
|
204
|
+
this.#timeout = setTimeout(
|
|
205
|
+
() => this.destroy(new Interrupted(`nothing arrived within ${this.#idleInterval}ms`)),
|
|
206
|
+
this.#idleInterval
|
|
207
|
+
)
|
|
197
208
|
}
|
|
198
209
|
|
|
199
210
|
_clear () {
|
package/source/batch.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Writes as one what a connection sends in one turn of the event loop.
|
|
5
|
+
*
|
|
6
|
+
* amqplib hands its frames to the socket one write at a time, and a request served is two of
|
|
7
|
+
* them: the reply, and the acknowledgement of the request it answers. With Nagle's algorithm off,
|
|
8
|
+
* which is what a socket sending without delay runs under, each is a syscall and a packet of its
|
|
9
|
+
* own. Corked, they are held in the socket's buffer and leave together, in one `writev`, at the
|
|
10
|
+
* end of the same turn: nothing waits for a timer, and nothing waits for a write that may never
|
|
11
|
+
* come.
|
|
12
|
+
*
|
|
13
|
+
* This belongs in amqplib, whose loop does the writing, and is proposed there:
|
|
14
|
+
* https://github.com/temich/amqplib/tree/perf/one-write-per-turn. It is done here until that
|
|
15
|
+
* lands, and goes when it does.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('node:net').Socket} socket
|
|
18
|
+
*/
|
|
19
|
+
function batch (socket) {
|
|
20
|
+
// whatever a connection is carried over is left as it is unless it is a stream that corks
|
|
21
|
+
if (!corkable(socket) || socket[BATCHED] === true) return
|
|
22
|
+
|
|
23
|
+
socket[BATCHED] = true
|
|
24
|
+
|
|
25
|
+
const write = socket.write.bind(socket)
|
|
26
|
+
|
|
27
|
+
const flush = () => {
|
|
28
|
+
socket[CORKED] = false
|
|
29
|
+
socket.uncork()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cork = () => {
|
|
33
|
+
if (socket[CORKED] === true) return
|
|
34
|
+
|
|
35
|
+
socket[CORKED] = true
|
|
36
|
+
socket.cork()
|
|
37
|
+
|
|
38
|
+
process.nextTick(flush)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
socket.write = (...args) => {
|
|
42
|
+
cork()
|
|
43
|
+
|
|
44
|
+
return write(...args)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {any} socket
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function corkable (socket) {
|
|
53
|
+
return socket !== null && socket !== undefined &&
|
|
54
|
+
typeof socket.write === 'function' &&
|
|
55
|
+
typeof socket.cork === 'function' &&
|
|
56
|
+
typeof socket.uncork === 'function'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const BATCHED = Symbol('comq.batched')
|
|
60
|
+
const CORKED = Symbol('comq.corked')
|
|
61
|
+
|
|
62
|
+
exports.batch = batch
|
package/source/connection.js
CHANGED
|
@@ -6,6 +6,7 @@ const { Promex } = require('promex')
|
|
|
6
6
|
const { retry } = require('reretry')
|
|
7
7
|
|
|
8
8
|
const { failsafe } = require('./attributes')
|
|
9
|
+
const { batch } = require('./batch')
|
|
9
10
|
const presets = require('./topology')
|
|
10
11
|
const channels = require('./channel')
|
|
11
12
|
const emitter = require('./emitter')
|
|
@@ -146,6 +147,9 @@ class Connection {
|
|
|
146
147
|
|
|
147
148
|
connection.on('close', (error) => this.#close(connection, error))
|
|
148
149
|
this.#connection = connection
|
|
150
|
+
|
|
151
|
+
batch(connection.connection?.stream)
|
|
152
|
+
|
|
149
153
|
this.#armWatchdog(connection)
|
|
150
154
|
this.#diagnostics.emit('open')
|
|
151
155
|
|
package/source/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { connect, assert } = require('./connect')
|
|
4
4
|
const { Retry, Park } = require('./verdicts')
|
|
5
5
|
const { Unroutable } = require('./unroutable')
|
|
6
|
+
const { Interrupted } = require('./interrupted')
|
|
6
7
|
|
|
7
8
|
exports.connect = connect
|
|
8
9
|
exports.assert = assert
|
|
@@ -10,3 +11,4 @@ exports.assert = assert
|
|
|
10
11
|
exports.Retry = Retry
|
|
11
12
|
exports.Park = Park
|
|
12
13
|
exports.Unroutable = Unroutable
|
|
14
|
+
exports.Interrupted = Interrupted
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Reply stream that ended before its producer did: what was received is a prefix of what was
|
|
5
|
+
* yielded, and the rest is not coming. A stream that completes ends instead, so that the two
|
|
6
|
+
* cannot be taken for one another.
|
|
7
|
+
*/
|
|
8
|
+
class Interrupted extends Error {
|
|
9
|
+
/** @type {string} */
|
|
10
|
+
reason
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} reason
|
|
14
|
+
*/
|
|
15
|
+
constructor (reason) {
|
|
16
|
+
super(`Reply stream has been interrupted: ${reason}`)
|
|
17
|
+
|
|
18
|
+
this.name = 'Interrupted'
|
|
19
|
+
this.reason = reason
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
exports.Interrupted = Interrupted
|
package/test/ReplyStream.test.js
CHANGED
|
@@ -4,6 +4,8 @@ const { once } = require('node:events')
|
|
|
4
4
|
const { ReplyStream, UNCONFIRMED } = require('../source/.io/ReplyStream')
|
|
5
5
|
const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
|
|
6
6
|
const { control, FLOW_HEADER } = require('../source/.io/const')
|
|
7
|
+
const { Interrupted } = require('../source/interrupted')
|
|
8
|
+
const { timeout } = require('./helpers')
|
|
7
9
|
|
|
8
10
|
/** @type {jest.Mock} */
|
|
9
11
|
let reply
|
|
@@ -72,6 +74,49 @@ it('should reject confirmation when nothing arrives within the idle interval', a
|
|
|
72
74
|
await expect(idle.confirmation).rejects.toThrow(UNCONFIRMED)
|
|
73
75
|
})
|
|
74
76
|
|
|
77
|
+
it('should raise when nothing arrives within the idle interval', async () => {
|
|
78
|
+
global.COMQ_TESTING_IDLE_INTERVAL = 10
|
|
79
|
+
|
|
80
|
+
const request = {
|
|
81
|
+
emitter: createReplyEmitter('test'),
|
|
82
|
+
properties: { correlationId: 'raising-correlation' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const idle = new ReplyStream(request, reply)
|
|
86
|
+
|
|
87
|
+
// the stream is answered, which is when it is handed to whoever asked for it
|
|
88
|
+
idle.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
|
|
89
|
+
|
|
90
|
+
const [error] = await once(idle, 'error')
|
|
91
|
+
|
|
92
|
+
expect(error).toBeInstanceOf(Interrupted)
|
|
93
|
+
expect(idle.readableEnded).toStrictEqual(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should raise when the values held until the missing one arrives no longer fit', async () => {
|
|
97
|
+
stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
|
|
98
|
+
|
|
99
|
+
for (let index = 2; index <= 6; index++) {
|
|
100
|
+
stream.arrange(index, { headers: { index } })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [error] = await once(stream, 'error')
|
|
104
|
+
|
|
105
|
+
expect(error).toBeInstanceOf(Interrupted)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should end without raising when the consumer destroys it', async () => {
|
|
109
|
+
const raised = jest.fn()
|
|
110
|
+
|
|
111
|
+
stream.on('error', raised)
|
|
112
|
+
stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
|
|
113
|
+
stream.destroy()
|
|
114
|
+
|
|
115
|
+
await timeout(10)
|
|
116
|
+
|
|
117
|
+
expect(raised).not.toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
|
|
75
120
|
it('should keep confirmation resolved after control.ok', async () => {
|
|
76
121
|
stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
|
|
77
122
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Writable } = require('node:stream')
|
|
4
|
+
|
|
5
|
+
const { batch } = require('../source/batch')
|
|
6
|
+
|
|
7
|
+
/** A socket, as far as writing to it goes: what it was given, and how many times. */
|
|
8
|
+
function socket () {
|
|
9
|
+
const writes = []
|
|
10
|
+
|
|
11
|
+
return Object.assign(new Writable({
|
|
12
|
+
write (chunk, _encoding, callback) {
|
|
13
|
+
writes.push([chunk.toString()])
|
|
14
|
+
callback()
|
|
15
|
+
},
|
|
16
|
+
writev (chunks, callback) {
|
|
17
|
+
writes.push(chunks.map(({ chunk }) => chunk.toString()))
|
|
18
|
+
callback()
|
|
19
|
+
}
|
|
20
|
+
}), { writes })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tick = () => new Promise((resolve) => process.nextTick(resolve))
|
|
24
|
+
|
|
25
|
+
it('should be', async () => {
|
|
26
|
+
expect(batch).toBeDefined()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should write what a turn wrote as one', async () => {
|
|
30
|
+
const stream = socket()
|
|
31
|
+
|
|
32
|
+
batch(stream)
|
|
33
|
+
|
|
34
|
+
stream.write('a')
|
|
35
|
+
stream.write('b')
|
|
36
|
+
|
|
37
|
+
await tick()
|
|
38
|
+
|
|
39
|
+
expect(stream.writes).toStrictEqual([['a', 'b']])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should write what a later turn wrote on its own', async () => {
|
|
43
|
+
const stream = socket()
|
|
44
|
+
|
|
45
|
+
batch(stream)
|
|
46
|
+
|
|
47
|
+
stream.write('a')
|
|
48
|
+
|
|
49
|
+
await tick()
|
|
50
|
+
|
|
51
|
+
stream.write('b')
|
|
52
|
+
|
|
53
|
+
await tick()
|
|
54
|
+
|
|
55
|
+
expect(stream.writes).toStrictEqual([['a'], ['b']])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should not hold a write past its turn', async () => {
|
|
59
|
+
const stream = socket()
|
|
60
|
+
|
|
61
|
+
batch(stream)
|
|
62
|
+
|
|
63
|
+
stream.write('a')
|
|
64
|
+
|
|
65
|
+
await tick()
|
|
66
|
+
|
|
67
|
+
expect(stream.writes).toStrictEqual([['a']])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should take a socket once', async () => {
|
|
71
|
+
const stream = socket()
|
|
72
|
+
|
|
73
|
+
batch(stream)
|
|
74
|
+
|
|
75
|
+
const wrapped = stream.write
|
|
76
|
+
|
|
77
|
+
batch(stream)
|
|
78
|
+
|
|
79
|
+
expect(stream.write).toStrictEqual(wrapped)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should leave alone what does not cork', async () => {
|
|
83
|
+
for (const value of [undefined, null, {}, { write: () => undefined }]) {
|
|
84
|
+
expect(() => batch(value)).not.toThrow()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const written = []
|
|
88
|
+
const plain = { write: (chunk) => written.push(chunk) }
|
|
89
|
+
|
|
90
|
+
batch(plain)
|
|
91
|
+
plain.write('a')
|
|
92
|
+
|
|
93
|
+
expect(written).toStrictEqual(['a'])
|
|
94
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -30,3 +30,11 @@ export class Unroutable extends Error {
|
|
|
30
30
|
readonly exchange: string
|
|
31
31
|
readonly key: string
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A Reply stream that ended before its producer did: what was received is a prefix of what was
|
|
36
|
+
* yielded, and the rest is not coming. A stream that completes ends instead.
|
|
37
|
+
*/
|
|
38
|
+
export class Interrupted extends Error {
|
|
39
|
+
readonly reason: string
|
|
40
|
+
}
|