comq 0.24.1 → 0.25.1
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 +16 -3
- 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/channel.test.js +25 -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.1",
|
|
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')
|
|
@@ -49,6 +49,9 @@ class Channel {
|
|
|
49
49
|
/** @type {Set<Promex>} */
|
|
50
50
|
#confirmations = new Set()
|
|
51
51
|
|
|
52
|
+
/** @type {Set<Promise<void>>} failed messages on their way to a retry or the parking queue */
|
|
53
|
+
#settling = new Set()
|
|
54
|
+
|
|
52
55
|
#diagnostics = emitter.create()
|
|
53
56
|
|
|
54
57
|
#closed = false
|
|
@@ -221,6 +224,10 @@ class Channel {
|
|
|
221
224
|
|
|
222
225
|
await this.seal()
|
|
223
226
|
|
|
227
|
+
// a failed message is moved in two steps, its copy placed and then the original
|
|
228
|
+
// released; a channel closed between them leaves the broker holding both
|
|
229
|
+
await Promise.allSettled(this.#settling)
|
|
230
|
+
|
|
224
231
|
// a channel that went down with its connection is the outcome this asks for
|
|
225
232
|
await this.#channel?.close().catch(noop)
|
|
226
233
|
|
|
@@ -587,7 +594,13 @@ class Channel {
|
|
|
587
594
|
} catch (exception) {
|
|
588
595
|
if (exception?.message === 'Channel closed') { return } // the message is requeued by the broker
|
|
589
596
|
|
|
590
|
-
|
|
597
|
+
const settling = this.#failed(queue, message, exception)
|
|
598
|
+
|
|
599
|
+
this.#settling.add(settling)
|
|
600
|
+
|
|
601
|
+
await settling
|
|
602
|
+
|
|
603
|
+
this.#settling.delete(settling)
|
|
591
604
|
}
|
|
592
605
|
}
|
|
593
606
|
|
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')
|