comq 0.24.1 → 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/rpc.streams.feature +28 -0
- package/features/steps/retransmission.js +1 -1
- package/features/steps/rpc.js +31 -1
- package/package.json +18 -10
- package/readme.md +6 -0
- 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 +2 -2
- package/source/connection.js +2 -2
- package/source/io.js +1 -1
- 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 +1 -1
- 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.test.js +1 -1
- package/test/globals.js +143 -0
- package/test/io.close.test.js +1 -1
- package/test/io.request.test.js +1 -1
- package/test/shards/channel.test.js +1 -1
- package/test/shards/connection.test.js +2 -3
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
|
|
|
@@ -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/steps/rpc.js
CHANGED
|
@@ -291,7 +291,37 @@ Then('the consumer receives the stream:',
|
|
|
291
291
|
|
|
292
292
|
for await (const reply of this.stream) replies.push(reply)
|
|
293
293
|
|
|
294
|
-
assert.
|
|
294
|
+
assert.deepStrictEqual(replies, values)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
Then('the consumer receives a stream of {number} values in order',
|
|
298
|
+
/**
|
|
299
|
+
* @param {number} count
|
|
300
|
+
* @this {comq.features.Context}
|
|
301
|
+
*/
|
|
302
|
+
async function (count) {
|
|
303
|
+
const replies = []
|
|
304
|
+
|
|
305
|
+
for await (const reply of this.stream) replies.push(reply)
|
|
306
|
+
|
|
307
|
+
assert.deepStrictEqual(replies, Array.from({ length: count }, (_, i) => ({ i })))
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
Then('the consumer receives the stream of buffers:',
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} yaml what each buffer holds, as text
|
|
313
|
+
* @this {comq.features.Context}
|
|
314
|
+
*/
|
|
315
|
+
async function (yaml) {
|
|
316
|
+
const values = parse(yaml)
|
|
317
|
+
const replies = []
|
|
318
|
+
|
|
319
|
+
for await (const reply of this.stream) {
|
|
320
|
+
assert.ok(Buffer.isBuffer(reply), `Not a buffer: ${JSON.stringify(reply)}`)
|
|
321
|
+
replies.push(reply.toString())
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
assert.deepStrictEqual(replies, values)
|
|
295
325
|
})
|
|
296
326
|
|
|
297
327
|
Then('the consumer receives the stream',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -27,19 +27,22 @@
|
|
|
27
27
|
"feat": "scripts/feat.js"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
+
"@commitlint/cli": "^21.2.3",
|
|
31
|
+
"@commitlint/config-conventional": "^21.2.3",
|
|
30
32
|
"@cucumber/cucumber": "^13.2.1",
|
|
33
|
+
"@jest/fake-timers": "^30.5.2",
|
|
31
34
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
32
35
|
"@semantic-release/git": "^11.0.1",
|
|
33
36
|
"@semantic-release/github": "^12.0.2",
|
|
34
37
|
"@semantic-release/npm": "^13.1.2",
|
|
35
38
|
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
36
39
|
"@testcontainers/rabbitmq": "^12.1.0",
|
|
37
|
-
"@types/jest": "^30.0.0",
|
|
38
40
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
|
41
|
+
"expect": "^30.5.2",
|
|
39
42
|
"fast-glob": "^3.3.3",
|
|
40
43
|
"fuzzysort": "4.0.2",
|
|
41
44
|
"husky": "^9.1.7",
|
|
42
|
-
"jest": "^30.
|
|
45
|
+
"jest-mock": "^30.5.2",
|
|
43
46
|
"js-yaml": "^5.4.1",
|
|
44
47
|
"randomstring": "^1.3.1",
|
|
45
48
|
"semantic-release": "^25.0.9",
|
|
@@ -48,28 +51,33 @@
|
|
|
48
51
|
"testcontainers": "^12.0.4"
|
|
49
52
|
},
|
|
50
53
|
"dependencies": {
|
|
51
|
-
"@toa.io/amqplib": "^0.1.0"
|
|
52
|
-
"promex": "^0.1.1",
|
|
53
|
-
"reretry": "^0.1.0"
|
|
54
|
+
"@toa.io/amqplib": "^0.1.0"
|
|
54
55
|
},
|
|
55
56
|
"engines": {
|
|
56
57
|
"node": ">=24.0.0"
|
|
57
58
|
},
|
|
58
59
|
"scripts": {
|
|
59
60
|
"prepare": "husky",
|
|
60
|
-
"test": "standard &&
|
|
61
|
+
"test": "standard && node --test --experimental-test-module-mocks --import ./test/globals.js 'test/**/*.test.js' 'source/**/*.test.js'",
|
|
61
62
|
"features": "cucumber-js --fail-fast -t 'not @manual'",
|
|
62
63
|
"features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
|
|
63
64
|
"benchmark": "node benchmarks/run.js",
|
|
64
65
|
"lint": "standard --fix --verbose | snazzy"
|
|
65
66
|
},
|
|
66
67
|
"standard": {
|
|
67
|
-
"
|
|
68
|
-
"
|
|
68
|
+
"globals": [
|
|
69
|
+
"afterAll",
|
|
70
|
+
"afterEach",
|
|
71
|
+
"beforeAll",
|
|
72
|
+
"beforeEach",
|
|
73
|
+
"describe",
|
|
74
|
+
"expect",
|
|
75
|
+
"it",
|
|
76
|
+
"jest",
|
|
77
|
+
"test"
|
|
69
78
|
]
|
|
70
79
|
},
|
|
71
80
|
"overrides": {
|
|
72
|
-
"test-exclude": "^8.0.0",
|
|
73
81
|
"rimraf": "^6.1.3",
|
|
74
82
|
"archiver-utils": {
|
|
75
83
|
"glob": "^13.0.6"
|
package/readme.md
CHANGED
|
@@ -354,6 +354,12 @@ for await (const number of stream)
|
|
|
354
354
|
console.log(number)
|
|
355
355
|
```
|
|
356
356
|
|
|
357
|
+
Values a producer yields together travel together: what the iterator yields before the event loop
|
|
358
|
+
turns is sent as one message, up to 128 values, and a value that has to be waited for is sent as
|
|
359
|
+
it comes. Buffers travel in messages of their own. The stream returned by `IO.request` yields each
|
|
360
|
+
value on its own either way. Values in an encoding other than `application/json` travel one to a
|
|
361
|
+
message.
|
|
362
|
+
|
|
357
363
|
### Stream topology
|
|
358
364
|
|
|
359
365
|
<picture>
|
package/source/.io/ReplyPipe.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('node:events')
|
|
4
|
-
const { Promex } = require('promex')
|
|
5
|
-
const { control, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
|
|
4
|
+
const { Promex } = require('../promex')
|
|
5
|
+
const { control, batch, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
|
|
6
6
|
|
|
7
7
|
/** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
|
|
8
8
|
|
|
@@ -36,6 +36,9 @@ class ReplyPipe extends EventEmitter {
|
|
|
36
36
|
/** @type {Reply} */
|
|
37
37
|
#reply
|
|
38
38
|
|
|
39
|
+
/** Whether the encoding can carry a list of values. */
|
|
40
|
+
#lists
|
|
41
|
+
|
|
39
42
|
/**
|
|
40
43
|
* @param {comq.amqp.Message} request
|
|
41
44
|
* @param {stream.Readable} stream
|
|
@@ -54,8 +57,12 @@ class ReplyPipe extends EventEmitter {
|
|
|
54
57
|
this.#reply = reply
|
|
55
58
|
this.#replyTo = replyTo
|
|
56
59
|
|
|
60
|
+
this.#lists = request.properties.contentType === LISTS
|
|
61
|
+
|
|
57
62
|
this.#properties = {
|
|
58
63
|
chunk: { correlationId, ...CHUNK },
|
|
64
|
+
batch: { correlationId, ...CHUNK, type: batch.values },
|
|
65
|
+
buffers: { correlationId, ...CHUNK, type: batch.buffers },
|
|
59
66
|
control: { correlationId, replyTo: feedback.queue, ...CONTROL },
|
|
60
67
|
ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
|
|
61
68
|
}
|
|
@@ -99,21 +106,90 @@ class ReplyPipe extends EventEmitter {
|
|
|
99
106
|
*/
|
|
100
107
|
async #pump () {
|
|
101
108
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
await this.#pumpBatches()
|
|
110
|
+
} catch {
|
|
111
|
+
// the source has been destroyed, by this pipe or by whoever made it
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.#close()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Values go out together while they arrive together: what the source yields before the event
|
|
119
|
+
* loop turns joins the message, and the first value that has to be waited for leaves with the
|
|
120
|
+
* ones before it rather than waiting for company. A cursor yielding the documents of a batch it
|
|
121
|
+
* holds sends them as one message; a source yielding one value at a time sends each as it
|
|
122
|
+
* comes. Values and buffers travel in messages of their own, and values in an encoding that
|
|
123
|
+
* holds no list travel one to a message.
|
|
124
|
+
*/
|
|
125
|
+
async #pumpBatches () {
|
|
126
|
+
const iterator = this.#stream[Symbol.asyncIterator]()
|
|
127
|
+
|
|
128
|
+
let pending = next(iterator)
|
|
129
|
+
|
|
130
|
+
while (true) {
|
|
131
|
+
const first = await pending
|
|
105
132
|
|
|
106
|
-
|
|
133
|
+
if (first.done === true) return
|
|
107
134
|
|
|
108
|
-
|
|
135
|
+
const chunks = [first.value]
|
|
136
|
+
const buffers = Buffer.isBuffer(first.value)
|
|
137
|
+
const turn = immediate()
|
|
109
138
|
|
|
110
|
-
|
|
139
|
+
let done = false
|
|
140
|
+
|
|
141
|
+
pending = next(iterator)
|
|
142
|
+
|
|
143
|
+
while (chunks.length < (buffers || this.#lists ? MAX_BATCH : 1)) {
|
|
144
|
+
const arrived = await Promise.race([pending, turn])
|
|
145
|
+
|
|
146
|
+
if (arrived === TURN) break
|
|
147
|
+
|
|
148
|
+
if (arrived.done === true) {
|
|
149
|
+
done = true
|
|
150
|
+
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// of the other kind, it starts the next message
|
|
155
|
+
if (Buffer.isBuffer(arrived.value) !== buffers) {
|
|
156
|
+
pending = Promise.resolve(arrived)
|
|
157
|
+
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
chunks.push(arrived.value)
|
|
162
|
+
pending = next(iterator)
|
|
111
163
|
}
|
|
112
|
-
|
|
113
|
-
|
|
164
|
+
|
|
165
|
+
if (!(await this.#send(...this.#message(chunks, buffers)))) {
|
|
166
|
+
await iterator.return?.()
|
|
167
|
+
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (done) return
|
|
114
172
|
}
|
|
173
|
+
}
|
|
115
174
|
|
|
116
|
-
|
|
175
|
+
#message (chunks, buffers) {
|
|
176
|
+
if (chunks.length === 1) return [chunks[0], this.#properties.chunk]
|
|
177
|
+
else if (buffers) return [prefixed(chunks), this.#properties.buffers]
|
|
178
|
+
else return [chunks, this.#properties.batch]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Answers whether the pipe goes on. */
|
|
182
|
+
async #send (data, properties) {
|
|
183
|
+
if (this.#gate !== null) await this.#gate
|
|
184
|
+
if (this.#closed) return false
|
|
185
|
+
|
|
186
|
+
await this.#transmit(data, properties)
|
|
187
|
+
|
|
188
|
+
if (this.#closed) return false
|
|
189
|
+
|
|
190
|
+
this.#heartbeat()
|
|
191
|
+
|
|
192
|
+
return true
|
|
117
193
|
}
|
|
118
194
|
|
|
119
195
|
async #transmit (data, properties) {
|
|
@@ -206,6 +282,49 @@ class ReplyPipe extends EventEmitter {
|
|
|
206
282
|
/** @type {comq.amqp.options.Publish} */
|
|
207
283
|
const CHUNK = { mandatory: true }
|
|
208
284
|
|
|
285
|
+
/** the most values a message carries */
|
|
286
|
+
const MAX_BATCH = 128
|
|
287
|
+
|
|
288
|
+
/** the encoding a batch of values is a list in */
|
|
289
|
+
const LISTS = 'application/json'
|
|
290
|
+
|
|
291
|
+
const TURN = Symbol('turn')
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The next value, asked for ahead of when it is awaited. A source that fails while a message is
|
|
295
|
+
* being sent is found failed by the loop that awaits it next, or by nobody where the pipe has
|
|
296
|
+
* stopped — and a rejection nobody awaits is an unhandled one.
|
|
297
|
+
*/
|
|
298
|
+
function next (iterator) {
|
|
299
|
+
const pending = iterator.next()
|
|
300
|
+
|
|
301
|
+
pending.catch(noop)
|
|
302
|
+
|
|
303
|
+
return pending
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Settles once the event loop has turned, with what says so. */
|
|
307
|
+
function immediate () {
|
|
308
|
+
return new Promise((resolve) => setImmediate(resolve, TURN))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {Buffer[]} buffers
|
|
313
|
+
* @returns {Buffer}
|
|
314
|
+
*/
|
|
315
|
+
function prefixed (buffers) {
|
|
316
|
+
const parts = []
|
|
317
|
+
|
|
318
|
+
for (const buffer of buffers) {
|
|
319
|
+
const length = Buffer.allocUnsafe(4)
|
|
320
|
+
|
|
321
|
+
length.writeUInt32BE(buffer.length)
|
|
322
|
+
parts.push(length, buffer)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return Buffer.concat(parts)
|
|
326
|
+
}
|
|
327
|
+
|
|
209
328
|
/** @type {comq.amqp.options.Publish} */
|
|
210
329
|
const CONTROL = { type: 'control', mandatory: true }
|
|
211
330
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { Readable } = require('node:stream')
|
|
4
|
-
const { Promex } = require('promex')
|
|
5
|
-
const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
|
|
4
|
+
const { Promex } = require('../promex')
|
|
5
|
+
const { IDLE_INTERVAL, FLOW_HEADER, batch, control } = require('./const')
|
|
6
6
|
const { Interrupted } = require('../interrupted')
|
|
7
7
|
|
|
8
8
|
class ReplyStream extends Readable {
|
|
@@ -133,10 +133,29 @@ class ReplyStream extends Readable {
|
|
|
133
133
|
|
|
134
134
|
if (properties.type === 'control')
|
|
135
135
|
this._control(payload, properties)
|
|
136
|
+
else if (properties.type === batch.values)
|
|
137
|
+
this._values(payload)
|
|
138
|
+
else if (properties.type === batch.buffers)
|
|
139
|
+
this._values(split(payload))
|
|
136
140
|
else if (!this.push(payload))
|
|
137
141
|
this._throttle()
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The values of one message, pushed whatever the consumer's room: they have arrived, and a
|
|
146
|
+
* pause asked for now holds back the messages after them.
|
|
147
|
+
*
|
|
148
|
+
* @param {unknown[]} values
|
|
149
|
+
* @private
|
|
150
|
+
*/
|
|
151
|
+
_values (values) {
|
|
152
|
+
let room = true
|
|
153
|
+
|
|
154
|
+
for (const value of values) room = this.push(value) && room
|
|
155
|
+
|
|
156
|
+
if (!room) this._throttle()
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
/**
|
|
141
160
|
* Values arriving out of order are held until the gap is filled. The hold is
|
|
142
161
|
* bounded by their number and, roughly, by their size: the size is that of
|
|
@@ -213,6 +232,26 @@ class ReplyStream extends Readable {
|
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The buffers of a message that carries each prefixed with its length.
|
|
237
|
+
*
|
|
238
|
+
* @param {Buffer} buffer
|
|
239
|
+
* @returns {Buffer[]}
|
|
240
|
+
*/
|
|
241
|
+
function split (buffer) {
|
|
242
|
+
const buffers = []
|
|
243
|
+
|
|
244
|
+
for (let offset = 0; offset < buffer.length;) {
|
|
245
|
+
const length = buffer.readUInt32BE(offset)
|
|
246
|
+
const start = offset + 4
|
|
247
|
+
|
|
248
|
+
buffers.push(buffer.subarray(start, start + length))
|
|
249
|
+
offset = start + length
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return buffers
|
|
253
|
+
}
|
|
254
|
+
|
|
216
255
|
const MAX_BUFFER_SIZE = 1000
|
|
217
256
|
|
|
218
257
|
/** @type {number} */
|
package/source/.io/const.js
CHANGED
|
@@ -14,5 +14,11 @@ exports.control = {
|
|
|
14
14
|
*/
|
|
15
15
|
exports.FLOW_HEADER = 'x-flow'
|
|
16
16
|
|
|
17
|
+
/** The type of a message carrying values: a list of them, or buffers prefixed with their length. */
|
|
18
|
+
exports.batch = {
|
|
19
|
+
values: 'batch',
|
|
20
|
+
buffers: 'buffers'
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
exports.HEARTBEAT_INTERVAL = 5_000
|
|
18
24
|
exports.IDLE_INTERVAL = 12_000
|
package/source/channel.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { Promex } = require('promex')
|
|
4
|
-
const { retry } = require('
|
|
3
|
+
const { Promex } = require('./promex')
|
|
4
|
+
const { retry } = require('./retry')
|
|
5
5
|
const { failsafe, lazy, recall } = require('./attributes')
|
|
6
6
|
const { verdictOf, PARK } = require('./verdicts')
|
|
7
7
|
const emitter = require('./emitter')
|
package/source/connection.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { setTimeout: delay } = require('node:timers/promises')
|
|
4
4
|
const amqp = require('@toa.io/amqplib')
|
|
5
|
-
const { Promex } = require('promex')
|
|
6
|
-
const { retry } = require('
|
|
5
|
+
const { Promex } = require('./promex')
|
|
6
|
+
const { retry } = require('./retry')
|
|
7
7
|
|
|
8
8
|
const { failsafe } = require('./attributes')
|
|
9
9
|
const presets = require('./topology')
|
package/source/io.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const stream = require('node:stream')
|
|
4
4
|
const { setTimeout } = require('node:timers/promises')
|
|
5
|
-
const { Promex } = require('promex')
|
|
5
|
+
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')
|
package/source/promex.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A promise settled by whoever holds it, rather than only from within its executor.
|
|
5
|
+
* `callback` settles it the way node-style callbacks report: an error, or a value.
|
|
6
|
+
*
|
|
7
|
+
* @template T
|
|
8
|
+
* @extends {Promise<T>}
|
|
9
|
+
*/
|
|
10
|
+
class Promex extends Promise {
|
|
11
|
+
/** @type {(value?: T | PromiseLike<T>) => void} */
|
|
12
|
+
resolve
|
|
13
|
+
|
|
14
|
+
/** @type {(reason?: any) => void} */
|
|
15
|
+
reject
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {(resolve: (value?: any) => void, reject: (reason?: any) => void) => void} [executor]
|
|
19
|
+
*/
|
|
20
|
+
constructor (executor = noop) {
|
|
21
|
+
let ok
|
|
22
|
+
let oh
|
|
23
|
+
|
|
24
|
+
super((resolve, reject) => {
|
|
25
|
+
ok = resolve
|
|
26
|
+
oh = reject
|
|
27
|
+
|
|
28
|
+
return executor(resolve, reject)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
this.resolve = ok
|
|
32
|
+
this.reject = oh
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {any} error
|
|
37
|
+
* @param {T} [value]
|
|
38
|
+
*/
|
|
39
|
+
callback = (error, value) => {
|
|
40
|
+
if (error !== null && error !== undefined) this.reject(error)
|
|
41
|
+
else this.resolve(value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noop () {}
|
|
46
|
+
|
|
47
|
+
exports.Promex = Promex
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Promex } = require('./promex')
|
|
4
|
+
|
|
5
|
+
it('should resolve', async () => {
|
|
6
|
+
const promise = new Promex()
|
|
7
|
+
|
|
8
|
+
setImmediate(() => promise.resolve('ok'))
|
|
9
|
+
|
|
10
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should reject', async () => {
|
|
14
|
+
const promise = new Promex()
|
|
15
|
+
|
|
16
|
+
setImmediate(() => promise.reject(new Error('oh')))
|
|
17
|
+
|
|
18
|
+
await expect(promise).rejects.toThrow('oh')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should resolve using the callback', async () => {
|
|
22
|
+
const promise = new Promex()
|
|
23
|
+
|
|
24
|
+
setImmediate(() => promise.callback(null, 'ok'))
|
|
25
|
+
|
|
26
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should reject using the callback', async () => {
|
|
30
|
+
const promise = new Promex()
|
|
31
|
+
|
|
32
|
+
setImmediate(() => promise.callback('oh'))
|
|
33
|
+
|
|
34
|
+
await expect(promise).rejects.toStrictEqual('oh')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should resolve using the callback given no error', async () => {
|
|
38
|
+
const promise = new Promex()
|
|
39
|
+
|
|
40
|
+
setImmediate(() => promise.callback(undefined, 'ok'))
|
|
41
|
+
|
|
42
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should run the executor it is given', async () => {
|
|
46
|
+
const promise = new Promex((resolve) => resolve('ok'))
|
|
47
|
+
|
|
48
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should stay settled by whoever holds it', async () => {
|
|
52
|
+
const promise = new Promex()
|
|
53
|
+
|
|
54
|
+
promise.resolve('first')
|
|
55
|
+
promise.resolve('second')
|
|
56
|
+
|
|
57
|
+
await expect(promise).resolves.toStrictEqual('first')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should be a promise', async () => {
|
|
61
|
+
const promise = new Promex()
|
|
62
|
+
|
|
63
|
+
expect(promise).toBeInstanceOf(Promise)
|
|
64
|
+
|
|
65
|
+
promise.resolve('ok')
|
|
66
|
+
|
|
67
|
+
await expect(Promise.all([promise])).resolves.toStrictEqual(['ok'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should derive promises of its own kind', async () => {
|
|
71
|
+
const promise = new Promex()
|
|
72
|
+
const derived = promise.then((value) => value + '!')
|
|
73
|
+
|
|
74
|
+
expect(derived).toBeInstanceOf(Promex)
|
|
75
|
+
|
|
76
|
+
promise.resolve('ok')
|
|
77
|
+
|
|
78
|
+
await expect(derived).resolves.toStrictEqual('ok!')
|
|
79
|
+
})
|
package/source/retry.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs an operation until it stops asking for another attempt, waiting longer before
|
|
5
|
+
* each one. The operation is handed the token it returns to ask for a retry; anything
|
|
6
|
+
* else it returns is the result, and whatever it throws is thrown on.
|
|
7
|
+
*
|
|
8
|
+
* @template T
|
|
9
|
+
* @param {(again: symbol) => Promise<T | symbol>} operation
|
|
10
|
+
* @param {Partial<typeof DEFAULTS>} [options]
|
|
11
|
+
* @returns {Promise<T>}
|
|
12
|
+
*/
|
|
13
|
+
async function retry (operation, options) {
|
|
14
|
+
const { attempts, base, max, factor, dispersion } = { ...DEFAULTS, ...options }
|
|
15
|
+
|
|
16
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
17
|
+
const result = await operation(again)
|
|
18
|
+
|
|
19
|
+
if (result !== again) return /** @type {T} */ (result)
|
|
20
|
+
|
|
21
|
+
const interval = Math.min(base * factor ** attempt, max)
|
|
22
|
+
|
|
23
|
+
// attempts that started together are spread out rather than kept in step
|
|
24
|
+
const spread = interval * dispersion * (Math.random() - 0.5)
|
|
25
|
+
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, interval + spread))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error('Maximum attempts exceeded')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const again = Symbol('again')
|
|
33
|
+
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
attempts: Infinity,
|
|
36
|
+
base: 1000,
|
|
37
|
+
max: 30000,
|
|
38
|
+
factor: 1.5,
|
|
39
|
+
dispersion: 0.1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
exports.retry = retry
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { retry } = require('./retry')
|
|
4
|
+
|
|
5
|
+
const immediate = { base: 0, max: 0, dispersion: 0 }
|
|
6
|
+
|
|
7
|
+
it('should return the result', async () => {
|
|
8
|
+
const result = await retry(async () => 'ok', immediate)
|
|
9
|
+
|
|
10
|
+
expect(result).toStrictEqual('ok')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should retry while the operation asks for it', async () => {
|
|
14
|
+
let attempts = 0
|
|
15
|
+
|
|
16
|
+
const result = await retry(async (again) => {
|
|
17
|
+
attempts++
|
|
18
|
+
|
|
19
|
+
return attempts < 3 ? again : 'ok'
|
|
20
|
+
}, immediate)
|
|
21
|
+
|
|
22
|
+
expect(attempts).toStrictEqual(3)
|
|
23
|
+
expect(result).toStrictEqual('ok')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should pass the same token on every attempt', async () => {
|
|
27
|
+
const tokens = []
|
|
28
|
+
|
|
29
|
+
await retry(async (again) => {
|
|
30
|
+
tokens.push(again)
|
|
31
|
+
|
|
32
|
+
return tokens.length < 3 ? again : 'ok'
|
|
33
|
+
}, immediate)
|
|
34
|
+
|
|
35
|
+
expect(tokens).toHaveLength(3)
|
|
36
|
+
expect(new Set(tokens).size).toStrictEqual(1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should throw what the operation throws', async () => {
|
|
40
|
+
const exception = new Error('nope')
|
|
41
|
+
|
|
42
|
+
await expect(retry(async () => { throw exception }, immediate)).rejects.toThrow(exception)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should give up after the last attempt', async () => {
|
|
46
|
+
let attempts = 0
|
|
47
|
+
|
|
48
|
+
const attempt = retry(async (again) => {
|
|
49
|
+
attempts++
|
|
50
|
+
|
|
51
|
+
return again
|
|
52
|
+
}, { ...immediate, attempts: 3 })
|
|
53
|
+
|
|
54
|
+
await expect(attempt).rejects.toThrow('Maximum attempts exceeded')
|
|
55
|
+
expect(attempts).toStrictEqual(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should not run the operation without an attempt to spare', async () => {
|
|
59
|
+
const operation = jest.fn(async (again) => again)
|
|
60
|
+
|
|
61
|
+
await expect(retry(operation, { ...immediate, attempts: 0 }))
|
|
62
|
+
.rejects.toThrow('Maximum attempts exceeded')
|
|
63
|
+
|
|
64
|
+
expect(operation).not.toHaveBeenCalled()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should back off, up to the maximum, before each attempt', async () => {
|
|
68
|
+
jest.useFakeTimers()
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const waits = []
|
|
72
|
+
|
|
73
|
+
let last = Date.now()
|
|
74
|
+
let attempts = 0
|
|
75
|
+
|
|
76
|
+
const attempt = retry(async (again) => {
|
|
77
|
+
waits.push(Date.now() - last)
|
|
78
|
+
last = Date.now()
|
|
79
|
+
attempts++
|
|
80
|
+
|
|
81
|
+
return attempts < 5 ? again : 'ok'
|
|
82
|
+
}, { base: 100, factor: 2, max: 300, dispersion: 0 })
|
|
83
|
+
|
|
84
|
+
await jest.advanceTimersByTimeAsync(10_000)
|
|
85
|
+
|
|
86
|
+
expect(await attempt).toStrictEqual('ok')
|
|
87
|
+
|
|
88
|
+
// the first attempt waits for nothing; then 100, 200, and 400 capped at 300
|
|
89
|
+
expect(waits).toStrictEqual([0, 100, 200, 300, 300])
|
|
90
|
+
} finally {
|
|
91
|
+
jest.useRealTimers()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should spread the wait around the interval', async () => {
|
|
96
|
+
const waits = []
|
|
97
|
+
|
|
98
|
+
const timer = global.setTimeout
|
|
99
|
+
|
|
100
|
+
global.setTimeout = (callback, ms) => {
|
|
101
|
+
waits.push(ms)
|
|
102
|
+
|
|
103
|
+
return timer(callback, 0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
let attempts = 0
|
|
108
|
+
|
|
109
|
+
await retry(async (again) => {
|
|
110
|
+
attempts++
|
|
111
|
+
|
|
112
|
+
return attempts < 20 ? again : 'ok'
|
|
113
|
+
}, { base: 1000, factor: 1, max: 1000, dispersion: 0.1 })
|
|
114
|
+
} finally {
|
|
115
|
+
global.setTimeout = timer
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(waits.every((ms) => ms >= 950 && ms <= 1050)).toStrictEqual(true)
|
|
119
|
+
expect(new Set(waits).size).toBeGreaterThan(1)
|
|
120
|
+
})
|
package/source/shards/channel.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as mock from 'jest-mock'
|
|
2
|
+
|
|
3
|
+
// The test files are written against Jest's mock types, and keep being: the mocks are still
|
|
4
|
+
// Jest's, taken as the standalone `jest-mock` package. This is what @types/jest declared of it.
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
|
|
8
|
+
namespace jest {
|
|
9
|
+
|
|
10
|
+
type Mock<T extends mock.FunctionLike = mock.UnknownFunction> = mock.Mock<T>
|
|
11
|
+
|
|
12
|
+
type Mocked<T extends object> = mock.Mocked<T>
|
|
13
|
+
|
|
14
|
+
type MockedClass<T extends mock.ClassLike> = mock.MockedClass<T>
|
|
15
|
+
|
|
16
|
+
type MockedFunction<T extends mock.FunctionLike = mock.UnknownFunction> = mock.MockedFunction<T>
|
|
17
|
+
|
|
18
|
+
type MockedFn<T extends mock.FunctionLike = mock.UnknownFunction> = mock.MockedFunction<T>
|
|
19
|
+
|
|
20
|
+
type MockedObject<T extends object> = mock.MockedObject<T>
|
|
21
|
+
}
|
|
22
|
+
}
|
package/test/ReplyPipe.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { Readable } = require('node:stream')
|
|
4
|
-
const { Promex } = require('promex')
|
|
4
|
+
const { Promex } = require('../source/promex')
|
|
5
5
|
const { ReplyPipe } = require('../source/.io/ReplyPipe')
|
|
6
6
|
const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
|
|
7
7
|
const { control, FLOW_HEADER } = require('../source/.io/const')
|
|
@@ -96,10 +96,14 @@ it('should pull the source no faster than the channel takes', async () => {
|
|
|
96
96
|
})
|
|
97
97
|
|
|
98
98
|
it('should hold the source while the consumer has asked for a pause', async () => {
|
|
99
|
-
|
|
99
|
+
// the pause is asked for while the confirmation is still being published: the pipe does not
|
|
100
|
+
// read its source until that has been published, so nothing can be in flight yet
|
|
101
|
+
const opening = ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
|
|
100
102
|
|
|
101
103
|
feedback.emit(request.properties.correlationId, control.pause, {})
|
|
102
104
|
|
|
105
|
+
const pipe = await opening
|
|
106
|
+
|
|
103
107
|
await timeout(10)
|
|
104
108
|
|
|
105
109
|
expect(messages()).toStrictEqual([control.ok])
|
|
@@ -208,3 +212,68 @@ it('should destroy the source if the confirmation cannot be sent', async () => {
|
|
|
208
212
|
|
|
209
213
|
expect(source.destroyed).toStrictEqual(true)
|
|
210
214
|
})
|
|
215
|
+
|
|
216
|
+
describe('batches', () => {
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
request.properties.contentType = 'application/json'
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('should send the values its source holds as one message', async () => {
|
|
222
|
+
const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
|
|
223
|
+
|
|
224
|
+
await closing(pipe)
|
|
225
|
+
|
|
226
|
+
expect(messages()).toStrictEqual([control.ok, [1, 2, 3], control.end])
|
|
227
|
+
expect(sent[1][1].type).toStrictEqual('batch')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should send each value that arrives on its own as it arrives', async () => {
|
|
231
|
+
async function * source () {
|
|
232
|
+
yield 1
|
|
233
|
+
await timeout(10)
|
|
234
|
+
yield 2
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const pipe = await ReplyPipe.create(request, Readable.from(source()), channel, feedback, reply)
|
|
238
|
+
|
|
239
|
+
await closing(pipe)
|
|
240
|
+
|
|
241
|
+
expect(messages()).toStrictEqual([control.ok, 1, 2, control.end])
|
|
242
|
+
expect(sent[1][1].type).toBeUndefined()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should send buffers as one message, each prefixed with its length', async () => {
|
|
246
|
+
const source = Readable.from([Buffer.from('ab'), Buffer.from('cde')])
|
|
247
|
+
const pipe = await ReplyPipe.create(request, source, channel, feedback, reply)
|
|
248
|
+
|
|
249
|
+
await closing(pipe)
|
|
250
|
+
|
|
251
|
+
const expected = Buffer.from([0, 0, 0, 2, 0x61, 0x62, 0, 0, 0, 3, 0x63, 0x64, 0x65])
|
|
252
|
+
|
|
253
|
+
expect(messages()[1]).toStrictEqual(expected)
|
|
254
|
+
expect(sent[1][1].type).toStrictEqual('buffers')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('should send values one at a time in an encoding that holds no list', async () => {
|
|
258
|
+
request.properties.contentType = 'text/plain'
|
|
259
|
+
|
|
260
|
+
const pipe = await ReplyPipe.create(request, Readable.from(['a', 'b']), channel, feedback, reply)
|
|
261
|
+
|
|
262
|
+
await closing(pipe)
|
|
263
|
+
|
|
264
|
+
expect(messages()).toStrictEqual([control.ok, 'a', 'b', control.end])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('should keep a batch within its limit', async () => {
|
|
268
|
+
const values = Array.from({ length: 300 }, (_, i) => i)
|
|
269
|
+
const source = Readable.from(values, { highWaterMark: 1000 })
|
|
270
|
+
const pipe = await ReplyPipe.create(request, source, channel, feedback, reply)
|
|
271
|
+
|
|
272
|
+
await closing(pipe)
|
|
273
|
+
|
|
274
|
+
const batches = messages().slice(1, -1)
|
|
275
|
+
|
|
276
|
+
expect(batches.flat()).toStrictEqual(values)
|
|
277
|
+
expect(Math.max(...batches.map((batch) => [batch].flat().length))).toBeLessThanOrEqual(128)
|
|
278
|
+
})
|
|
279
|
+
})
|
package/test/ReplyStream.test.js
CHANGED
|
@@ -204,3 +204,36 @@ describe('flow', () => {
|
|
|
204
204
|
}
|
|
205
205
|
})
|
|
206
206
|
})
|
|
207
|
+
|
|
208
|
+
describe('batches', () => {
|
|
209
|
+
const ok = { headers: { index: 0, [FLOW_HEADER]: true }, type: 'control' }
|
|
210
|
+
const end = (index) => [control.end, { headers: { index }, type: 'control' }]
|
|
211
|
+
|
|
212
|
+
it('should yield each value of a batch, in order', async () => {
|
|
213
|
+
stream.arrange(control.ok, ok)
|
|
214
|
+
stream.arrange([1, 2, 3], { headers: { index: 1 }, type: 'batch' })
|
|
215
|
+
stream.arrange(4, { headers: { index: 2 } })
|
|
216
|
+
stream.arrange(...end(3))
|
|
217
|
+
|
|
218
|
+
expect(await stream.toArray()).toStrictEqual([1, 2, 3, 4])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('should yield each buffer of a batch of buffers', async () => {
|
|
222
|
+
const buffers = Buffer.from([0, 0, 0, 2, 0x61, 0x62, 0, 0, 0, 0, 0, 0, 0, 1, 0x63])
|
|
223
|
+
|
|
224
|
+
stream.arrange(control.ok, ok)
|
|
225
|
+
stream.arrange(buffers, { headers: { index: 1 }, type: 'buffers' })
|
|
226
|
+
stream.arrange(...end(2))
|
|
227
|
+
|
|
228
|
+
expect(await stream.toArray()).toStrictEqual([Buffer.from('ab'), Buffer.alloc(0), Buffer.from('c')])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should hold a batch that arrives out of order until the gap is filled', async () => {
|
|
232
|
+
stream.arrange(control.ok, ok)
|
|
233
|
+
stream.arrange([3, 4], { headers: { index: 2 }, type: 'batch' })
|
|
234
|
+
stream.arrange([1, 2], { headers: { index: 1 }, type: 'batch' })
|
|
235
|
+
stream.arrange(...end(3))
|
|
236
|
+
|
|
237
|
+
expect(await stream.toArray()).toStrictEqual([1, 2, 3, 4])
|
|
238
|
+
})
|
|
239
|
+
})
|
package/test/connection.test.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const { generate } = require('randomstring')
|
|
6
6
|
|
|
7
|
-
const { Promex } = require('promex')
|
|
7
|
+
const { Promex } = require('../source/promex')
|
|
8
8
|
const { timeout, random } = require('./helpers')
|
|
9
9
|
const { amqplib, connect } = require('./amqplib.mock')
|
|
10
10
|
const { channel: create } = require('./connection.mock')
|
package/test/globals.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Jest's globals, on top of node:test.
|
|
4
|
+
//
|
|
5
|
+
// The runner is Node's own; the assertions, the mocks and the fake timers are still Jest's,
|
|
6
|
+
// taken as the standalone packages they are published as. Loaded with `--import`, so that
|
|
7
|
+
// the test files themselves keep seeing `it`, `expect` and `jest` as globals.
|
|
8
|
+
|
|
9
|
+
const { createRequire } = require('node:module')
|
|
10
|
+
const { format } = require('node:util')
|
|
11
|
+
const nodeTest = require('node:test')
|
|
12
|
+
const { expect } = require('expect')
|
|
13
|
+
const { ModuleMocker } = require('jest-mock')
|
|
14
|
+
const { ModernFakeTimers } = require('@jest/fake-timers')
|
|
15
|
+
|
|
16
|
+
// node:test drives its own queue with process.nextTick: faking it, as Jest does inside its
|
|
17
|
+
// sandbox, stops the runner mid-file and the tests left behind are never reported as missing
|
|
18
|
+
const DO_NOT_FAKE = ['nextTick']
|
|
19
|
+
|
|
20
|
+
const mocker = new ModuleMocker(globalThis)
|
|
21
|
+
const timers = new ModernFakeTimers({ global: globalThis, config: { rootDir: process.cwd() } })
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where `jest.mock` was called from, so that its specifier resolves
|
|
25
|
+
* relative to the test file rather than to this one.
|
|
26
|
+
*
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function origin () {
|
|
30
|
+
const prepare = Error.prepareStackTrace
|
|
31
|
+
|
|
32
|
+
Error.prepareStackTrace = (_, frames) => frames
|
|
33
|
+
|
|
34
|
+
const { stack } = new Error('origin')
|
|
35
|
+
|
|
36
|
+
Error.prepareStackTrace = prepare
|
|
37
|
+
|
|
38
|
+
for (const frame of stack) {
|
|
39
|
+
const file = frame.getFileName()
|
|
40
|
+
|
|
41
|
+
if (file !== null && file !== undefined && file !== __filename && !file.startsWith('node:')) {
|
|
42
|
+
return file
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
throw new Error('cannot tell which file has called jest.mock')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Replaces a module, as `jest.mock` does. Without a factory the module is automocked:
|
|
51
|
+
* every function it exports becomes a mock function.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} specifier
|
|
54
|
+
* @param {() => object} [factory]
|
|
55
|
+
*/
|
|
56
|
+
function mockModule (specifier, factory) {
|
|
57
|
+
if (nodeTest.mock.module === undefined) {
|
|
58
|
+
throw new Error('jest.mock requires node --experimental-test-module-mocks')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const path = createRequire(origin()).resolve(specifier)
|
|
62
|
+
|
|
63
|
+
const namedExports = factory === undefined
|
|
64
|
+
? mocker.generateFromMetadata(mocker.getMetadata(require(path)))
|
|
65
|
+
: factory()
|
|
66
|
+
|
|
67
|
+
nodeTest.mock.module(path, { namedExports, cache: false })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const jest = {
|
|
71
|
+
fn: (implementation) => mocker.fn(implementation),
|
|
72
|
+
spyOn: (object, property, accessType) => mocker.spyOn(object, property, accessType),
|
|
73
|
+
clearAllMocks: () => mocker.clearAllMocks(),
|
|
74
|
+
resetAllMocks: () => mocker.resetAllMocks(),
|
|
75
|
+
restoreAllMocks: () => mocker.restoreAllMocks(),
|
|
76
|
+
mock: mockModule,
|
|
77
|
+
useFakeTimers: (config) => timers.useFakeTimers({ doNotFake: DO_NOT_FAKE, ...config }),
|
|
78
|
+
useRealTimers: () => timers.useRealTimers(),
|
|
79
|
+
advanceTimersByTime: (ms) => timers.advanceTimersByTime(ms),
|
|
80
|
+
advanceTimersByTimeAsync: (ms) => timers.advanceTimersByTimeAsync(ms),
|
|
81
|
+
runAllTimers: () => timers.runAllTimers(),
|
|
82
|
+
runAllTimersAsync: () => timers.runAllTimersAsync(),
|
|
83
|
+
runOnlyPendingTimers: () => timers.runOnlyPendingTimers(),
|
|
84
|
+
getTimerCount: () => timers.getTimerCount(),
|
|
85
|
+
setSystemTime: (now) => timers.setSystemTime(now)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `it.each` / `describe.each`: one test per row, the row spread into the callback,
|
|
90
|
+
* the title formatted with the row's values.
|
|
91
|
+
*
|
|
92
|
+
* @param {Function} declare
|
|
93
|
+
* @returns {(table: any[]) => (title: string, fn: Function, options?: object) => void}
|
|
94
|
+
*/
|
|
95
|
+
const each = (declare) => (table) => (title, fn, options) => {
|
|
96
|
+
const placeholders = (title.replace(/%%/g, '').match(/%[sdifjoOc]/g) ?? []).length
|
|
97
|
+
|
|
98
|
+
for (const row of table) {
|
|
99
|
+
const values = Array.isArray(row) ? row : [row]
|
|
100
|
+
|
|
101
|
+
declare(format(title, ...values.slice(0, placeholders)), options, () => fn(...values))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Jest's `it` and `describe` carry `.each`; node:test's do not.
|
|
107
|
+
*
|
|
108
|
+
* @param {Function} declare
|
|
109
|
+
* @returns {Function}
|
|
110
|
+
*/
|
|
111
|
+
function extend (declare) {
|
|
112
|
+
// Jest takes a timeout where node:test takes options
|
|
113
|
+
const call = (...args) => declare(...(typeof args[2] === 'number'
|
|
114
|
+
? [args[0], { timeout: args[2] }, args[1]]
|
|
115
|
+
: args))
|
|
116
|
+
|
|
117
|
+
const extended = Object.assign(call, declare, { each: each(call) })
|
|
118
|
+
|
|
119
|
+
extended.only = Object.assign((...args) => declare.only(...args), { each: each(declare.only) })
|
|
120
|
+
extended.skip = Object.assign((...args) => declare.skip(...args), { each: each(declare.skip) })
|
|
121
|
+
extended.todo = (...args) => declare.todo(...args)
|
|
122
|
+
|
|
123
|
+
return extended
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Object.assign(globalThis, {
|
|
127
|
+
jest,
|
|
128
|
+
expect,
|
|
129
|
+
it: extend(nodeTest.it),
|
|
130
|
+
test: extend(nodeTest.test),
|
|
131
|
+
describe: extend(nodeTest.describe),
|
|
132
|
+
beforeEach: nodeTest.beforeEach,
|
|
133
|
+
afterEach: nodeTest.afterEach,
|
|
134
|
+
beforeAll: nodeTest.before,
|
|
135
|
+
afterAll: nodeTest.after
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// `expect.assertions` is only enforced by whoever runs the test
|
|
139
|
+
nodeTest.afterEach(() => {
|
|
140
|
+
const errors = expect.extractExpectedAssertionsErrors()
|
|
141
|
+
|
|
142
|
+
if (errors.length > 0) throw errors[0].error
|
|
143
|
+
})
|
package/test/io.close.test.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomBytes } = require('node:crypto')
|
|
4
4
|
const { generate } = require('randomstring')
|
|
5
|
-
const { Promex } = require('promex')
|
|
5
|
+
const { Promex } = require('../source/promex')
|
|
6
6
|
const { immediate } = require('./helpers')
|
|
7
7
|
|
|
8
8
|
const mock = require('./connection.mock')
|
package/test/io.request.test.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const stream = require('node:stream')
|
|
6
6
|
const { randomBytes } = require('node:crypto')
|
|
7
7
|
const { generate } = require('randomstring')
|
|
8
|
-
const { Promex } = require('promex')
|
|
8
|
+
const { Promex } = require('../source/promex')
|
|
9
9
|
const { immediate } = require('./helpers')
|
|
10
10
|
const { encode } = require('../source/encode')
|
|
11
11
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomBytes } = require('node:crypto')
|
|
4
4
|
const { generate } = require('randomstring')
|
|
5
|
-
const { Promex } = require('promex')
|
|
5
|
+
const { Promex } = require('../../source/promex')
|
|
6
6
|
const { sample, immediate, random } = require('../helpers')
|
|
7
7
|
|
|
8
8
|
const mock = require('../connection.mock')
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
|
-
const { Promex } = require('promex')
|
|
4
|
+
const { Promex } = require('../../source/promex')
|
|
5
5
|
const { random, immediate } = require('../helpers')
|
|
6
6
|
|
|
7
|
-
const { Connection } = require('../../source/shards')
|
|
8
|
-
|
|
9
7
|
const mock = require('../connection.mock')
|
|
10
8
|
|
|
11
9
|
jest.mock('../../source/shards/channel')
|
|
12
10
|
|
|
11
|
+
const { Connection } = require('../../source/shards')
|
|
13
12
|
const { create } = require('../../source/shards/channel')
|
|
14
13
|
|
|
15
14
|
it('should be', async () => {
|