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/source/io.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const stream = require('node:stream')
|
|
4
|
-
const { randomBytes } = require('node:crypto')
|
|
5
4
|
const { setTimeout } = require('node:timers/promises')
|
|
6
5
|
const { Promex } = require('promex')
|
|
7
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
@@ -38,6 +37,9 @@ class IO {
|
|
|
38
37
|
/** @type {Map<Promex, comq.Request>} */
|
|
39
38
|
#pendingReplies = new Map()
|
|
40
39
|
|
|
40
|
+
/** @type {[comq.diagnostics.Event, Function][]} */
|
|
41
|
+
#forwarders = []
|
|
42
|
+
|
|
41
43
|
/** @type {Set<comq.Destroyable>} */
|
|
42
44
|
#replyStreams = new Set()
|
|
43
45
|
|
|
@@ -53,7 +55,10 @@ class IO {
|
|
|
53
55
|
this.#connection = connection
|
|
54
56
|
|
|
55
57
|
for (const event of events.connection) {
|
|
56
|
-
|
|
58
|
+
const forwarder = (...args) => this.#diagnostics.emit(event, ...args)
|
|
59
|
+
|
|
60
|
+
this.#connection.diagnose(event, forwarder)
|
|
61
|
+
this.#forwarders.push([event, forwarder])
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -87,10 +92,11 @@ class IO {
|
|
|
87
92
|
)
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
const
|
|
95
|
+
const [buffer, contentType] = this.#encode(payload, encoding)
|
|
96
|
+
const request = this.#createRequest(queue, contentType)
|
|
91
97
|
const reply = this.#createReply(request)
|
|
92
98
|
|
|
93
|
-
await this.#requests.send(queue,
|
|
99
|
+
await this.#requests.send(queue, buffer, request.properties)
|
|
94
100
|
|
|
95
101
|
return reply
|
|
96
102
|
}))
|
|
@@ -126,20 +132,46 @@ class IO {
|
|
|
126
132
|
)
|
|
127
133
|
}
|
|
128
134
|
|
|
129
|
-
|
|
130
|
-
const properties = {}
|
|
135
|
+
const [buffer, properties] = this.#properties(payload, encoding)
|
|
131
136
|
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
await this.#events[method](exchange, buffer, properties)
|
|
138
|
+
})
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Publishes to a routed exchange under a key, where `emit` fans out. The exchange is
|
|
142
|
+
* `direct`, so a message reaches the queues bound under that key and no others.
|
|
143
|
+
*/
|
|
144
|
+
route = lazy(this, this.#createEventChannel,
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} exchange
|
|
147
|
+
* @param {string} key
|
|
148
|
+
* @param {any} payload
|
|
149
|
+
* @param {comq.Encoding | comq.amqp.options.Publish} [encoding]
|
|
150
|
+
* @returns {Promise<void>}
|
|
151
|
+
*/
|
|
152
|
+
async (exchange, key, payload, encoding) => {
|
|
153
|
+
const [buffer, properties] = this.#properties(payload, encoding)
|
|
137
154
|
|
|
138
|
-
|
|
155
|
+
await this.#events.route(exchange, key, buffer, properties)
|
|
156
|
+
})
|
|
139
157
|
|
|
140
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Consumes a named durable queue bound to a routed exchange under a key, where `consume`
|
|
160
|
+
* takes everything published to a fanout. The queue is named rather than derived, because
|
|
161
|
+
* what is bound to it is a key rather than an exchange.
|
|
162
|
+
*/
|
|
163
|
+
subscribe = lazy(this, this.#createEventChannel,
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} exchange
|
|
166
|
+
* @param {string} queue
|
|
167
|
+
* @param {string} key
|
|
168
|
+
* @param {comq.Consumer} callback
|
|
169
|
+
* @returns {Promise<void>}
|
|
170
|
+
*/
|
|
171
|
+
async (exchange, queue, key, callback) => {
|
|
172
|
+
const consumer = this.#getEventConsumer(callback)
|
|
141
173
|
|
|
142
|
-
await this.#events
|
|
174
|
+
await this.#events.bound(exchange, queue, key, consumer)
|
|
143
175
|
})
|
|
144
176
|
|
|
145
177
|
process = lazy(this, this.#createEventChannel,
|
|
@@ -169,6 +201,11 @@ class IO {
|
|
|
169
201
|
await Promise.all([this.#requests, this.#replies, this.#events]
|
|
170
202
|
.map((channel) => channel?.close()))
|
|
171
203
|
|
|
204
|
+
// a connection that outlives its IO must not keep it as a listener
|
|
205
|
+
for (const [event, forwarder] of this.#forwarders) this.#connection.forget(event, forwarder)
|
|
206
|
+
|
|
207
|
+
this.#forwarders = []
|
|
208
|
+
|
|
172
209
|
await this.#connection.close()
|
|
173
210
|
})
|
|
174
211
|
|
|
@@ -189,6 +226,30 @@ class IO {
|
|
|
189
226
|
this.#events = await this.#createChannel('event')
|
|
190
227
|
}
|
|
191
228
|
|
|
229
|
+
/**
|
|
230
|
+
* What a publication carries, from what its caller passed as an encoding or as properties.
|
|
231
|
+
*
|
|
232
|
+
* @param {any} payload
|
|
233
|
+
* @param {comq.Encoding | comq.amqp.options.Publish} [encoding]
|
|
234
|
+
* @returns {[Buffer, comq.amqp.options.Publish]}
|
|
235
|
+
*/
|
|
236
|
+
#properties (payload, encoding) {
|
|
237
|
+
/** @type {comq.amqp.options.Publish} */
|
|
238
|
+
const properties = {}
|
|
239
|
+
|
|
240
|
+
if (typeof encoding === 'object') { // properties passed
|
|
241
|
+
Object.assign(properties, encoding)
|
|
242
|
+
|
|
243
|
+
encoding = /** @type {comq.Encoding} */ properties.contentType
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const [buffer, contentType] = this.#encode(payload, encoding)
|
|
247
|
+
|
|
248
|
+
properties.contentType = contentType
|
|
249
|
+
|
|
250
|
+
return [buffer, properties]
|
|
251
|
+
}
|
|
252
|
+
|
|
192
253
|
async #consumeReplies (queue) {
|
|
193
254
|
const emitter = io.createReplyEmitter(queue)
|
|
194
255
|
const consumer = this.#getReplyConsumer(queue, emitter)
|
|
@@ -274,7 +335,7 @@ class IO {
|
|
|
274
335
|
(message) => {
|
|
275
336
|
const payload = decode(message)
|
|
276
337
|
|
|
277
|
-
emitter.emit(message.properties.correlationId, payload, message.properties)
|
|
338
|
+
emitter.emit(message.properties.correlationId, payload, message.properties, message.content.length)
|
|
278
339
|
}
|
|
279
340
|
|
|
280
341
|
/**
|
|
@@ -289,20 +350,21 @@ class IO {
|
|
|
289
350
|
})
|
|
290
351
|
|
|
291
352
|
/**
|
|
353
|
+
* The request holds no copy of what was sent: a retransmission encodes the
|
|
354
|
+
* payload anew, and an unanswered request would otherwise keep two of it.
|
|
355
|
+
*
|
|
292
356
|
* @param {string} queue
|
|
293
|
-
* @param {
|
|
294
|
-
* @param {comq.Encoding} [encoding]
|
|
357
|
+
* @param {comq.Encoding} contentType
|
|
295
358
|
* @return {comq.Request}
|
|
296
359
|
*/
|
|
297
|
-
#createRequest (queue,
|
|
298
|
-
const [buffer, contentType] = this.#encode(payload, encoding)
|
|
360
|
+
#createRequest (queue, contentType) {
|
|
299
361
|
const emitter = this.#emitters.get(queue)
|
|
300
|
-
const correlationId =
|
|
362
|
+
const correlationId = emitter.next()
|
|
301
363
|
|
|
302
364
|
/** @type {comq.amqp.Properties} */
|
|
303
365
|
const properties = { contentType, correlationId, replyTo: emitter.queue }
|
|
304
366
|
|
|
305
|
-
return {
|
|
367
|
+
return { emitter, properties }
|
|
306
368
|
}
|
|
307
369
|
|
|
308
370
|
/**
|
|
@@ -312,7 +374,7 @@ class IO {
|
|
|
312
374
|
#createReply (request) {
|
|
313
375
|
const reply = this.#createPendingReply(request)
|
|
314
376
|
|
|
315
|
-
request.emitter.
|
|
377
|
+
request.emitter.on(request.properties.correlationId, this.#getReplyResolver(request, reply))
|
|
316
378
|
|
|
317
379
|
return reply
|
|
318
380
|
}
|
|
@@ -323,12 +385,12 @@ class IO {
|
|
|
323
385
|
*/
|
|
324
386
|
#createPendingReply (request) {
|
|
325
387
|
const reply = new Promex()
|
|
388
|
+
const settled = () => this.#pendingReplies.delete(reply)
|
|
326
389
|
|
|
327
390
|
this.#pendingReplies.set(reply, request)
|
|
328
391
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
.finally(() => this.#pendingReplies.delete(reply))
|
|
392
|
+
// one derived promise per request, and a rejection handled with it
|
|
393
|
+
reply.then(settled, settled)
|
|
332
394
|
|
|
333
395
|
return reply
|
|
334
396
|
}
|
|
@@ -338,11 +400,14 @@ class IO {
|
|
|
338
400
|
* @param reply
|
|
339
401
|
*/
|
|
340
402
|
#getReplyResolver (request, reply) {
|
|
341
|
-
return async (payload, properties) => {
|
|
403
|
+
return async (payload, properties, size) => {
|
|
342
404
|
const isStream = properties.headers?.index !== undefined
|
|
343
405
|
|
|
406
|
+
// a reply is answered once; a reply stream takes the place of this resolver
|
|
407
|
+
request.emitter.off(request.properties.correlationId)
|
|
408
|
+
|
|
344
409
|
if (isStream) {
|
|
345
|
-
const stream = this.#createReplyStream(request, payload, properties)
|
|
410
|
+
const stream = this.#createReplyStream(request, payload, properties, size)
|
|
346
411
|
|
|
347
412
|
try {
|
|
348
413
|
await stream.confirmation
|
|
@@ -358,10 +423,10 @@ class IO {
|
|
|
358
423
|
}
|
|
359
424
|
}
|
|
360
425
|
|
|
361
|
-
#createReplyStream (request, payload, properties) {
|
|
426
|
+
#createReplyStream (request, payload, properties, size) {
|
|
362
427
|
const stream = new io.ReplyStream(request, this.#reply.bind(this))
|
|
363
428
|
|
|
364
|
-
stream.arrange(payload, properties)
|
|
429
|
+
stream.arrange(payload, properties, size)
|
|
365
430
|
this.#addReplyStream(/** @type {comq.Destroyable} */ stream)
|
|
366
431
|
|
|
367
432
|
return stream
|
|
@@ -453,7 +518,7 @@ class IO {
|
|
|
453
518
|
for (const [reply, request] of this.#pendingReplies) {
|
|
454
519
|
// detaching this attempt alone leaves the listeners of the reply streams
|
|
455
520
|
// that are still flowing over the other shards in place
|
|
456
|
-
request.emitter.
|
|
521
|
+
request.emitter.off(request.properties.correlationId)
|
|
457
522
|
|
|
458
523
|
// trigger failsafe attribute
|
|
459
524
|
reply.reject(RETRANSMISSION)
|
|
@@ -484,6 +549,4 @@ const DEFAULT = 'application/json'
|
|
|
484
549
|
|
|
485
550
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
486
551
|
|
|
487
|
-
function noop () {}
|
|
488
|
-
|
|
489
552
|
exports.IO = IO
|
package/source/pipeline.js
CHANGED
|
@@ -5,12 +5,20 @@ const stream = require('node:stream/promises')
|
|
|
5
5
|
|
|
6
6
|
function pipeline (source, transform, channel) {
|
|
7
7
|
const destination = new Pipeline(transform)
|
|
8
|
+
const pause = source.pause.bind(source)
|
|
9
|
+
const resume = source.resume.bind(source)
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
channel.diagnose('pause', pause)
|
|
12
|
+
channel.diagnose('resume', resume)
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
// the source must not outlive the pipeline as a listener of the channel;
|
|
15
|
+
// a failure is delivered through the destination, the promise says nothing new
|
|
16
|
+
const detach = () => {
|
|
17
|
+
channel.forget('pause', pause)
|
|
18
|
+
channel.forget('resume', resume)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stream.pipeline(source, destination).then(detach, detach)
|
|
14
22
|
|
|
15
23
|
return destination
|
|
16
24
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Readable } = require('node:stream')
|
|
4
|
+
const { Promex } = require('promex')
|
|
5
|
+
const { ReplyPipe } = require('../source/.io/ReplyPipe')
|
|
6
|
+
const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
|
|
7
|
+
const { control, FLOW_HEADER } = require('../source/.io/const')
|
|
8
|
+
const { timeout } = require('./helpers')
|
|
9
|
+
|
|
10
|
+
/** @type {jest.Mock} */
|
|
11
|
+
let reply
|
|
12
|
+
|
|
13
|
+
/** @type {[any, comq.amqp.options.Publish][]} */
|
|
14
|
+
let sent
|
|
15
|
+
|
|
16
|
+
let channel
|
|
17
|
+
|
|
18
|
+
/** @type {comq.ReplyEmitter} */
|
|
19
|
+
let feedback
|
|
20
|
+
|
|
21
|
+
let request
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
global.COMQ_TESTING_HEARTBEAT_INTERVAL = 60_000
|
|
25
|
+
|
|
26
|
+
sent = []
|
|
27
|
+
reply = jest.fn(async (message, properties) => {
|
|
28
|
+
sent.push([message, properties])
|
|
29
|
+
|
|
30
|
+
return true
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
channel = { diagnose: jest.fn(), forget: jest.fn() }
|
|
34
|
+
feedback = createReplyEmitter('control')
|
|
35
|
+
request = { properties: { correlationId: 'test-correlation', replyTo: 'replies' } }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
delete global.COMQ_TESTING_HEARTBEAT_INTERVAL
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const messages = () => sent.map(([message]) => message)
|
|
43
|
+
|
|
44
|
+
const closing = (pipe) => new Promise((resolve) => pipe.on('close', resolve))
|
|
45
|
+
|
|
46
|
+
it('should confirm, announcing flow control', async () => {
|
|
47
|
+
const pipe = await ReplyPipe.create(request, Readable.from([]), channel, feedback, reply)
|
|
48
|
+
|
|
49
|
+
expect(sent[0][0]).toStrictEqual(control.ok)
|
|
50
|
+
expect(sent[0][1].headers).toStrictEqual({ index: 0, [FLOW_HEADER]: true })
|
|
51
|
+
|
|
52
|
+
await closing(pipe)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should transmit values in order, then end', async () => {
|
|
56
|
+
const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
|
|
57
|
+
|
|
58
|
+
await closing(pipe)
|
|
59
|
+
|
|
60
|
+
expect(messages()).toStrictEqual([control.ok, 1, 2, 3, control.end])
|
|
61
|
+
expect(sent.map(([, properties]) => properties.headers.index)).toStrictEqual([0, 1, 2, 3, 4])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// a source listened to runs at its own pace, and its output piles up in front of a paused channel
|
|
65
|
+
it('should pull the source no faster than the channel takes', async () => {
|
|
66
|
+
const gate = new Promex()
|
|
67
|
+
|
|
68
|
+
let pulled = 0
|
|
69
|
+
|
|
70
|
+
reply.mockImplementation(async (message) => {
|
|
71
|
+
if (message !== control.ok) await gate
|
|
72
|
+
|
|
73
|
+
return true
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function * source () {
|
|
77
|
+
for (let i = 0; i < 100; i++) {
|
|
78
|
+
pulled++
|
|
79
|
+
|
|
80
|
+
yield i
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pipe = await ReplyPipe.create(request, Readable.from(source()), channel, feedback, reply)
|
|
85
|
+
|
|
86
|
+
await timeout(10)
|
|
87
|
+
|
|
88
|
+
// a Readable reads ahead by no more than its high water mark
|
|
89
|
+
expect(pulled).toBeLessThanOrEqual(20)
|
|
90
|
+
|
|
91
|
+
gate.resolve()
|
|
92
|
+
|
|
93
|
+
await closing(pipe)
|
|
94
|
+
|
|
95
|
+
expect(pulled).toStrictEqual(100)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should hold the source while the consumer has asked for a pause', async () => {
|
|
99
|
+
const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
|
|
100
|
+
|
|
101
|
+
feedback.emit(request.properties.correlationId, control.pause, {})
|
|
102
|
+
|
|
103
|
+
await timeout(10)
|
|
104
|
+
|
|
105
|
+
expect(messages()).toStrictEqual([control.ok])
|
|
106
|
+
|
|
107
|
+
feedback.emit(request.properties.correlationId, control.resume, {})
|
|
108
|
+
|
|
109
|
+
await closing(pipe)
|
|
110
|
+
|
|
111
|
+
expect(messages()).toStrictEqual([control.ok, 1, 2, 3, control.end])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should stop once the consumer has ended the stream', async () => {
|
|
115
|
+
const gate = new Promex()
|
|
116
|
+
|
|
117
|
+
reply.mockImplementation(async (message, properties) => {
|
|
118
|
+
sent.push([message, properties])
|
|
119
|
+
|
|
120
|
+
if (message !== control.ok) await gate
|
|
121
|
+
|
|
122
|
+
return true
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
|
|
126
|
+
const closed = closing(pipe)
|
|
127
|
+
|
|
128
|
+
feedback.emit(request.properties.correlationId, control.end, {})
|
|
129
|
+
|
|
130
|
+
await closed
|
|
131
|
+
|
|
132
|
+
gate.resolve()
|
|
133
|
+
|
|
134
|
+
await timeout(10)
|
|
135
|
+
|
|
136
|
+
expect(messages()).not.toContain(control.end)
|
|
137
|
+
expect(messages()).not.toContain(3)
|
|
138
|
+
expect(channel.forget).toHaveBeenCalledWith('return', expect.any(Function))
|
|
139
|
+
expect(feedback.pending).toStrictEqual(0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should not heartbeat once the confirmation could not be delivered', async () => {
|
|
143
|
+
global.COMQ_TESTING_HEARTBEAT_INTERVAL = 5
|
|
144
|
+
|
|
145
|
+
reply.mockImplementation(async (message, properties) => {
|
|
146
|
+
sent.push([message, properties])
|
|
147
|
+
|
|
148
|
+
return false
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const pipe = new ReplyPipe(request, Readable.from([1]), channel, feedback, reply)
|
|
152
|
+
|
|
153
|
+
await pipe.pipe()
|
|
154
|
+
await timeout(30)
|
|
155
|
+
|
|
156
|
+
expect(messages()).toStrictEqual([control.ok])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should heartbeat while idle', async () => {
|
|
160
|
+
global.COMQ_TESTING_HEARTBEAT_INTERVAL = 5
|
|
161
|
+
|
|
162
|
+
const source = new Readable({ objectMode: true, read () {} })
|
|
163
|
+
const pipe = await ReplyPipe.create(request, source, channel, feedback, reply)
|
|
164
|
+
|
|
165
|
+
await timeout(30)
|
|
166
|
+
|
|
167
|
+
expect(messages()).toContain(control.heartbeat)
|
|
168
|
+
|
|
169
|
+
pipe.destroy()
|
|
170
|
+
})
|
package/test/ReplyStream.test.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { EventEmitter } = require('node:events')
|
|
4
3
|
const { once } = require('node:events')
|
|
5
4
|
const { ReplyStream, UNCONFIRMED } = require('../source/.io/ReplyStream')
|
|
6
|
-
const {
|
|
5
|
+
const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
|
|
6
|
+
const { control, FLOW_HEADER } = require('../source/.io/const')
|
|
7
7
|
|
|
8
8
|
/** @type {jest.Mock} */
|
|
9
9
|
let reply
|
|
@@ -18,7 +18,7 @@ beforeEach(() => {
|
|
|
18
18
|
reply = jest.fn().mockResolvedValue(true)
|
|
19
19
|
|
|
20
20
|
const request = {
|
|
21
|
-
emitter:
|
|
21
|
+
emitter: createReplyEmitter('test'),
|
|
22
22
|
properties: { correlationId: 'test-correlation' }
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -63,7 +63,7 @@ it('should reject confirmation when nothing arrives within the idle interval', a
|
|
|
63
63
|
global.COMQ_TESTING_IDLE_INTERVAL = 10
|
|
64
64
|
|
|
65
65
|
const request = {
|
|
66
|
-
emitter:
|
|
66
|
+
emitter: createReplyEmitter('test'),
|
|
67
67
|
properties: { correlationId: 'idle-correlation' }
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -81,3 +81,81 @@ it('should keep confirmation resolved after control.ok', async () => {
|
|
|
81
81
|
|
|
82
82
|
await expect(stream.confirmation).resolves.toBeUndefined()
|
|
83
83
|
})
|
|
84
|
+
|
|
85
|
+
describe('flow', () => {
|
|
86
|
+
const TOTAL = 40 // well beyond the 16 values a Readable holds in object mode
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {boolean} flow whether the producer honours pause and resume
|
|
90
|
+
*/
|
|
91
|
+
const ok = (flow) => ({ headers: { index: 0, [FLOW_HEADER]: flow }, type: 'control' })
|
|
92
|
+
|
|
93
|
+
const send = () => {
|
|
94
|
+
for (let index = 1; index <= TOTAL; index++) stream.arrange(index, { headers: { index } })
|
|
95
|
+
|
|
96
|
+
stream.arrange(control.end, { headers: { index: TOTAL + 1 }, type: 'control' })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// dropping the listener once behind would leave the stream waiting for values that have passed
|
|
100
|
+
it('should keep receiving while the consumer is behind', async () => {
|
|
101
|
+
stream.arrange(control.ok, ok(false))
|
|
102
|
+
send()
|
|
103
|
+
|
|
104
|
+
const received = []
|
|
105
|
+
|
|
106
|
+
for await (const value of stream) received.push(value)
|
|
107
|
+
|
|
108
|
+
expect(received).toHaveLength(TOTAL)
|
|
109
|
+
expect(received[TOTAL - 1]).toStrictEqual(TOTAL)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should not ask a producer that knows nothing of pause', async () => {
|
|
113
|
+
stream.arrange(control.ok, ok(false))
|
|
114
|
+
send()
|
|
115
|
+
|
|
116
|
+
expect(reply).not.toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should ask the producer to pause once behind, and to resume once caught up', async () => {
|
|
120
|
+
stream.arrange(control.ok, ok(true))
|
|
121
|
+
|
|
122
|
+
for (let index = 1; index <= TOTAL; index++) stream.arrange(index, { headers: { index } })
|
|
123
|
+
|
|
124
|
+
expect(reply).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(reply).toHaveBeenCalledWith({ properties: ok(true) }, control.pause)
|
|
126
|
+
|
|
127
|
+
const drained = new Promise((resolve) => stream.on('data', (value) => { if (value === TOTAL) resolve() }))
|
|
128
|
+
|
|
129
|
+
await drained
|
|
130
|
+
|
|
131
|
+
expect(reply).toHaveBeenCalledTimes(2)
|
|
132
|
+
expect(reply).toHaveBeenLastCalledWith({ properties: ok(true) }, control.resume)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should bound the values held out of order by their size', async () => {
|
|
136
|
+
global.COMQ_TESTING_MAX_BUFFER_SIZE = 1000
|
|
137
|
+
global.COMQ_TESTING_MAX_BUFFER_BYTES = 100
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const request = {
|
|
141
|
+
emitter: createReplyEmitter('test'),
|
|
142
|
+
properties: { correlationId: 'bytes-correlation' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const held = new ReplyStream(request, reply)
|
|
146
|
+
const closed = once(held, 'close')
|
|
147
|
+
|
|
148
|
+
held.arrange(1, { headers: { index: 1 } }, 60)
|
|
149
|
+
|
|
150
|
+
expect(held.destroyed).toBe(false)
|
|
151
|
+
|
|
152
|
+
held.arrange(2, { headers: { index: 2 } }, 60)
|
|
153
|
+
|
|
154
|
+
await closed
|
|
155
|
+
|
|
156
|
+
expect(held.destroyed).toBe(true)
|
|
157
|
+
} finally {
|
|
158
|
+
delete global.COMQ_TESTING_MAX_BUFFER_BYTES
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
|
|
5
|
+
const { amqplib } = require('./amqplib.mock')
|
|
6
|
+
const fixtures = require('./channel.fixtures')
|
|
7
|
+
const { create } = require('../source/channel')
|
|
8
|
+
|
|
9
|
+
/** @type {jest.MockedObject<comq.amqp.Connection>} */
|
|
10
|
+
let connection
|
|
11
|
+
|
|
12
|
+
/** @type {comq.Topology} */
|
|
13
|
+
let topology
|
|
14
|
+
|
|
15
|
+
/** @type {comq.Channel} */
|
|
16
|
+
let channel
|
|
17
|
+
|
|
18
|
+
/** @type {jest.MockedObject<comq.amqp.Channel>} */
|
|
19
|
+
let chan
|
|
20
|
+
|
|
21
|
+
const exchange = generate()
|
|
22
|
+
const queue = generate()
|
|
23
|
+
const key = generate()
|
|
24
|
+
|
|
25
|
+
const getCreatedChannel = () => {
|
|
26
|
+
const method = `create${topology.confirms ? 'Confirm' : ''}Channel`
|
|
27
|
+
|
|
28
|
+
return connection[method].mock.results[0].value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
jest.clearAllMocks()
|
|
33
|
+
|
|
34
|
+
connection = await amqplib.connect()
|
|
35
|
+
topology = fixtures.preset()
|
|
36
|
+
channel = await create(connection, topology)
|
|
37
|
+
chan = await getCreatedChannel()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('route', () => {
|
|
41
|
+
const buffer = Buffer.from(generate())
|
|
42
|
+
|
|
43
|
+
it('should assert a direct exchange', async () => {
|
|
44
|
+
await channel.route(exchange, key, buffer)
|
|
45
|
+
|
|
46
|
+
expect(chan.assertExchange).toHaveBeenCalledTimes(1)
|
|
47
|
+
|
|
48
|
+
const [name, type] = chan.assertExchange.mock.calls[0]
|
|
49
|
+
|
|
50
|
+
expect(name).toStrictEqual(exchange)
|
|
51
|
+
expect(type).toStrictEqual('direct')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should publish with the key as the routing key', async () => {
|
|
55
|
+
await channel.route(exchange, key, buffer)
|
|
56
|
+
|
|
57
|
+
const [name, routingKey, published] = chan.publish.mock.calls[0]
|
|
58
|
+
|
|
59
|
+
expect(name).toStrictEqual(exchange)
|
|
60
|
+
expect(routingKey).toStrictEqual(key)
|
|
61
|
+
expect(published).toStrictEqual(buffer)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should assert the exchange once for many keys', async () => {
|
|
65
|
+
await channel.route(exchange, key, buffer)
|
|
66
|
+
await channel.route(exchange, generate(), buffer)
|
|
67
|
+
|
|
68
|
+
expect(chan.assertExchange).toHaveBeenCalledTimes(1)
|
|
69
|
+
expect(chan.publish).toHaveBeenCalledTimes(2)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('bound', () => {
|
|
74
|
+
const consumer = /** @type {comq.channels.Consumer} */ jest.fn(() => undefined)
|
|
75
|
+
|
|
76
|
+
it('should assert a direct exchange', async () => {
|
|
77
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
78
|
+
|
|
79
|
+
const [name, type] = chan.assertExchange.mock.calls[0]
|
|
80
|
+
|
|
81
|
+
expect(name).toStrictEqual(exchange)
|
|
82
|
+
expect(type).toStrictEqual('direct')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should assert the queue by name', async () => {
|
|
86
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
87
|
+
|
|
88
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.anything())
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should bind the queue under the key', async () => {
|
|
92
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
93
|
+
|
|
94
|
+
const { queue: asserted } = await chan.assertQueue.mock.results[0].value
|
|
95
|
+
|
|
96
|
+
expect(chan.bindQueue).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(chan.bindQueue).toHaveBeenCalledWith(asserted, exchange, key)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should consume the queue', async () => {
|
|
101
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
102
|
+
|
|
103
|
+
expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.anything())
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should bind once per key', async () => {
|
|
107
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
108
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
109
|
+
|
|
110
|
+
expect(chan.bindQueue).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should bind again for another key', async () => {
|
|
114
|
+
await channel.bound(exchange, queue, key, consumer)
|
|
115
|
+
await channel.bound(exchange, generate(), generate(), consumer)
|
|
116
|
+
|
|
117
|
+
expect(chan.bindQueue).toHaveBeenCalledTimes(2)
|
|
118
|
+
})
|
|
119
|
+
})
|