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.
@@ -31,8 +31,8 @@ jobs:
31
31
  with:
32
32
  fetch-depth: 0
33
33
 
34
- # - name: Run tests
35
- # run: npm i && npm test
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
@@ -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
@@ -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
- ({ a, b }) => { return a + b }
10
- """
8
+ """
9
+ ({ a, b }) => { return a + b }
10
+ """
11
11
  When the consumer sends the following request to the `add_numbers` queue:
12
- """yaml
13
- a: 1
14
- b: 2
15
- """
12
+ """yaml
13
+ a: 1
14
+ b: 2
15
+ """
16
16
  Then the consumer receives the reply:
17
- """yaml
18
- 3
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 + 12)
235
+ await timeout(100)
236
236
 
237
- const callsAmountExpected = [10, 11]
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()
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "useWorkspaces": true,
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.8.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
- 1. [Dynamic topology](#topology)
9
- 2. [Request](#request)-[reply](#reply) (RPC)
10
- 3. Events ([pub](#emission)/[sub](#consumption))
11
- 4. [Content encoding](#encoding)
12
- 5. [Flow control](#flow-control) and back pressure handling
13
- 6. [Consumer acknowledgments](#messages) and [publisher confirms](#channels)
14
- 7. [Poison message handling](#messages)
15
- 8. [Connection tolerance](#connection-tolerance) and broker restart resilience
16
- 9. [Sharded connection](#sharded-connection)
17
- 10. [Graceful shutdown](#graceful-shutdown)
18
-
19
- > CommonJS, ECMAScript and TypeScript compatible (types included).
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. The shard number corresponds to the number of the argument
368
- used in the `connect` function call.
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
@@ -4,4 +4,4 @@
4
4
  exports.connection = ['open', 'close']
5
5
 
6
6
  /** @type {comq.diagnostics.event[]} */
7
- exports.channel = ['flow', 'drain', 'recover', 'discard']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume']
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<void>}
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) { // 2 arguments passed
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
@@ -120,7 +120,7 @@ class Channel {
120
120
  */
121
121
  #add (channel) {
122
122
  this.#channels.add(channel)
123
- this.#pool = Array.from(this.#channels)
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.#pool = Array.from(this.#channels)
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
  */
@@ -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
  */
@@ -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)
@@ -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 rejects and will be removed from the pool
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 () => {
@@ -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