comq 0.20.1 → 0.22.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/closing.feature +32 -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/closing.js +109 -0
- package/features/steps/rpc.js +64 -1
- package/package.json +2 -1
- package/readme.md +37 -1
- package/source/.io/ReplyStream.js +16 -5
- package/source/abandoned.js +15 -0
- package/source/batch.js +62 -0
- package/source/connection.js +4 -0
- package/source/index.js +4 -0
- package/source/interrupted.js +23 -0
- package/source/io.js +31 -0
- package/test/ReplyStream.test.js +45 -0
- package/test/batch.test.js +94 -0
- package/types/index.d.ts +14 -0
- package/types/io.d.ts +6 -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')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Feature: Abandoning the Replies a close is waiting for
|
|
2
|
+
|
|
3
|
+
A consumer callback may call out while it runs, and a close waits for every callback to return.
|
|
4
|
+
Where the answer that callback waits for is the one thing going away with the close, the two
|
|
5
|
+
wait for each other. Abandoning ends that wait.
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given an active connection to the broker
|
|
9
|
+
|
|
10
|
+
Scenario: Abandoning lets a close finish
|
|
11
|
+
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
12
|
+
When the consumer sends a request to the `upstream` queue
|
|
13
|
+
And the producer is handling the request
|
|
14
|
+
And the connection abandons its replies
|
|
15
|
+
Then the connection closes within 1000ms
|
|
16
|
+
And the producer was told its reply is abandoned
|
|
17
|
+
And the consumer was told its own reply is abandoned
|
|
18
|
+
|
|
19
|
+
Scenario: A close already under way is told to stop waiting
|
|
20
|
+
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
21
|
+
When the consumer sends a request to the `upstream` queue
|
|
22
|
+
And the producer is handling the request
|
|
23
|
+
And the connection has started closing
|
|
24
|
+
And the connection abandons its replies
|
|
25
|
+
Then the connection closes within 1000ms
|
|
26
|
+
|
|
27
|
+
Scenario: A Request made once the Replies are abandoned is refused
|
|
28
|
+
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
29
|
+
When the consumer sends a request to the `upstream` queue
|
|
30
|
+
And the producer is handling the request
|
|
31
|
+
And the connection abandons its replies
|
|
32
|
+
Then a request to the `downstream` queue is refused as abandoned
|
|
@@ -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>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
5
|
+
const { Abandoned } = require('../../')
|
|
6
|
+
const { timeout } = require('../../test/helpers')
|
|
7
|
+
|
|
8
|
+
Given('a producer replying {token} queue by requesting the {token} queue',
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} queue
|
|
11
|
+
* @param {string} downstream
|
|
12
|
+
* @this {comq.features.Context}
|
|
13
|
+
*/
|
|
14
|
+
async function (queue, downstream) {
|
|
15
|
+
this.handling = new Promise((resolve) => { this.handled = resolve })
|
|
16
|
+
|
|
17
|
+
const producer = async (request) => {
|
|
18
|
+
const reply = this.io.request(downstream, request)
|
|
19
|
+
|
|
20
|
+
this.handled()
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return await reply
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// recorded rather than rethrown: what this asserts is what the caller was told,
|
|
26
|
+
// and a rejected producer would have the delivery retried on top of it
|
|
27
|
+
this.abandoned = error
|
|
28
|
+
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await this.io.reply(queue, producer)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
When('the producer is handling the request',
|
|
37
|
+
/**
|
|
38
|
+
* @this {comq.features.Context}
|
|
39
|
+
*/
|
|
40
|
+
async function () {
|
|
41
|
+
await this.handling
|
|
42
|
+
|
|
43
|
+
// the consumer's own Request is outstanding as well, and abandoning abandons that one too;
|
|
44
|
+
// its outcome is taken here so that it is asserted rather than left to surface as an
|
|
45
|
+
// unhandled rejection
|
|
46
|
+
this.reply.catch((error) => { this.refused = error })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
When('the connection has started closing',
|
|
50
|
+
/**
|
|
51
|
+
* @this {comq.features.Context}
|
|
52
|
+
*/
|
|
53
|
+
async function () {
|
|
54
|
+
this.closing = this.io.close()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
When('the connection abandons its replies',
|
|
58
|
+
/**
|
|
59
|
+
* @this {comq.features.Context}
|
|
60
|
+
*/
|
|
61
|
+
async function () {
|
|
62
|
+
await this.io.abandon()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
Then('the connection closes within {number}ms',
|
|
66
|
+
/**
|
|
67
|
+
* @param {number} ms
|
|
68
|
+
* @this {comq.features.Context}
|
|
69
|
+
*/
|
|
70
|
+
async function (ms) {
|
|
71
|
+
const io = this.io
|
|
72
|
+
|
|
73
|
+
// taken off the world so that the hook on the way out does not await a close
|
|
74
|
+
// this may have just proven does not end
|
|
75
|
+
this.io = undefined
|
|
76
|
+
this.connected = false
|
|
77
|
+
|
|
78
|
+
const closing = io.close().then(() => true, () => true)
|
|
79
|
+
const closed = await Promise.race([closing, timeout(ms).then(() => false)])
|
|
80
|
+
|
|
81
|
+
assert.ok(closed, `The connection did not close within ${ms}ms`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
Then('the producer was told its reply is abandoned',
|
|
85
|
+
/**
|
|
86
|
+
* @this {comq.features.Context}
|
|
87
|
+
*/
|
|
88
|
+
async function () {
|
|
89
|
+
assert.ok(this.abandoned instanceof Abandoned,
|
|
90
|
+
`The producer was told ${this.abandoned ?? 'nothing'}`)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
Then('the consumer was told its own reply is abandoned',
|
|
94
|
+
/**
|
|
95
|
+
* @this {comq.features.Context}
|
|
96
|
+
*/
|
|
97
|
+
async function () {
|
|
98
|
+
assert.ok(this.refused instanceof Abandoned,
|
|
99
|
+
`The consumer was told ${this.refused ?? 'nothing'}`)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
Then('a request to the {token} queue is refused as abandoned',
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} queue
|
|
105
|
+
* @this {comq.features.Context}
|
|
106
|
+
*/
|
|
107
|
+
async function (queue) {
|
|
108
|
+
await assert.rejects(() => this.io.request(queue, null), Abandoned)
|
|
109
|
+
})
|
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.22.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
|
|
|
@@ -723,6 +734,28 @@ Sending Requests, receiving Replies, and emitting Events will still be available
|
|
|
723
734
|
Keys held by [`back`](#addressed-requests) are withdrawn first, so a call published from then on is
|
|
724
735
|
refused.
|
|
725
736
|
|
|
737
|
+
### Abandoning
|
|
738
|
+
|
|
739
|
+
`async IO.abandon(): void`
|
|
740
|
+
|
|
741
|
+
Stop waiting for Replies.
|
|
742
|
+
|
|
743
|
+
A [close](#disconnection) waits for every `producer` and `consumer` call to return, and such a
|
|
744
|
+
call may itself be waiting on a Request of its own. Where the answer to that Request is the very
|
|
745
|
+
thing going away — the process it would come from is shutting down alongside this one — the close
|
|
746
|
+
waits for as long as the answer takes.
|
|
747
|
+
|
|
748
|
+
`IO.abandon()` ends that wait:
|
|
749
|
+
|
|
750
|
+
- every outstanding Reply is rejected with `Abandoned`, so a caller inside a `producer` or
|
|
751
|
+
`consumer` is told at once rather than held;
|
|
752
|
+
- a Request made from then on is refused with `Abandoned`, so a callback cannot start another
|
|
753
|
+
wait on the way out.
|
|
754
|
+
|
|
755
|
+
The Requests those Replies belonged to were sent and may well be processed; what is given up is
|
|
756
|
+
this side's interest in the answers. It may be called before a close or while one is already under
|
|
757
|
+
way, which is how a close that has begun is told to stop waiting.
|
|
758
|
+
|
|
726
759
|
### Disconnection
|
|
727
760
|
|
|
728
761
|
`async IO.close(): void`
|
|
@@ -731,6 +764,9 @@ refused.
|
|
|
731
764
|
2. Wait for any outstanding messages to be processed[^2] and acknowledged.
|
|
732
765
|
3. Close the connection.
|
|
733
766
|
|
|
767
|
+
Step 2 still runs where the Replies have been [abandoned](#abandoning): each message is processed
|
|
768
|
+
to a verdict and acknowledged, and what was given up is the waiting rather than the drain.
|
|
769
|
+
|
|
734
770
|
[^2]: Therefore, if the underlying connection is lost, `.close()` will only be completed once the
|
|
735
771
|
connection is [recovered](#connection-tolerance).
|
|
736
772
|
|
|
@@ -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 () {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Reply nobody is waiting for any more, or a Request made once the waiting has stopped. The
|
|
5
|
+
* Request itself was sent and may well be processed; what was given up is the answer.
|
|
6
|
+
*/
|
|
7
|
+
class Abandoned extends Error {
|
|
8
|
+
constructor () {
|
|
9
|
+
super('The connection no longer waits for a Reply')
|
|
10
|
+
|
|
11
|
+
this.name = 'Abandoned'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
exports.Abandoned = Abandoned
|
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 `Mux` writes the frames one at a time, and where corking that
|
|
14
|
+
* loop is six lines and needs no wrapping of a socket the library does not own. It is done here
|
|
15
|
+
* until amqplib carries it, 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,8 @@
|
|
|
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')
|
|
7
|
+
const { Abandoned } = require('./abandoned')
|
|
6
8
|
|
|
7
9
|
exports.connect = connect
|
|
8
10
|
exports.assert = assert
|
|
@@ -10,3 +12,5 @@ exports.assert = assert
|
|
|
10
12
|
exports.Retry = Retry
|
|
11
13
|
exports.Park = Park
|
|
12
14
|
exports.Unroutable = Unroutable
|
|
15
|
+
exports.Interrupted = Interrupted
|
|
16
|
+
exports.Abandoned = Abandoned
|
|
@@ -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/source/io.js
CHANGED
|
@@ -6,6 +6,7 @@ const { Promex } = require('promex')
|
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
7
|
const { verdictOf, PARK } = require('./verdicts')
|
|
8
8
|
const { Unroutable } = require('./unroutable')
|
|
9
|
+
const { Abandoned } = require('./abandoned')
|
|
9
10
|
|
|
10
11
|
const { decode } = require('./decode')
|
|
11
12
|
const { encode } = require('./encode')
|
|
@@ -39,6 +40,9 @@ class IO {
|
|
|
39
40
|
/** @type {Map<Promex, comq.Request>} */
|
|
40
41
|
#pendingReplies = new Map()
|
|
41
42
|
|
|
43
|
+
/** Whether this has stopped waiting for Replies, see `abandon`. */
|
|
44
|
+
#abandoning = false
|
|
45
|
+
|
|
42
46
|
/** @type {[comq.diagnostics.Event, Function][]} */
|
|
43
47
|
#forwarders = []
|
|
44
48
|
|
|
@@ -250,6 +254,30 @@ class IO {
|
|
|
250
254
|
await this.#destroyStreams(this.#replyStreams)
|
|
251
255
|
})
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Stops waiting for Replies: every one still outstanding is rejected with `Abandoned`, and a
|
|
259
|
+
* Request made from now on is refused with it.
|
|
260
|
+
*
|
|
261
|
+
* A close waits for every consumer callback to return, and such a callback may be waiting on
|
|
262
|
+
* a Request of its own. Where the answer to that Request is the very thing going away — the
|
|
263
|
+
* process it would come from is shutting down alongside this one — the close waits for as
|
|
264
|
+
* long as the answer takes. This is how that wait is ended. What the Requests themselves do
|
|
265
|
+
* is not affected: they were sent and may well be processed; what is given up is this side's
|
|
266
|
+
* interest in the answers.
|
|
267
|
+
*
|
|
268
|
+
* Callable at any time, including while a close is already under way.
|
|
269
|
+
*/
|
|
270
|
+
abandon = memo(async () => {
|
|
271
|
+
this.#abandoning = true
|
|
272
|
+
|
|
273
|
+
// the same three steps `#abandon` takes for one Reply whose caller stopped waiting
|
|
274
|
+
for (const [reply, request] of this.#pendingReplies) {
|
|
275
|
+
request.emitter.off(request.properties.correlationId)
|
|
276
|
+
this.#pendingReplies.delete(reply)
|
|
277
|
+
reply.reject(new Abandoned())
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
253
281
|
close = memo(async () => {
|
|
254
282
|
await this.seal()
|
|
255
283
|
await this.#destroyStreams(this.#replyPipes)
|
|
@@ -425,6 +453,9 @@ class IO {
|
|
|
425
453
|
|
|
426
454
|
signal?.throwIfAborted()
|
|
427
455
|
|
|
456
|
+
// one made now would be waited for by nobody: the Replies are abandoned already
|
|
457
|
+
if (this.#abandoning) throw new Abandoned()
|
|
458
|
+
|
|
428
459
|
const [buffer, contentType] = this.#encode(payload, terms.encoding)
|
|
429
460
|
const request = this.#createRequest(target, contentType, signal)
|
|
430
461
|
const properties = { ...request.properties }
|
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,17 @@ 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
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A Reply nobody is waiting for any more, or a Request made once the waiting has stopped.
|
|
44
|
+
* See `IO.abandon`.
|
|
45
|
+
*/
|
|
46
|
+
export class Abandoned extends Error {}
|
package/types/io.d.ts
CHANGED
|
@@ -119,6 +119,12 @@ declare namespace comq {
|
|
|
119
119
|
|
|
120
120
|
seal(): Promise<void>
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Stops waiting for Replies: every one still outstanding is rejected with `Abandoned`, and a
|
|
124
|
+
* Request made from now on is refused with it. Callable while a close is already under way.
|
|
125
|
+
*/
|
|
126
|
+
abandon(): Promise<void>
|
|
127
|
+
|
|
122
128
|
close(): Promise<void>
|
|
123
129
|
|
|
124
130
|
diagnose(event: 'open', listener: (index?: number) => void): void
|