comq 0.16.1 → 0.17.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/.github/dependabot.yml +5 -0
- package/features/events.routed.feature +19 -0
- package/features/steps/events.routed.js +55 -0
- package/package.json +1 -1
- package/readme.md +44 -2
- package/source/.io/ReplyPipe.js +57 -14
- package/source/.io/ReplyStream.js +64 -10
- package/source/.io/const.js +9 -1
- package/source/.io/createReplyEmitter.js +84 -9
- package/source/attributes/lazy.js +65 -25
- package/source/attributes/lazy.test.js +39 -0
- package/source/attributes/recall.js +17 -0
- package/source/attributes/recall.test.js +43 -0
- package/source/channel.js +82 -7
- package/source/connection.js +16 -8
- package/source/io.js +94 -31
- package/source/pipeline.js +12 -4
- package/source/shards/connection.js +4 -0
- package/test/ReplyPipe.test.js +170 -0
- package/test/ReplyStream.test.js +82 -4
- package/test/channel.routed.test.js +119 -0
- package/test/channel.test.js +58 -0
- package/test/connection.mock.js +5 -1
- package/test/connection.test.js +34 -2
- package/test/io.diagnostics.test.js +11 -0
- package/test/io.route.test.js +94 -0
- package/test/memory.test.js +273 -0
- package/test/pipeline.test.js +21 -0
- package/test/shards/connection.test.js +15 -0
- package/types/channel.d.ts +6 -0
- package/types/connection.d.ts +2 -0
- package/types/io.d.ts +28 -2
package/.github/dependabot.yml
CHANGED
|
@@ -14,3 +14,8 @@ updates:
|
|
|
14
14
|
target-branch: "dev"
|
|
15
15
|
labels:
|
|
16
16
|
- "Bump"
|
|
17
|
+
ignore:
|
|
18
|
+
# a newer preset needs conventional-changelog-writer@9, which the
|
|
19
|
+
# semantic-release plugins do not use yet; the release fails to render its notes
|
|
20
|
+
- dependency-name: "conventional-changelog-conventionalcommits"
|
|
21
|
+
update-types: ["version-update:semver-major"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Feature: Routed events
|
|
2
|
+
|
|
3
|
+
An exchange that routes by key, where an event exchange fans out: a message reaches the
|
|
4
|
+
queues bound under the key it was published with, and none of the others.
|
|
5
|
+
|
|
6
|
+
Background:
|
|
7
|
+
Given an active connection to the broker
|
|
8
|
+
|
|
9
|
+
Scenario: Receiving an event by its routing key
|
|
10
|
+
Given that `orders` is bound to the `records` exchange under the `placed` key
|
|
11
|
+
When a message is routed to the `records` exchange under the `placed` key
|
|
12
|
+
Then `orders` receives the event
|
|
13
|
+
|
|
14
|
+
Scenario: Not receiving an event bound under another key
|
|
15
|
+
Given that `orders` is bound to the `records` exchange under the `placed` key
|
|
16
|
+
And `customers` is bound to the `records` exchange under the `shipped` key
|
|
17
|
+
When a message is routed to the `records` exchange under the `placed` key
|
|
18
|
+
Then `orders` receives the event
|
|
19
|
+
And `customers` receives nothing
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const { randomBytes } = require('node:crypto')
|
|
5
|
+
const { timeout } = require('../../test/helpers')
|
|
6
|
+
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
7
|
+
|
|
8
|
+
Given('(that ){token} is bound to the {token} exchange under the {token} key',
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} queue
|
|
11
|
+
* @param {string} exchange
|
|
12
|
+
* @param {string} key
|
|
13
|
+
* @this {comq.features.Context}
|
|
14
|
+
*/
|
|
15
|
+
async function (queue, exchange, key) {
|
|
16
|
+
await subscribe.call(this, queue, exchange, key)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
When('a message is routed to the {token} exchange under the {token} key',
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} exchange
|
|
22
|
+
* @param {string} key
|
|
23
|
+
* @this {comq.features.Context}
|
|
24
|
+
*/
|
|
25
|
+
async function (exchange, key) {
|
|
26
|
+
const message = randomBytes(8)
|
|
27
|
+
|
|
28
|
+
await this.io.route(exchange, key, message)
|
|
29
|
+
|
|
30
|
+
this.published = message
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
Then('{token} receives nothing',
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} queue
|
|
36
|
+
* @this {comq.features.Context}
|
|
37
|
+
*/
|
|
38
|
+
async function (queue) {
|
|
39
|
+
await timeout(100) // let it not consume
|
|
40
|
+
|
|
41
|
+
assert.equal(this.consumed[queue], undefined, `'${queue}' has received an event`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} queue
|
|
46
|
+
* @param {string} exchange
|
|
47
|
+
* @param {string} key
|
|
48
|
+
* @this {comq.features.Context}
|
|
49
|
+
*/
|
|
50
|
+
async function subscribe (queue, exchange, key) {
|
|
51
|
+
await this.io.subscribe(exchange, queue, key, async (payload, properties) => {
|
|
52
|
+
this.consumed[queue] = { payload, properties }
|
|
53
|
+
this.eventsConsumedCount++
|
|
54
|
+
})
|
|
55
|
+
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -7,7 +7,7 @@ for distributed, eventually consistent systems running on Node.js.
|
|
|
7
7
|
|
|
8
8
|
- [Dynamic topology](#topology)
|
|
9
9
|
- [Request](#request)-[reply](#reply) (RPC)
|
|
10
|
-
- Events ([pub](#emission)/[sub](#consumption))
|
|
10
|
+
- Events ([pub](#emission)/[sub](#consumption)), fanned out or [routed](#routing)
|
|
11
11
|
- [Tasks](#tasks)
|
|
12
12
|
- [Pipelines](#pipelines)
|
|
13
13
|
- [Reply streams](#reply-streams)
|
|
@@ -67,6 +67,9 @@ value as in the Request.
|
|
|
67
67
|
|
|
68
68
|
**Event** is an AMQP message published to an exchange.
|
|
69
69
|
|
|
70
|
+
**Key** is the routing key an Event is published with, and the one a queue is bound under. An
|
|
71
|
+
Event published to a fanout exchange carries none.
|
|
72
|
+
|
|
70
73
|
**Task** is an AMQP message sent to a queue without a `replyTo` property set.
|
|
71
74
|
|
|
72
75
|
**Producer** is an application role that receives Requests and Tasks, and produces Replies and Events.
|
|
@@ -158,6 +161,36 @@ asserted.
|
|
|
158
161
|
await io.emit('numbers_added', { a: 1, b: 2 })
|
|
159
162
|
```
|
|
160
163
|
|
|
164
|
+
## Routing
|
|
165
|
+
|
|
166
|
+
`async IO.route(exchange: string, key: string, payload: any, encoding?: string): void`
|
|
167
|
+
|
|
168
|
+
`async IO.subscribe(exchange: string, queue: string, key: string, consumer): void`
|
|
169
|
+
|
|
170
|
+
Publish and consume Events addressed by a Key, where [Emission](#emission)
|
|
171
|
+
and [Consumption](#consumption) fan out.
|
|
172
|
+
|
|
173
|
+
`route` asserts a
|
|
174
|
+
[direct exchange](https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-direct) (once per
|
|
175
|
+
unique `exchange`) and publishes the encoded Event to it under the `key`. `subscribe` asserts the
|
|
176
|
+
same exchange and a durable `queue`, binds it under the `key` (once per unique `exchange`, `queue`
|
|
177
|
+
and `key`), and starts consuming. **An Event reaches the queues bound under the Key it carries,
|
|
178
|
+
and no others.**
|
|
179
|
+
|
|
180
|
+
The queue is named rather than derived from a Consumer group, because what identifies it here is
|
|
181
|
+
the Key it is bound under rather than the exchange it belongs to. It is also durable, so what is
|
|
182
|
+
published while nothing is consuming is held rather than dropped.
|
|
183
|
+
|
|
184
|
+
### Example
|
|
185
|
+
|
|
186
|
+
```javascript
|
|
187
|
+
await io.subscribe('records', 'records.orders', 'store.orders',
|
|
188
|
+
(record) => console.log(record))
|
|
189
|
+
|
|
190
|
+
await io.route('records', 'store.orders', { id: 1, status: 'paid' })
|
|
191
|
+
await io.route('records', 'store.customers', { id: 2 }) // not delivered to the above
|
|
192
|
+
```
|
|
193
|
+
|
|
161
194
|
## Tasks
|
|
162
195
|
|
|
163
196
|
`async IO.enqueue(queue: string, payload: any, encoding?: string): void`
|
|
@@ -255,6 +288,11 @@ These intervals are not configurable.
|
|
|
255
288
|
|
|
256
289
|
An "end stream" message is sent to the `replyTo` queue when the Reply stream is finished.
|
|
257
290
|
|
|
291
|
+
When the Consumer falls behind, a "pause" message is sent to the Producer's control queue,
|
|
292
|
+
and the Producer stops pulling values from the iterator until a "resume" message follows.
|
|
293
|
+
The Producer announces its support of these messages in the confirmation message,
|
|
294
|
+
so they are never sent to a Producer that would not understand them.
|
|
295
|
+
|
|
258
296
|
See also [Reply stream shutdown](#reply-stream-shutdown).
|
|
259
297
|
|
|
260
298
|
### Loss of tail
|
|
@@ -278,7 +316,8 @@ At the same time, there is no guarantee that the stream will be transmitted to t
|
|
|
278
316
|
|
|
279
317
|
> When using the [Sharded connection](#sharded-connection), the order of yielded values is maintained through buffering.
|
|
280
318
|
> However, there is a scenario in which some of the yielded values may be lost if a broker crashes.
|
|
281
|
-
> In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded
|
|
319
|
+
> In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded
|
|
320
|
+
> (1000 values or 16 MiB of encoded messages).
|
|
282
321
|
> Also, buffered control messages can result in [stream idling](#stream-control).
|
|
283
322
|
|
|
284
323
|
## Encoding
|
|
@@ -403,6 +442,9 @@ requests and are expecting replies.
|
|
|
403
442
|
|
|
404
443
|
- Exchanges and queues for Events, and queues for Requests
|
|
405
444
|
are _durable_.
|
|
445
|
+
- An exchange is asserted as _fanout_ for [Emission](#emission)
|
|
446
|
+
and [Consumption](#consumption), and as _direct_ for [Routing](#routing). One name is one or
|
|
447
|
+
the other: asserting it as both is what the broker refuses.
|
|
406
448
|
- Queues for Replies are _exclusive_ and _auto deleted_.
|
|
407
449
|
|
|
408
450
|
See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.html#channel_assertQueue).
|
package/source/.io/ReplyPipe.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('node:events')
|
|
4
|
-
const {
|
|
4
|
+
const { Promex } = require('promex')
|
|
5
|
+
const { control, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
|
|
5
6
|
|
|
6
7
|
/** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
|
|
7
8
|
|
|
8
9
|
class ReplyPipe extends EventEmitter {
|
|
9
10
|
#index = -1
|
|
10
11
|
#interrupted = false
|
|
12
|
+
#closed = false
|
|
11
13
|
#heartbeatInterval = global['COMQ_TESTING_HEARTBEAT_INTERVAL'] || HEARTBEAT_INTERVAL
|
|
12
14
|
|
|
13
15
|
/** @type {ReturnType<setInterval> | null} */
|
|
14
16
|
#interval = null
|
|
15
17
|
|
|
18
|
+
/** Closed while the consumer has asked for a pause. */
|
|
19
|
+
#gate = null
|
|
20
|
+
|
|
16
21
|
/** @type {string} */
|
|
17
22
|
#replyTo
|
|
18
23
|
|
|
@@ -51,7 +56,8 @@ class ReplyPipe extends EventEmitter {
|
|
|
51
56
|
|
|
52
57
|
this.#properties = {
|
|
53
58
|
chunk: { correlationId, ...CHUNK },
|
|
54
|
-
control: { correlationId, replyTo: feedback.queue, ...CONTROL }
|
|
59
|
+
control: { correlationId, replyTo: feedback.queue, ...CONTROL },
|
|
60
|
+
ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
channel.diagnose('return', this.#onReturn)
|
|
@@ -59,11 +65,13 @@ class ReplyPipe extends EventEmitter {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
async pipe () {
|
|
62
|
-
await this.#transmit(control.ok, this.#properties.
|
|
68
|
+
await this.#transmit(control.ok, this.#properties.ok)
|
|
69
|
+
|
|
70
|
+
if (this.#closed) return
|
|
63
71
|
|
|
64
|
-
this.#stream.on('data', this.#onData)
|
|
65
|
-
this.#stream.on('close', this.#close)
|
|
66
72
|
this.#heartbeat()
|
|
73
|
+
|
|
74
|
+
void this.#pump()
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
destroy () {
|
|
@@ -71,11 +79,35 @@ class ReplyPipe extends EventEmitter {
|
|
|
71
79
|
this.#stream.destroy()
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The source is pulled rather than listened to: a value is asked for once the
|
|
84
|
+
* previous one has been handed to the channel, so a paused channel or a paused
|
|
85
|
+
* consumer holds the source back instead of piling its output up here.
|
|
86
|
+
*/
|
|
87
|
+
async #pump () {
|
|
88
|
+
try {
|
|
89
|
+
for await (const chunk of this.#stream) {
|
|
90
|
+
if (this.#gate !== null) await this.#gate
|
|
91
|
+
if (this.#closed) break
|
|
92
|
+
|
|
93
|
+
await this.#transmit(chunk, this.#properties.chunk)
|
|
94
|
+
|
|
95
|
+
if (this.#closed) break
|
|
96
|
+
|
|
97
|
+
this.#heartbeat()
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// the source has been destroyed, by this pipe or by whoever made it
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.#close()
|
|
104
|
+
}
|
|
105
|
+
|
|
74
106
|
async #transmit (data, properties) {
|
|
75
107
|
this.#index++
|
|
76
108
|
|
|
77
|
-
const
|
|
78
|
-
|
|
109
|
+
const headers = { ...properties.headers, index: this.#index }
|
|
110
|
+
const ok = await this.#reply(data, { ...properties, headers })
|
|
79
111
|
|
|
80
112
|
if (!ok) this.#interrupt()
|
|
81
113
|
}
|
|
@@ -97,12 +129,21 @@ class ReplyPipe extends EventEmitter {
|
|
|
97
129
|
#clear () {
|
|
98
130
|
clearInterval(this.#interval)
|
|
99
131
|
|
|
100
|
-
this.#stream.removeAllListeners()
|
|
101
132
|
this.#channel.forget('return', this.#onReturn)
|
|
102
133
|
this.#feedback.off(this.#properties.control.correlationId, this.#control)
|
|
134
|
+
this.#resume()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#resume () {
|
|
138
|
+
this.#gate?.resolve()
|
|
139
|
+
this.#gate = null
|
|
103
140
|
}
|
|
104
141
|
|
|
105
142
|
#close = () => {
|
|
143
|
+
if (this.#closed) return
|
|
144
|
+
|
|
145
|
+
this.#closed = true
|
|
146
|
+
|
|
106
147
|
this.emit('close')
|
|
107
148
|
this.#clear()
|
|
108
149
|
|
|
@@ -111,12 +152,6 @@ class ReplyPipe extends EventEmitter {
|
|
|
111
152
|
}
|
|
112
153
|
}
|
|
113
154
|
|
|
114
|
-
#onData = async (chunk) => {
|
|
115
|
-
await this.#transmit(chunk, this.#properties.chunk)
|
|
116
|
-
|
|
117
|
-
this.#heartbeat()
|
|
118
|
-
}
|
|
119
|
-
|
|
120
155
|
#onReturn = (message) => {
|
|
121
156
|
if (message.fields.routingKey === this.#replyTo)
|
|
122
157
|
this.#interrupt()
|
|
@@ -127,6 +162,12 @@ class ReplyPipe extends EventEmitter {
|
|
|
127
162
|
case control.end:
|
|
128
163
|
this.#interrupt()
|
|
129
164
|
break
|
|
165
|
+
case control.pause:
|
|
166
|
+
this.#gate ??= new Promex()
|
|
167
|
+
break
|
|
168
|
+
case control.resume:
|
|
169
|
+
this.#resume()
|
|
170
|
+
break
|
|
130
171
|
default:
|
|
131
172
|
throw new Error(`Unknown control message: ${message}`)
|
|
132
173
|
}
|
|
@@ -155,4 +196,6 @@ const CHUNK = { mandatory: true }
|
|
|
155
196
|
/** @type {comq.amqp.options.Publish} */
|
|
156
197
|
const CONTROL = { type: 'control', mandatory: true }
|
|
157
198
|
|
|
199
|
+
const FLOW = { [FLOW_HEADER]: true }
|
|
200
|
+
|
|
158
201
|
exports.ReplyPipe = ReplyPipe
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Readable } = require('node:stream')
|
|
4
4
|
const { Promex } = require('promex')
|
|
5
|
-
const { IDLE_INTERVAL, control } = require('./const')
|
|
5
|
+
const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
|
|
6
6
|
|
|
7
7
|
class ReplyStream extends Readable {
|
|
8
8
|
confirmation = new Promex()
|
|
@@ -16,6 +16,7 @@ class ReplyStream extends Readable {
|
|
|
16
16
|
/** @type {string} */
|
|
17
17
|
#correlationId
|
|
18
18
|
|
|
19
|
+
/** The confirmation message, addressing the control queue of the producer. */
|
|
19
20
|
#control
|
|
20
21
|
|
|
21
22
|
#reply
|
|
@@ -26,9 +27,19 @@ class ReplyStream extends Readable {
|
|
|
26
27
|
|
|
27
28
|
#buffered = 0
|
|
28
29
|
|
|
30
|
+
#bufferedBytes = 0
|
|
31
|
+
|
|
29
32
|
#maxBufferSize
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
#maxBufferBytes
|
|
35
|
+
|
|
36
|
+
/** Whether the producer honours `pause` and `resume`. */
|
|
37
|
+
#flow = false
|
|
38
|
+
|
|
39
|
+
/** Whether the producer has been asked to pause. */
|
|
40
|
+
#throttled = false
|
|
41
|
+
|
|
42
|
+
/** @type {Map<number, { payload: unknown, properties: comq.amqp.Properties, size: number }>} */
|
|
32
43
|
#queue = new Map()
|
|
33
44
|
|
|
34
45
|
/**
|
|
@@ -42,6 +53,7 @@ class ReplyStream extends Readable {
|
|
|
42
53
|
this.#correlationId = request.properties.correlationId
|
|
43
54
|
this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
|
|
44
55
|
this.#maxBufferSize = global['COMQ_TESTING_MAX_BUFFER_SIZE'] || MAX_BUFFER_SIZE
|
|
56
|
+
this.#maxBufferBytes = global['COMQ_TESTING_MAX_BUFFER_BYTES'] || MAX_BUFFER_BYTES
|
|
45
57
|
this.#reply = reply
|
|
46
58
|
|
|
47
59
|
this.confirmation.catch(noop) // it is not awaited until the stream is handed over
|
|
@@ -65,15 +77,25 @@ class ReplyStream extends Readable {
|
|
|
65
77
|
super._destroy(error, callback)
|
|
66
78
|
}
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Called once the consumer has room again.
|
|
82
|
+
*/
|
|
83
|
+
_read (_) {
|
|
84
|
+
if (!this.#throttled) return
|
|
85
|
+
|
|
86
|
+
this.#throttled = false
|
|
87
|
+
|
|
88
|
+
void this.#reply(this.#control, control.resume)
|
|
89
|
+
}
|
|
69
90
|
|
|
70
91
|
/**
|
|
71
92
|
* @param {unknown} payload
|
|
72
93
|
* @param {comq.amqp.Properties} properties
|
|
94
|
+
* @param {number} [size] of the encoded payload
|
|
73
95
|
*/
|
|
74
|
-
arrange (payload, properties) {
|
|
96
|
+
arrange (payload, properties, size = 0) {
|
|
75
97
|
if (properties.headers.index !== this.#index) {
|
|
76
|
-
this._buffer(payload, properties)
|
|
98
|
+
this._buffer(payload, properties, size)
|
|
77
99
|
|
|
78
100
|
return
|
|
79
101
|
}
|
|
@@ -85,6 +107,7 @@ class ReplyStream extends Readable {
|
|
|
85
107
|
|
|
86
108
|
while ((message = this.#queue.get(this.#index))) {
|
|
87
109
|
this.#queue.delete(this.#index)
|
|
110
|
+
this.#bufferedBytes -= message.size
|
|
88
111
|
this._add(message.payload, message.properties)
|
|
89
112
|
}
|
|
90
113
|
|
|
@@ -104,18 +127,45 @@ class ReplyStream extends Readable {
|
|
|
104
127
|
if (properties.type === 'control')
|
|
105
128
|
this._control(payload, properties)
|
|
106
129
|
else if (!this.push(payload))
|
|
107
|
-
this.
|
|
130
|
+
this._throttle()
|
|
108
131
|
}
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Values arriving out of order are held until the gap is filled. The hold is
|
|
135
|
+
* bounded by their number and, roughly, by their size: the size is that of
|
|
136
|
+
* the encoded message, which is what is known of a decoded value.
|
|
137
|
+
*
|
|
138
|
+
* @param {unknown} payload
|
|
139
|
+
* @param {comq.amqp.Properties} properties
|
|
140
|
+
* @param {number} size
|
|
141
|
+
* @private
|
|
142
|
+
*/
|
|
143
|
+
_buffer (payload, properties, size) {
|
|
144
|
+
if (this.#buffered > this.#maxBufferSize || this.#bufferedBytes + size > this.#maxBufferBytes) {
|
|
112
145
|
this.destroy()
|
|
113
146
|
|
|
114
147
|
return
|
|
115
148
|
}
|
|
116
149
|
|
|
117
150
|
this.#buffered++
|
|
118
|
-
this.#
|
|
151
|
+
this.#bufferedBytes += size
|
|
152
|
+
this.#queue.set(properties.headers.index, { payload, properties, size })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The consumer is behind. Values in flight keep coming, and a producer that
|
|
157
|
+
* knows nothing of `pause` keeps going, in which case they pile up here rather
|
|
158
|
+
* than get lost: dropping the listener would leave the stream waiting for a
|
|
159
|
+
* value that has already passed.
|
|
160
|
+
*
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
_throttle () {
|
|
164
|
+
if (this.#throttled || !this.#flow) return
|
|
165
|
+
|
|
166
|
+
this.#throttled = true
|
|
167
|
+
|
|
168
|
+
void this.#reply(this.#control, control.pause)
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
/**
|
|
@@ -127,6 +177,7 @@ class ReplyStream extends Readable {
|
|
|
127
177
|
switch (message) {
|
|
128
178
|
case control.ok:
|
|
129
179
|
this.#control = { properties }
|
|
180
|
+
this.#flow = properties.headers?.[FLOW_HEADER] === true
|
|
130
181
|
this.confirmation.resolve()
|
|
131
182
|
break
|
|
132
183
|
case control.heartbeat:
|
|
@@ -147,12 +198,15 @@ class ReplyStream extends Readable {
|
|
|
147
198
|
|
|
148
199
|
_clear () {
|
|
149
200
|
clearTimeout(this.#timeout)
|
|
150
|
-
this.#emitter.
|
|
201
|
+
this.#emitter.off(this.#correlationId)
|
|
151
202
|
}
|
|
152
203
|
}
|
|
153
204
|
|
|
154
205
|
const MAX_BUFFER_SIZE = 1000
|
|
155
206
|
|
|
207
|
+
/** @type {number} */
|
|
208
|
+
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
|
209
|
+
|
|
156
210
|
const UNCONFIRMED = 'Reply stream has been destroyed before confirmation'
|
|
157
211
|
|
|
158
212
|
function noop () {}
|
package/source/.io/const.js
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
exports.control = {
|
|
4
4
|
ok: 'ok',
|
|
5
5
|
heartbeat: 'heartbeat',
|
|
6
|
-
end: 'end'
|
|
6
|
+
end: 'end',
|
|
7
|
+
pause: 'pause',
|
|
8
|
+
resume: 'resume'
|
|
7
9
|
}
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Set on the confirmation message by a producer that honours `pause` and
|
|
13
|
+
* `resume`, so that a consumer never sends them to one that does not.
|
|
14
|
+
*/
|
|
15
|
+
exports.FLOW_HEADER = 'x-flow'
|
|
16
|
+
|
|
9
17
|
exports.HEARTBEAT_INTERVAL = 5_000
|
|
10
18
|
exports.IDLE_INTERVAL = 12_000
|
|
@@ -1,22 +1,97 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { randomBytes } = require('node:crypto')
|
|
4
|
-
const { EventEmitter } = require('node:events')
|
|
5
4
|
const { concat } = require('./concat')
|
|
6
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Routes the replies arriving on a queue to whoever is waiting for them.
|
|
8
|
+
*
|
|
9
|
+
* A Map keyed by correlation identifier rather than an EventEmitter: with
|
|
10
|
+
* thousands of requests in flight the emitter's event table degrades into a
|
|
11
|
+
* dictionary, and every `once` costs a wrapper.
|
|
12
|
+
*
|
|
13
|
+
* @implements {comq.ReplyEmitter}
|
|
14
|
+
*/
|
|
15
|
+
class ReplyEmitter {
|
|
16
|
+
/** @type {string} */
|
|
17
|
+
queue
|
|
18
|
+
|
|
19
|
+
/** @type {string} */
|
|
20
|
+
#prefix
|
|
21
|
+
|
|
22
|
+
/** @type {number} */
|
|
23
|
+
#sequence = 0
|
|
24
|
+
|
|
25
|
+
/** @type {Map<string, comq.ReplyHandler>} */
|
|
26
|
+
#handlers = new Map()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} label
|
|
30
|
+
*/
|
|
31
|
+
constructor (label) {
|
|
32
|
+
const id = randomBytes(8).toString('hex')
|
|
33
|
+
|
|
34
|
+
this.queue = concat(label, id)
|
|
35
|
+
this.#prefix = id
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A correlation identifier that is unique across processes as well: the
|
|
40
|
+
* control queue of a producer sees the requests of every consumer, and tells
|
|
41
|
+
* them apart by nothing else. Random bytes per request would cost more than
|
|
42
|
+
* everything else on the way to the broker put together.
|
|
43
|
+
*
|
|
44
|
+
* @return {string}
|
|
45
|
+
*/
|
|
46
|
+
next () {
|
|
47
|
+
return this.#prefix + (++this.#sequence).toString(36)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} id
|
|
52
|
+
* @param {comq.ReplyHandler} handler
|
|
53
|
+
*/
|
|
54
|
+
on (id, handler) {
|
|
55
|
+
this.#handlers.set(id, handler)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} id
|
|
60
|
+
* @param {comq.ReplyHandler} [handler] the one to remove, or whichever is there
|
|
61
|
+
*/
|
|
62
|
+
off (id, handler) {
|
|
63
|
+
if (handler === undefined || this.#handlers.get(id) === handler) this.#handlers.delete(id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} id
|
|
68
|
+
* @param {any} payload
|
|
69
|
+
* @param {comq.amqp.Properties} properties
|
|
70
|
+
* @param {number} [size] of the encoded payload
|
|
71
|
+
* @return {boolean} whether anyone was waiting
|
|
72
|
+
*/
|
|
73
|
+
emit (id, payload, properties, size) {
|
|
74
|
+
const handler = this.#handlers.get(id)
|
|
75
|
+
|
|
76
|
+
if (handler === undefined) return false
|
|
77
|
+
|
|
78
|
+
handler(payload, properties, size)
|
|
79
|
+
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How many replies are being waited for. */
|
|
84
|
+
get pending () {
|
|
85
|
+
return this.#handlers.size
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
7
89
|
/**
|
|
8
90
|
* @param {string} label
|
|
9
91
|
* @returns {comq.ReplyEmitter}
|
|
10
92
|
*/
|
|
11
93
|
function createReplyEmitter (label) {
|
|
12
|
-
|
|
13
|
-
const queue = concat(label, id)
|
|
14
|
-
const emitter = new EventEmitter()
|
|
15
|
-
|
|
16
|
-
emitter.setMaxListeners(0)
|
|
17
|
-
emitter.queue = queue
|
|
18
|
-
|
|
19
|
-
return /** @type {comq.ReplyEmitter} */ emitter
|
|
94
|
+
return new ReplyEmitter(label)
|
|
20
95
|
}
|
|
21
96
|
|
|
22
97
|
exports.createReplyEmitter = createReplyEmitter
|