comq 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/commit-msg +5 -0
- package/benchmarks/run.js +66 -4
- package/benchmarks/server.js +14 -0
- package/commitlint.config.js +6 -0
- package/features/heartbeat.feature +16 -0
- package/features/rpc.call.recovery.feature +2 -2
- package/features/rpc.shards.retransmission.feature +46 -0
- package/features/rpc.streams.feature +28 -0
- package/features/shards.feature +1 -1
- package/features/silence.feature +3 -2
- package/features/steps/.types/context.d.ts +6 -1
- package/features/steps/brokers.js +1 -1
- package/features/steps/connection.js +23 -0
- package/features/steps/context.js +1 -2
- package/features/steps/hooks.js +0 -1
- package/features/steps/network.js +11 -2
- package/features/steps/networks.js +31 -2
- package/features/steps/retransmission.js +165 -0
- package/features/steps/rpc.js +31 -7
- package/package.json +31 -9
- package/readme.md +15 -4
- package/source/.io/ReplyPipe.js +130 -11
- package/source/.io/ReplyStream.js +41 -2
- package/source/.io/const.js +6 -0
- package/source/attributes/track.test.js +1 -1
- package/source/channel.js +5 -3
- package/source/connection.js +11 -88
- package/source/io.js +91 -16
- package/source/promex.js +47 -0
- package/source/promex.test.js +79 -0
- package/source/retry.js +42 -0
- package/source/retry.test.js +120 -0
- package/source/shards/channel.js +74 -13
- package/test/.types/jest.d.ts +22 -0
- package/test/ReplyPipe.test.js +71 -2
- package/test/ReplyStream.test.js +33 -0
- package/test/connection.mock.js +3 -2
- package/test/connection.test.js +16 -78
- package/test/globals.js +143 -0
- package/test/io.call.test.js +5 -3
- package/test/io.close.test.js +1 -1
- package/test/io.reply.test.js +5 -3
- package/test/io.request.test.js +264 -2
- package/test/memory.test.js +0 -2
- package/test/shards/channel.test.js +169 -3
- package/test/shards/connection.test.js +2 -3
- package/types/channel.d.ts +13 -4
- package/types/io.d.ts +9 -0
package/features/steps/rpc.js
CHANGED
|
@@ -88,12 +88,6 @@ Given('idle timeout is set to {number}ms',
|
|
|
88
88
|
global.COMQ_TESTING_IDLE_INTERVAL = value
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
-
Given('watchdog interval is set to {number}ms with {number}s AMQP heartbeat',
|
|
92
|
-
function (watchdog, heartbeat) {
|
|
93
|
-
global.COMQ_TESTING_WATCHDOG_INTERVAL = watchdog
|
|
94
|
-
global.COMQ_TESTING_AMQP_HEARTBEAT = heartbeat
|
|
95
|
-
})
|
|
96
|
-
|
|
97
91
|
Given('a producer replying {token} queue',
|
|
98
92
|
/**
|
|
99
93
|
* @param {string} queue
|
|
@@ -297,7 +291,37 @@ Then('the consumer receives the stream:',
|
|
|
297
291
|
|
|
298
292
|
for await (const reply of this.stream) replies.push(reply)
|
|
299
293
|
|
|
300
|
-
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)
|
|
301
325
|
})
|
|
302
326
|
|
|
303
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,24 +51,43 @@
|
|
|
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
|
]
|
|
79
|
+
},
|
|
80
|
+
"overrides": {
|
|
81
|
+
"rimraf": "^6.1.3",
|
|
82
|
+
"archiver-utils": {
|
|
83
|
+
"glob": "^13.0.6"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"allowScripts": {
|
|
87
|
+
"@parcel/watcher": false,
|
|
88
|
+
"cpu-features": false,
|
|
89
|
+
"protobufjs": false,
|
|
90
|
+
"ssh2": false,
|
|
91
|
+
"unrs-resolver": false
|
|
70
92
|
}
|
|
71
93
|
}
|
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>
|
|
@@ -457,8 +463,9 @@ Reconnection attempts will be made indefinitely, with intervals increasing up to
|
|
|
457
463
|
Unless the URL sets one, a 15 second heartbeat is requested, so that a connection that is gone
|
|
458
464
|
without a word, such as after a machine wakes from sleep, is noticed within a minute instead of
|
|
459
465
|
being left to whatever the broker suggests.
|
|
460
|
-
A connection that
|
|
461
|
-
the operating system have reported, since neither is guaranteed to report anything
|
|
466
|
+
A connection that has received nothing for two heartbeats is closed and restored regardless of what
|
|
467
|
+
the broker and the operating system have reported, since neither is guaranteed to report anything
|
|
468
|
+
at all. A connection that is merely idle receives the broker's heartbeats, and is kept.
|
|
462
469
|
Requesting `heartbeat=0` disables both.
|
|
463
470
|
If the broker rejects the connection, for example, due to access being denied, an exception will be thrown.
|
|
464
471
|
Once reconnected, the topology will be recovered, and any unanswered requests and unconfirmed events will be
|
|
@@ -480,6 +487,10 @@ connection is re-established.
|
|
|
480
487
|
|
|
481
488
|
Incoming messages are consumed from all shards.
|
|
482
489
|
|
|
490
|
+
A Reply goes back through the shard its Request arrived on, while that shard is reachable. When a
|
|
491
|
+
shard is lost, the Requests sent through it that are still unanswered are re-sent through the
|
|
492
|
+
others, since their Replies may be lost with it; a Request sent through another shard is not.
|
|
493
|
+
|
|
483
494
|
`async connect(...shards: string[]): IO`
|
|
484
495
|
|
|
485
496
|
Returns an instance of `IO` once connections to the shards are established.
|
|
@@ -795,8 +806,8 @@ Subscribe to one of the diagnostic events:
|
|
|
795
806
|
an argument.
|
|
796
807
|
- `drain`: back pressure is removed from a channel. Channel type is passed.
|
|
797
808
|
- `remove`: channel is removed from the [pool](#sharded-connection), having failed to publish.
|
|
798
|
-
- `lost`: a shard has lost its connection, hence the requests
|
|
799
|
-
re-sent. Channel type is passed.
|
|
809
|
+
- `lost`: a shard has lost its connection, hence the requests sent through it that are awaiting
|
|
810
|
+
their replies are re-sent. Channel type is passed.
|
|
800
811
|
- `recover`: channel's topology is recovered. Channel type is passed.
|
|
801
812
|
- `discard`: message is [parked](#parked-messages), having run out of attempts. Channel type,
|
|
802
813
|
raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
|
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')
|
|
@@ -70,7 +70,9 @@ class Channel {
|
|
|
70
70
|
this.#failfast = index !== undefined
|
|
71
71
|
this.#release = release
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// a shard's publish fails rather than waits for its connection to come back: the pool
|
|
74
|
+
// fails it over to another shard, and whoever published it is told where it went
|
|
75
|
+
if (this.#failfast) failsafe.disable(this.send, this.publish, this.route)
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
async create () {
|
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')
|
|
@@ -35,18 +35,9 @@ class Connection {
|
|
|
35
35
|
/** @type {boolean} */
|
|
36
36
|
#closed = false
|
|
37
37
|
|
|
38
|
-
/** @type {NodeJS.Timeout | null} */
|
|
39
|
-
#heartbeatTimer = null
|
|
40
|
-
|
|
41
38
|
/** @type {comq.topology.Overrides} */
|
|
42
39
|
#overrides
|
|
43
40
|
|
|
44
|
-
/** @type {import('node:net').Socket | null} */
|
|
45
|
-
#heartbeatSocket = null
|
|
46
|
-
|
|
47
|
-
/** @type {(() => void) | null} */
|
|
48
|
-
#heartbeatReset = null
|
|
49
|
-
|
|
50
41
|
#diagnostics = emitter.create()
|
|
51
42
|
|
|
52
43
|
/**
|
|
@@ -147,7 +138,6 @@ class Connection {
|
|
|
147
138
|
connection.on('close', (error) => this.#close(connection, error))
|
|
148
139
|
this.#connection = connection
|
|
149
140
|
|
|
150
|
-
this.#armWatchdog(connection)
|
|
151
141
|
this.#diagnostics.emit('open')
|
|
152
142
|
|
|
153
143
|
try {
|
|
@@ -177,11 +167,14 @@ class Connection {
|
|
|
177
167
|
#close = (connection, error) => {
|
|
178
168
|
if (this.#connection !== connection) return
|
|
179
169
|
|
|
180
|
-
this.#disarmWatchdog()
|
|
181
170
|
this.#diagnostics.emit('close', error)
|
|
182
171
|
connection.removeAllListeners()
|
|
183
172
|
this.#connection = undefined
|
|
184
173
|
|
|
174
|
+
// amqplib only ends the socket of a connection it has given up on, and a peer that has gone
|
|
175
|
+
// silent never ends its side, which would leave the socket to the kernel's keepalive
|
|
176
|
+
if (error !== undefined) connection.connection?.stream?.destroy()
|
|
177
|
+
|
|
185
178
|
if (error !== undefined && !this.#closed) {
|
|
186
179
|
this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
187
180
|
}
|
|
@@ -224,10 +217,7 @@ class Connection {
|
|
|
224
217
|
* @param {comq.amqp.Connection} connection
|
|
225
218
|
*/
|
|
226
219
|
#drop (connection) {
|
|
227
|
-
if (this.#connection === connection)
|
|
228
|
-
this.#disarmWatchdog()
|
|
229
|
-
this.#connection = undefined
|
|
230
|
-
}
|
|
220
|
+
if (this.#connection === connection) this.#connection = undefined
|
|
231
221
|
|
|
232
222
|
connection.removeAllListeners()
|
|
233
223
|
|
|
@@ -294,46 +284,6 @@ class Connection {
|
|
|
294
284
|
}
|
|
295
285
|
}
|
|
296
286
|
|
|
297
|
-
/**
|
|
298
|
-
* @param {comq.amqp.Connection} connection
|
|
299
|
-
* @param {number} [timeoutMs]
|
|
300
|
-
*/
|
|
301
|
-
#armWatchdog (connection, timeoutMs = tolerance(connection)) {
|
|
302
|
-
this.#disarmWatchdog()
|
|
303
|
-
|
|
304
|
-
const socket = connection.connection?.stream
|
|
305
|
-
|
|
306
|
-
if (socket === undefined || timeoutMs === undefined) return
|
|
307
|
-
|
|
308
|
-
const reset = () => {
|
|
309
|
-
clearTimeout(this.#heartbeatTimer)
|
|
310
|
-
// destroying a socket without an error tells amqplib nothing: it only
|
|
311
|
-
// listens for 'error' and 'end', so a bare destroy() leaves the connection
|
|
312
|
-
// silently dead — no 'close' event, no recovery, every pending operation
|
|
313
|
-
// hanging forever
|
|
314
|
-
this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
|
|
315
|
-
this.#heartbeatTimer.unref()
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
this.#heartbeatSocket = socket
|
|
319
|
-
this.#heartbeatReset = reset
|
|
320
|
-
|
|
321
|
-
reset()
|
|
322
|
-
socket.on('data', reset)
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
#disarmWatchdog () {
|
|
326
|
-
clearTimeout(this.#heartbeatTimer)
|
|
327
|
-
this.#heartbeatTimer = null
|
|
328
|
-
|
|
329
|
-
// otherwise a byte arriving on a replaced socket rearms the watchdog of a
|
|
330
|
-
// connection that is already gone, leaving the current one unguarded
|
|
331
|
-
this.#heartbeatSocket?.off('data', this.#heartbeatReset)
|
|
332
|
-
|
|
333
|
-
this.#heartbeatSocket = null
|
|
334
|
-
this.#heartbeatReset = null
|
|
335
|
-
}
|
|
336
|
-
|
|
337
287
|
#transient (exception) {
|
|
338
288
|
if (this.#running) return true
|
|
339
289
|
if (TRANSIENT_CODES.has(exception.code)) return true
|
|
@@ -348,6 +298,10 @@ class Connection {
|
|
|
348
298
|
* to suggest one that leaves a connection lost for minutes before anything
|
|
349
299
|
* notices, and RabbitMQ suggests 60 by default.
|
|
350
300
|
*
|
|
301
|
+
* It is amqplib that tells a connection gone silent: one that has received nothing
|
|
302
|
+
* for two heartbeats is closed with an error, whatever the broker and the operating
|
|
303
|
+
* system have reported, which is what restores it.
|
|
304
|
+
*
|
|
351
305
|
* @type {number}
|
|
352
306
|
*/
|
|
353
307
|
const HEARTBEAT_S = 15
|
|
@@ -355,15 +309,6 @@ const HEARTBEAT_S = 15
|
|
|
355
309
|
/** @type {number} */
|
|
356
310
|
const KEEPALIVE_MS = 10_000
|
|
357
311
|
|
|
358
|
-
/** How many heartbeats a connection may miss before it is destroyed. */
|
|
359
|
-
const MISSED_HEARTBEATS = 3
|
|
360
|
-
|
|
361
|
-
/** @type {number} */
|
|
362
|
-
const WATCHDOG_MIN_MS = 15_000
|
|
363
|
-
|
|
364
|
-
/** The watchdog interval used when the negotiated heartbeat is unknown. */
|
|
365
|
-
const WATCHDOG_MS = 60_000
|
|
366
|
-
|
|
367
312
|
/** @type {number} */
|
|
368
313
|
const CONNECT_MS = 30_000
|
|
369
314
|
|
|
@@ -422,28 +367,6 @@ function heartbeaten (url) {
|
|
|
422
367
|
return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
|
|
423
368
|
}
|
|
424
369
|
|
|
425
|
-
/**
|
|
426
|
-
* The watchdog measures silence, so it cannot be shorter than the interval at
|
|
427
|
-
* which a healthy broker is expected to say something, which is a heartbeat
|
|
428
|
-
* frame every half a heartbeat. A connection that has agreed to no heartbeats
|
|
429
|
-
* says nothing at all while it is idle, leaving nothing to measure.
|
|
430
|
-
*
|
|
431
|
-
* @param {comq.amqp.Connection} connection
|
|
432
|
-
* @return {number | undefined}
|
|
433
|
-
*/
|
|
434
|
-
function tolerance (connection) {
|
|
435
|
-
const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
|
|
436
|
-
|
|
437
|
-
if (override !== undefined) return override
|
|
438
|
-
|
|
439
|
-
const heartbeat = connection.connection?.heartbeat
|
|
440
|
-
|
|
441
|
-
if (heartbeat === undefined) return WATCHDOG_MS
|
|
442
|
-
if (heartbeat === 0) return undefined
|
|
443
|
-
|
|
444
|
-
return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
370
|
/**
|
|
448
371
|
* @return {Error}
|
|
449
372
|
*/
|