comq 0.8.0 → 0.9.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/workflows/release.yaml +4 -2
- package/features/events.feature +5 -0
- package/features/recovery.feature +0 -3
- package/features/rpc.feature +15 -10
- package/features/steps/.test/events.test.js +2 -2
- package/features/steps/.test/rpc.test.js +1 -1
- package/features/steps/events.js +29 -0
- package/features/steps/rpc.js +38 -0
- package/lerna.json +1 -1
- package/package.json +1 -2
- package/readme.md +49 -15
- package/source/channel.js +2 -0
- package/source/events.js +1 -1
- package/source/io.js +21 -3
- package/source/pipeline.js +43 -0
- package/source/shards/channel.js +15 -2
- package/test/channel.test.js +6 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.emit.test.js +18 -3
- package/test/io.request.test.js +41 -0
- package/test/pipeline.test.js +81 -0
- package/test/shards/channel.test.js +38 -2
- package/types/diagnostic.d.ts +1 -1
- package/types/io.d.ts +19 -7
|
@@ -31,8 +31,8 @@ jobs:
|
|
|
31
31
|
with:
|
|
32
32
|
fetch-depth: 0
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
- name: Run tests
|
|
35
|
+
run: npm i && npm test
|
|
36
36
|
|
|
37
37
|
- name: Import GPG key
|
|
38
38
|
id: gpg
|
|
@@ -65,6 +65,8 @@ jobs:
|
|
|
65
65
|
env:
|
|
66
66
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
67
67
|
run: |
|
|
68
|
+
npm pkg delete scripts.prepare # husky
|
|
69
|
+
|
|
68
70
|
if [ ${{ github.base_ref }} = main ]; then
|
|
69
71
|
npm publish
|
|
70
72
|
else
|
package/features/events.feature
CHANGED
|
@@ -19,3 +19,8 @@ Feature: Events
|
|
|
19
19
|
Given that events are exclusively consumed from the `numbers_added` exchange
|
|
20
20
|
When an event is emitted to the `numbers_added` exchange
|
|
21
21
|
Then the event is received
|
|
22
|
+
|
|
23
|
+
Scenario: Streaming requests
|
|
24
|
+
Given that events are exclusively consumed from the `numbers_added` exchange
|
|
25
|
+
When a stream of 1k events is emitted to the `numbers_added` exchange
|
|
26
|
+
Then 1k events is received
|
|
@@ -98,9 +98,6 @@ Feature: Request-Reply Topology Recovery
|
|
|
98
98
|
And `someone` receives the event
|
|
99
99
|
|
|
100
100
|
Scenario: Start consuming events while broker is down
|
|
101
|
-
|
|
102
|
-
First consume will assert queue, so error will happen while calling AMQP.consume()
|
|
103
|
-
|
|
104
101
|
Given that `first` is consuming events from the `numbers_added` exchange
|
|
105
102
|
And `second` consuming events from the `numbers_added` exchange is expected
|
|
106
103
|
When the broker has crashed
|
package/features/rpc.feature
CHANGED
|
@@ -5,18 +5,18 @@ Feature: Request-reply (RPC)
|
|
|
5
5
|
|
|
6
6
|
Scenario: Sending request and getting reply
|
|
7
7
|
Given function replying `add_numbers` queue:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
"""
|
|
9
|
+
({ a, b }) => { return a + b }
|
|
10
|
+
"""
|
|
11
11
|
When the consumer sends the following request to the `add_numbers` queue:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
"""yaml
|
|
13
|
+
a: 1
|
|
14
|
+
b: 2
|
|
15
|
+
"""
|
|
16
16
|
Then the consumer receives the reply:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
"""yaml
|
|
18
|
+
3
|
|
19
|
+
"""
|
|
20
20
|
|
|
21
21
|
Scenario: Request causing an exception is discarded
|
|
22
22
|
Given function replying `throw_error` queue:
|
|
@@ -26,3 +26,8 @@ Feature: Request-reply (RPC)
|
|
|
26
26
|
When the consumer sends a request to the `throw_error` queue
|
|
27
27
|
Then the message is discarded
|
|
28
28
|
And the consumer does not receive the reply
|
|
29
|
+
|
|
30
|
+
Scenario: Streaming requests
|
|
31
|
+
Given a producer replying `ping` queue
|
|
32
|
+
When the consumer sends 1k requests to the `ping` queue as a stream
|
|
33
|
+
Then the consumer receives 1k replies as a stream
|
|
@@ -232,9 +232,9 @@ describe('Given I\'m publishing {quantity}B events to the {token} exchange at {q
|
|
|
232
232
|
|
|
233
233
|
step.call(context, bytesQ, exchange, frequencyQ)
|
|
234
234
|
|
|
235
|
-
await timeout(100
|
|
235
|
+
await timeout(100)
|
|
236
236
|
|
|
237
|
-
const callsAmountExpected = [
|
|
237
|
+
const callsAmountExpected = [9, 10]
|
|
238
238
|
|
|
239
239
|
expect(callsAmountExpected).toContain(context.io.emit.mock.calls.length)
|
|
240
240
|
expect(context.publishing).toBeDefined()
|
|
@@ -221,7 +221,7 @@ describe('Given I\'m sending {quantity}B requests to the {token} queue at {quant
|
|
|
221
221
|
|
|
222
222
|
await timeout(100 + 12)
|
|
223
223
|
|
|
224
|
-
const callsAmountExpected = [10, 11]
|
|
224
|
+
const callsAmountExpected = [9, 10, 11, 12]
|
|
225
225
|
|
|
226
226
|
expect(callsAmountExpected).toContain(context.io.request.mock.calls.length)
|
|
227
227
|
expect(context.sending).toBeDefined()
|
package/features/steps/events.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const stream = require('node:stream')
|
|
3
4
|
const assert = require('node:assert')
|
|
4
5
|
const { randomBytes } = require('node:crypto')
|
|
5
6
|
const { timeout, quantity, match } = require('@toa.io/generic')
|
|
@@ -61,6 +62,23 @@ When('an event is emitted to the {token} exchange with properties:',
|
|
|
61
62
|
await emit.call(this, exchange, message, properties)
|
|
62
63
|
})
|
|
63
64
|
|
|
65
|
+
When('a stream of {quantity} events is emitted to the {token} exchange',
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} exchange
|
|
68
|
+
* @this {comq.features.Context}
|
|
69
|
+
*/
|
|
70
|
+
async function (amountQ, exchange) {
|
|
71
|
+
const amount = quantity(amountQ)
|
|
72
|
+
|
|
73
|
+
function * generate () {
|
|
74
|
+
for (let i = 0; i < amount; i++) yield randomBytes(8)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const events = stream.Readable.from(generate())
|
|
78
|
+
|
|
79
|
+
await emit.call(this, exchange, events)
|
|
80
|
+
})
|
|
81
|
+
|
|
64
82
|
Then('{token} receives the event',
|
|
65
83
|
/**
|
|
66
84
|
* @param {string} group
|
|
@@ -93,6 +111,17 @@ Then('the event is received',
|
|
|
93
111
|
await consumed.call(this)
|
|
94
112
|
})
|
|
95
113
|
|
|
114
|
+
Then('{quantity} events is received',
|
|
115
|
+
/**
|
|
116
|
+
* @this {comq.features.Context}
|
|
117
|
+
*/
|
|
118
|
+
async function (expectedQ) {
|
|
119
|
+
const expected = quantity(expectedQ)
|
|
120
|
+
|
|
121
|
+
await timeout(100) // let it consume
|
|
122
|
+
assert.equal(this.eventsConsumedCount, expected, 'Not all events have been consumed')
|
|
123
|
+
})
|
|
124
|
+
|
|
96
125
|
Given('I\'m publishing {quantity}B events to the {token} exchange at {quantity}Hz',
|
|
97
126
|
/**
|
|
98
127
|
* @param {string} bytesQ
|
package/features/steps/rpc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const stream = require('node:stream')
|
|
3
4
|
const assert = require('node:assert')
|
|
4
5
|
const { randomBytes } = require('node:crypto')
|
|
5
6
|
const { parse } = require('@toa.io/yaml')
|
|
@@ -106,6 +107,43 @@ Then('the consumer does not receive the reply',
|
|
|
106
107
|
assert.equal(reply, undefined, 'The reply was received')
|
|
107
108
|
})
|
|
108
109
|
|
|
110
|
+
When('the consumer sends {quantity} requests to the {token} queue as a stream',
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} queue
|
|
113
|
+
* @this {comq.features.Context}
|
|
114
|
+
*/
|
|
115
|
+
async function (amountQ, queue) {
|
|
116
|
+
const amount = quantity(amountQ)
|
|
117
|
+
|
|
118
|
+
function * generate () {
|
|
119
|
+
for (let i = 0; i < amount; i++) {
|
|
120
|
+
yield randomBytes(8)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const input = stream.Readable.from(generate())
|
|
125
|
+
|
|
126
|
+
await send.call(this, queue, input)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
Then('the consumer receives {quantity} replies as a stream',
|
|
130
|
+
/**
|
|
131
|
+
* @this {comq.features.Context}
|
|
132
|
+
*/
|
|
133
|
+
async function (amountQ) {
|
|
134
|
+
const expected = quantity(amountQ)
|
|
135
|
+
const output = await this.reply
|
|
136
|
+
|
|
137
|
+
let actual = 0
|
|
138
|
+
|
|
139
|
+
// eslint-disable-next-line no-unused-vars
|
|
140
|
+
for await (const _ of output) {
|
|
141
|
+
actual++
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
assert.equal(actual, expected, 'The amount of replies mismatches')
|
|
145
|
+
})
|
|
146
|
+
|
|
109
147
|
Given('I\'m sending {quantity}B requests to the {token} queue at {quantity}Hz',
|
|
110
148
|
/**
|
|
111
149
|
* @param {string} bytesQ
|
package/lerna.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"msgpackr": "^1.9.5"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
-
"prepare": "husky install",
|
|
49
48
|
"test": "standard && jest",
|
|
50
49
|
"features": "cucumber-js",
|
|
51
50
|
"lint": "standard --fix --verbose | snazzy"
|
package/readme.md
CHANGED
|
@@ -5,18 +5,20 @@ Node.js.
|
|
|
5
5
|
|
|
6
6
|
## Features
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
8
|
+
- [Dynamic topology](#topology)
|
|
9
|
+
- [Request](#request)-[reply](#reply) (RPC)
|
|
10
|
+
- Events ([pub](#emission)/[sub](#consumption))
|
|
11
|
+
- [Content encoding](#encoding)
|
|
12
|
+
- [Flow control](#flow-control) and back pressure handling
|
|
13
|
+
- [Pipelines](#pipelines)
|
|
14
|
+
- [Consumer acknowledgments](#messages) and [publisher confirms](#channels)
|
|
15
|
+
- [Poison message handling](#messages)
|
|
16
|
+
- [Connection tolerance](#connection-tolerance) and broker restart resilience
|
|
17
|
+
- [Sharded connection](#sharded-connection)
|
|
18
|
+
- [Singleton connection](#singleton-connection)
|
|
19
|
+
- [Graceful shutdown](#graceful-shutdown)
|
|
20
|
+
|
|
21
|
+
> CommonJS, ECMAScript, and TypeScript compatible (types included).
|
|
20
22
|
|
|
21
23
|
## TL;DR
|
|
22
24
|
|
|
@@ -180,6 +182,34 @@ underlying broker connection is lost, any current and future outgoing messages w
|
|
|
180
182
|
Corresponding returned promises will remain in a `pending` state until the pressure is removed or
|
|
181
183
|
the connection is restored.
|
|
182
184
|
|
|
185
|
+
### Pipelines
|
|
186
|
+
|
|
187
|
+
Payloads for Requests and Events can be passed as a readable stream
|
|
188
|
+
in [object mode](https://nodejs.org/api/stream.html#object-mode), enabling the handling of large amounts of data with
|
|
189
|
+
the benefits of RabbitMQ back pressure and flow control.
|
|
190
|
+
|
|
191
|
+
`async IO.request(queue: string, payload: Readable, [encoding: string]): Readable`
|
|
192
|
+
|
|
193
|
+
Returns a readable stream of replies.
|
|
194
|
+
|
|
195
|
+
`async IO.emit(exchange: string, payload: Readable, [encoding: string]): void`
|
|
196
|
+
|
|
197
|
+
```javascript
|
|
198
|
+
function * generate () {
|
|
199
|
+
yield { a: 1, b: 2 };
|
|
200
|
+
yield { a: 3, b: 4 };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const events = Readable.from(generate())
|
|
204
|
+
|
|
205
|
+
await io.emit('numbers_added', events)
|
|
206
|
+
|
|
207
|
+
const requests = Readable.from(generate())
|
|
208
|
+
|
|
209
|
+
for await (const reply of io.request('add_numbers', requests))
|
|
210
|
+
console.log(reply)
|
|
211
|
+
```
|
|
212
|
+
|
|
183
213
|
## Connection tolerance
|
|
184
214
|
|
|
185
215
|
When the established connection is lost, it will be automatically restored.
|
|
@@ -362,12 +392,16 @@ Subscribe to one of the diagnostic events:
|
|
|
362
392
|
exceptions. Channel type,
|
|
363
393
|
raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
|
|
364
394
|
and the exception are passed as arguments.
|
|
395
|
+
- `pause`: channel is paused. Channel type is passed.
|
|
396
|
+
- `resume`: channel is resumed. Channel type is passed.
|
|
365
397
|
|
|
366
398
|
In the case of a [sharded connection](#sharded-connection), an additional argument specifying the
|
|
367
|
-
shard number will be passed to listeners.
|
|
368
|
-
|
|
399
|
+
shard number will be passed to listeners.
|
|
400
|
+
This is applicable except for the `pause` and `resume` events,
|
|
401
|
+
which are emitted when the associated channels are paused or resumed across all shards.
|
|
402
|
+
The shard number corresponds to the position of the argument used in the `connect` function call.
|
|
369
403
|
|
|
370
|
-
[^3]: As [`connect`](#connect) function returns an instance of `IO` *after* the connection has been
|
|
404
|
+
[^3]: As the [`connect`](#connect) function returns an instance of `IO` *after* the connection has been
|
|
371
405
|
established, there is no way to capture the initial `open` event.
|
|
372
406
|
|
|
373
407
|
### Example
|
package/source/channel.js
CHANGED
|
@@ -281,6 +281,7 @@ class Channel {
|
|
|
281
281
|
this.#paused = promex()
|
|
282
282
|
this.#channel.once('drain', this.#unpause)
|
|
283
283
|
this.#diagnostics.emit('flow')
|
|
284
|
+
this.#diagnostics.emit('pause')
|
|
284
285
|
}
|
|
285
286
|
|
|
286
287
|
/**
|
|
@@ -294,6 +295,7 @@ class Channel {
|
|
|
294
295
|
|
|
295
296
|
this.#paused = undefined
|
|
296
297
|
this.#diagnostics.emit('drain')
|
|
298
|
+
this.#diagnostics.emit('resume')
|
|
297
299
|
}
|
|
298
300
|
|
|
299
301
|
async #unpaused () {
|
package/source/events.js
CHANGED
package/source/io.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const stream = require('node:stream')
|
|
3
4
|
const { EventEmitter } = require('node:events')
|
|
4
5
|
const { randomBytes } = require('node:crypto')
|
|
5
6
|
const { lazy, track, failsafe, promex } = require('@toa.io/generic')
|
|
6
7
|
|
|
7
8
|
const { decode } = require('./decode')
|
|
8
9
|
const { encode } = require('./encode')
|
|
10
|
+
const { pipeline, transform } = require('./pipeline')
|
|
9
11
|
const events = require('./events')
|
|
10
12
|
const io = require('./.io')
|
|
11
13
|
|
|
@@ -61,12 +63,20 @@ class IO {
|
|
|
61
63
|
failsafe(this, this.#recover,
|
|
62
64
|
/**
|
|
63
65
|
* @param {string} queue
|
|
64
|
-
* @param {any} payload
|
|
66
|
+
* @param {any | Readable} payload
|
|
65
67
|
* @param {comq.encoding} [encoding]
|
|
66
68
|
* @param {comq.ReplyToPropertyFormatter} [replyToFormatter]
|
|
67
|
-
* @returns {Promise<
|
|
69
|
+
* @returns {Promise<any | Readable>}
|
|
68
70
|
*/
|
|
69
71
|
async (queue, payload, encoding, replyToFormatter) => {
|
|
72
|
+
if (payload instanceof stream.Readable) {
|
|
73
|
+
return pipeline(
|
|
74
|
+
payload,
|
|
75
|
+
(payload) => this.request(queue, payload, encoding, replyToFormatter),
|
|
76
|
+
this.#requests
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
const [buffer, contentType] = this.#encode(payload, encoding)
|
|
71
81
|
const emitter = this.#emitters.get(queue)
|
|
72
82
|
const reply = this.#createReply()
|
|
@@ -84,7 +94,7 @@ class IO {
|
|
|
84
94
|
|
|
85
95
|
consume = lazy(this, this.#createEventChannel,
|
|
86
96
|
async (exchange, group, callback) => {
|
|
87
|
-
if (callback === undefined) { //
|
|
97
|
+
if (callback === undefined) { // two arguments passed
|
|
88
98
|
callback = group
|
|
89
99
|
group = undefined
|
|
90
100
|
}
|
|
@@ -104,6 +114,14 @@ class IO {
|
|
|
104
114
|
* @returns {Promise<void>}
|
|
105
115
|
*/
|
|
106
116
|
async (exchange, payload, encoding) => {
|
|
117
|
+
if (payload instanceof stream.Readable) {
|
|
118
|
+
return transform(
|
|
119
|
+
payload,
|
|
120
|
+
(payload) => this.emit(exchange, payload, encoding),
|
|
121
|
+
this.#events
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
/** @type {comq.amqp.options.Publish} */
|
|
108
126
|
const properties = {}
|
|
109
127
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Transform } = require('node:stream')
|
|
4
|
+
const stream = require('node:stream/promises')
|
|
5
|
+
|
|
6
|
+
function pipeline (source, transform, channel) {
|
|
7
|
+
const destination = new Pipeline(transform)
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line no-void
|
|
10
|
+
void stream.pipeline(source, destination)
|
|
11
|
+
|
|
12
|
+
channel.diagnose('pause', source.pause.bind(source))
|
|
13
|
+
channel.diagnose('resume', source.resume.bind(source))
|
|
14
|
+
|
|
15
|
+
return destination
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function transform (source, transform, channel) {
|
|
19
|
+
const readable = pipeline(source, transform, channel)
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line no-void, no-unused-vars
|
|
22
|
+
for await (const _ of readable) void 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class Pipeline extends Transform {
|
|
26
|
+
#transform
|
|
27
|
+
|
|
28
|
+
constructor (transform) {
|
|
29
|
+
super({ objectMode: true })
|
|
30
|
+
|
|
31
|
+
this.#transform = transform
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_transform (request, _, callback) {
|
|
35
|
+
const promise = this.#transform(request)
|
|
36
|
+
|
|
37
|
+
this.push(promise)
|
|
38
|
+
callback()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
exports.pipeline = pipeline
|
|
43
|
+
exports.transform = transform
|
package/source/shards/channel.js
CHANGED
|
@@ -120,7 +120,7 @@ class Channel {
|
|
|
120
120
|
*/
|
|
121
121
|
#add (channel) {
|
|
122
122
|
this.#channels.add(channel)
|
|
123
|
-
this.#
|
|
123
|
+
this.#update()
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
@@ -131,10 +131,23 @@ class Channel {
|
|
|
131
131
|
|
|
132
132
|
this.#bench.set(channel, promex())
|
|
133
133
|
this.#channels.delete(channel)
|
|
134
|
-
this.#
|
|
134
|
+
this.#update()
|
|
135
135
|
this.#diagnostics.emit('remove', channel.index)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
#update () {
|
|
139
|
+
const from = this.#pool?.length
|
|
140
|
+
const to = this.#channels.size
|
|
141
|
+
|
|
142
|
+
this.#pool = Array.from(this.#channels)
|
|
143
|
+
|
|
144
|
+
if (from === undefined) return
|
|
145
|
+
|
|
146
|
+
if (from !== 0 && to === 0) { this.#diagnostics.emit('pause') }
|
|
147
|
+
|
|
148
|
+
if (from === 0 && to !== 0) { this.#diagnostics.emit('resume') }
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
/**
|
|
139
152
|
* @param {comq.Channel} channel
|
|
140
153
|
*/
|
package/test/channel.test.js
CHANGED
|
@@ -823,17 +823,23 @@ describe('diagnostics', () => {
|
|
|
823
823
|
|
|
824
824
|
let flowed = false
|
|
825
825
|
let drained = false
|
|
826
|
+
let paused = false
|
|
827
|
+
let resumed = false
|
|
826
828
|
|
|
827
829
|
channel.diagnose('flow', () => (flowed = true))
|
|
828
830
|
channel.diagnose('drain', () => (drained = true))
|
|
831
|
+
channel.diagnose('pause', () => (paused = true))
|
|
832
|
+
channel.diagnose('resume', () => (resumed = true))
|
|
829
833
|
|
|
830
834
|
await channel.publish(exchange, buffer)
|
|
831
835
|
|
|
832
836
|
expect(flowed).toStrictEqual(true)
|
|
837
|
+
expect(paused).toStrictEqual(true)
|
|
833
838
|
|
|
834
839
|
chan.emit('drain')
|
|
835
840
|
|
|
836
841
|
expect(drained).toStrictEqual(true)
|
|
842
|
+
expect(resumed).toStrictEqual(true)
|
|
837
843
|
})
|
|
838
844
|
|
|
839
845
|
it.each(/** @type {[string, boolean][]} */ [
|
|
@@ -28,7 +28,7 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
|
|
|
28
28
|
* @param {comq.topology.type} type
|
|
29
29
|
*/
|
|
30
30
|
(type) => {
|
|
31
|
-
it.each(['flow', 'drain', 'recover', 'discard'])('should re-emit %s',
|
|
31
|
+
it.each(['flow', 'drain', 'recover', 'discard', 'pause', 'resume'])('should re-emit %s',
|
|
32
32
|
/**
|
|
33
33
|
* @param {comq.diagnostics.event} event
|
|
34
34
|
*/
|
package/test/io.emit.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const stream = require('node:stream')
|
|
3
4
|
const { randomBytes } = require('node:crypto')
|
|
4
5
|
const { generate } = require('randomstring')
|
|
5
6
|
|
|
@@ -18,6 +19,9 @@ let connection
|
|
|
18
19
|
/** @type {jest.MockedObject<comq.Channel>} */
|
|
19
20
|
let events
|
|
20
21
|
|
|
22
|
+
const exchange = generate()
|
|
23
|
+
const payload = generate()
|
|
24
|
+
|
|
21
25
|
beforeEach(async () => {
|
|
22
26
|
jest.clearAllMocks()
|
|
23
27
|
|
|
@@ -33,9 +37,6 @@ it('should be', async () => {
|
|
|
33
37
|
expect(io.emit).toBeDefined()
|
|
34
38
|
})
|
|
35
39
|
|
|
36
|
-
const exchange = generate()
|
|
37
|
-
const payload = generate()
|
|
38
|
-
|
|
39
40
|
it('should create events channel', async () => {
|
|
40
41
|
expect(connection.createChannel).toHaveBeenCalledWith('event')
|
|
41
42
|
expect(events).toBeDefined()
|
|
@@ -49,6 +50,20 @@ it('should publish to an exchange', async () => {
|
|
|
49
50
|
expect(args[0]).toStrictEqual(exchange)
|
|
50
51
|
})
|
|
51
52
|
|
|
53
|
+
it('should publish stream of events', async () => {
|
|
54
|
+
jest.clearAllMocks()
|
|
55
|
+
|
|
56
|
+
function * generate () {
|
|
57
|
+
for (let i = 0; i < 10; i++) yield i
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const input = stream.Readable.from(generate())
|
|
61
|
+
|
|
62
|
+
await io.emit(exchange, input)
|
|
63
|
+
|
|
64
|
+
expect(events.publish).toHaveBeenCalledTimes(10)
|
|
65
|
+
})
|
|
66
|
+
|
|
52
67
|
it('should encode message as msgpack by default', async () => {
|
|
53
68
|
const encoding = 'application/msgpack'
|
|
54
69
|
const buf = encode(payload, encoding)
|
package/test/io.request.test.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// region setup
|
|
4
4
|
|
|
5
|
+
const stream = require('node:stream')
|
|
5
6
|
const { randomBytes } = require('node:crypto')
|
|
6
7
|
const { generate } = require('randomstring')
|
|
7
8
|
const { immediate } = require('@toa.io/generic')
|
|
@@ -160,6 +161,33 @@ describe('send', () => {
|
|
|
160
161
|
expect(properties.contentType).toStrictEqual(encoding)
|
|
161
162
|
})
|
|
162
163
|
|
|
164
|
+
it('should send stream of requests', async () => {
|
|
165
|
+
requests.send.mockClear()
|
|
166
|
+
|
|
167
|
+
function * generate () {
|
|
168
|
+
for (let i = 0; i < 10; i++) yield i
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const encoding = 'application/json'
|
|
172
|
+
const replyFormatter = (queue) => queue
|
|
173
|
+
|
|
174
|
+
setTimeout(replyAll, 10) // read the stream to end
|
|
175
|
+
|
|
176
|
+
const input = stream.Readable.from(generate())
|
|
177
|
+
|
|
178
|
+
/** @type {Readable} */
|
|
179
|
+
const output = await io.request(queue, input, encoding, replyFormatter)
|
|
180
|
+
|
|
181
|
+
expect(output).toBeInstanceOf(stream.Readable)
|
|
182
|
+
|
|
183
|
+
const replies = []
|
|
184
|
+
|
|
185
|
+
for await (const reply of output) replies.push(reply)
|
|
186
|
+
|
|
187
|
+
expect(replies.length).toStrictEqual(10)
|
|
188
|
+
expect(requests.send).toHaveBeenCalledTimes(10)
|
|
189
|
+
})
|
|
190
|
+
|
|
163
191
|
it('should resend unanswered Requests', async () => {
|
|
164
192
|
expect(requests.diagnose).toHaveBeenCalledWith('recover', expect.any(Function))
|
|
165
193
|
|
|
@@ -239,6 +267,19 @@ const reply = async (content = randomBytes(8), contentType = undefined) => {
|
|
|
239
267
|
await callback(message)
|
|
240
268
|
}
|
|
241
269
|
|
|
270
|
+
async function replyAll () {
|
|
271
|
+
const callback = replies.consume.mock.calls[0][1]
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < requests.send.mock.calls.length; i++) {
|
|
274
|
+
const call = requests.send.mock.calls[i]
|
|
275
|
+
const correlationId = call[2].correlationId
|
|
276
|
+
const properties = { correlationId }
|
|
277
|
+
const message = /** @type {comq.amqp.Message} */ { content: randomBytes(8), properties }
|
|
278
|
+
|
|
279
|
+
await callback(message)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
242
283
|
/**
|
|
243
284
|
* @param {comq.topology.type} type
|
|
244
285
|
* @returns {jest.MockedObject<comq.Channel>}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events')
|
|
4
|
+
const stream = require('node:stream')
|
|
5
|
+
const { pipeline, transform } = require('../source/pipeline')
|
|
6
|
+
const { timeout } = require('@toa.io/generic')
|
|
7
|
+
|
|
8
|
+
/** @type {Channel} */
|
|
9
|
+
let channel
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
channel = new Channel()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should pipeline', async () => {
|
|
16
|
+
const input = stream.Readable.from([1, 2, 3])
|
|
17
|
+
const pipe = pipeline(input, (x) => x * x, channel)
|
|
18
|
+
const output = []
|
|
19
|
+
|
|
20
|
+
for await (const x of pipe) {
|
|
21
|
+
output.push(x)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(output).toStrictEqual([1, 4, 9])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should control source', async () => {
|
|
28
|
+
async function * generate () {
|
|
29
|
+
for (let i = 1; i < 4; i++) {
|
|
30
|
+
await timeout(1)
|
|
31
|
+
|
|
32
|
+
yield i
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const input = stream.Readable.from(generate())
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line no-void
|
|
39
|
+
void pipeline(input, (x) => x * x, channel)
|
|
40
|
+
|
|
41
|
+
channel.emit('pause')
|
|
42
|
+
|
|
43
|
+
await timeout(0)
|
|
44
|
+
|
|
45
|
+
expect(input.isPaused()).toBe(true)
|
|
46
|
+
|
|
47
|
+
channel.emit('resume')
|
|
48
|
+
|
|
49
|
+
await timeout(0)
|
|
50
|
+
|
|
51
|
+
expect(input.isPaused()).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should wait for end', async () => {
|
|
55
|
+
async function * generate () {
|
|
56
|
+
for (let i = 1; i < 4; i++) {
|
|
57
|
+
await timeout(1)
|
|
58
|
+
|
|
59
|
+
yield i
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let finished = 0
|
|
64
|
+
|
|
65
|
+
const fn = jest.fn(async () => {
|
|
66
|
+
await timeout(1)
|
|
67
|
+
finished++
|
|
68
|
+
})
|
|
69
|
+
const input = stream.Readable.from(generate())
|
|
70
|
+
|
|
71
|
+
await transform(input, fn, channel)
|
|
72
|
+
|
|
73
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
74
|
+
expect(finished).toBe(3)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
class Channel extends EventEmitter {
|
|
78
|
+
diagnose (event, callback) {
|
|
79
|
+
this.on(event, callback)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -187,7 +187,7 @@ describe.each(/** @type {string[]} */['send', 'publish', 'fire'])('%s', (method)
|
|
|
187
187
|
|
|
188
188
|
const last = label + i
|
|
189
189
|
|
|
190
|
-
// now channel has thrown an exception
|
|
190
|
+
// now the channel has thrown an exception
|
|
191
191
|
expect(broken[method]).toHaveBeenCalledWith(last, buffer, options)
|
|
192
192
|
|
|
193
193
|
const r = (b + 1) % 2
|
|
@@ -202,7 +202,7 @@ describe.each(/** @type {string[]} */['send', 'publish', 'fire'])('%s', (method)
|
|
|
202
202
|
/** @type {jest.MockedObject<comq.Channel>} */
|
|
203
203
|
let recovered
|
|
204
204
|
|
|
205
|
-
// both shards
|
|
205
|
+
// both shards reject and will be removed from the pool
|
|
206
206
|
channels[0][method].mockImplementationOnce(reject)
|
|
207
207
|
channels[1][method].mockImplementationOnce(reject)
|
|
208
208
|
|
|
@@ -338,6 +338,42 @@ describe('diagnose', () => {
|
|
|
338
338
|
|
|
339
339
|
expect(listener).toHaveBeenCalledWith(expect.any(Number))
|
|
340
340
|
})
|
|
341
|
+
|
|
342
|
+
it('should emit `pause` and `unpause` events', async () => {
|
|
343
|
+
const queue = generate()
|
|
344
|
+
const buffer = randomBytes(8)
|
|
345
|
+
const pause = /** @type {jest.MockedFunction} */ jest.fn()
|
|
346
|
+
const resume = /** @type {jest.MockedFunction} */ jest.fn()
|
|
347
|
+
|
|
348
|
+
channel.diagnose('pause', pause)
|
|
349
|
+
channel.diagnose('resume', resume)
|
|
350
|
+
|
|
351
|
+
for (const chan of channels) {
|
|
352
|
+
chan.send.mockImplementationOnce(() => {
|
|
353
|
+
expect(pause).not.toHaveBeenCalled()
|
|
354
|
+
throw new Error()
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// recover
|
|
359
|
+
setImmediate(() => {
|
|
360
|
+
for (const chan of channels) {
|
|
361
|
+
// subscriptions to the 'recover' event
|
|
362
|
+
const calls = chan.diagnose.mock.calls.filter(
|
|
363
|
+
(call) => call[0] === 'recover')
|
|
364
|
+
|
|
365
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
366
|
+
|
|
367
|
+
// emit 'recover' event to return shard to the pool
|
|
368
|
+
for (const call of calls) call[1]()
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
await channel.send(queue, buffer)
|
|
373
|
+
|
|
374
|
+
expect(pause).toHaveBeenCalledTimes(1)
|
|
375
|
+
expect(resume).toHaveBeenCalledTimes(1)
|
|
376
|
+
})
|
|
341
377
|
})
|
|
342
378
|
|
|
343
379
|
it('should not throw on recovery (if the "bench" is empty)', async () => {
|
package/types/diagnostic.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
declare namespace comq.diagnostics {
|
|
2
2
|
|
|
3
|
-
type event = 'open' | 'close' | 'flow' | 'drain' | 'remove' | 'recover' | 'discard'
|
|
3
|
+
type event = 'open' | 'close' | 'flow' | 'drain' | 'remove' | 'recover' | 'discard' | 'pause' | 'resume'
|
|
4
4
|
|
|
5
5
|
interface Diagnosable {
|
|
6
6
|
diagnose(event: event, listener: Function): void
|
package/types/io.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream'
|
|
1
2
|
import * as _diagnostics from './diagnostic'
|
|
2
3
|
import * as _encoding from './encoding'
|
|
3
4
|
import * as _topology from './topology'
|
|
@@ -30,6 +31,13 @@ declare namespace comq {
|
|
|
30
31
|
replyToFormatter?: ReplyToPropertyFormatter)
|
|
31
32
|
: Promise<any>
|
|
32
33
|
|
|
34
|
+
request(
|
|
35
|
+
queue: string,
|
|
36
|
+
stream: Readable,
|
|
37
|
+
encoding?: _encoding.encoding,
|
|
38
|
+
replyToFormatter?: ReplyToPropertyFormatter)
|
|
39
|
+
: Promise<Readable>
|
|
40
|
+
|
|
33
41
|
consume(exchange: string, group: string, consumer: consumer): Promise<void>
|
|
34
42
|
|
|
35
43
|
consume(exchange: string, consumer: consumer): Promise<void>
|
|
@@ -38,23 +46,27 @@ declare namespace comq {
|
|
|
38
46
|
|
|
39
47
|
emit(exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
|
|
40
48
|
|
|
49
|
+
emit(exchange: string, stream: Readable, encoding?: _encoding.encoding): Promise<void>
|
|
50
|
+
|
|
51
|
+
emit(exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
|
|
52
|
+
|
|
41
53
|
seal(): Promise<void>
|
|
42
54
|
|
|
43
55
|
close(): Promise<void>
|
|
44
56
|
|
|
45
|
-
diagnose(event: 'open', listener: (index?: number) => void)
|
|
57
|
+
diagnose(event: 'open', listener: (index?: number) => void): void
|
|
46
58
|
|
|
47
|
-
diagnose(event: 'close', listener: (index?: number) => void)
|
|
59
|
+
diagnose(event: 'close', listener: (index?: number) => void): void
|
|
48
60
|
|
|
49
|
-
diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void)
|
|
61
|
+
diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
|
|
50
62
|
|
|
51
|
-
diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void)
|
|
63
|
+
diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void
|
|
52
64
|
|
|
53
|
-
diagnose(event: 'remove', listener: (index?: number) => void)
|
|
65
|
+
diagnose(event: 'remove', listener: (index?: number) => void): void
|
|
54
66
|
|
|
55
|
-
diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void)
|
|
67
|
+
diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
|
|
56
68
|
|
|
57
|
-
diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void)
|
|
69
|
+
diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
|
|
58
70
|
}
|
|
59
71
|
}
|
|
60
72
|
|