comq 0.16.0 → 0.16.2
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/connection.feature +14 -0
- package/features/silence.feature +16 -0
- package/package.json +1 -1
- package/readme.md +7 -1
- 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 +22 -7
- package/source/connection.js +37 -17
- package/source/io.js +35 -22
- 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.test.js +58 -0
- package/test/connection.mock.js +3 -1
- package/test/connection.test.js +65 -3
- package/test/io.diagnostics.test.js +11 -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/connection.d.ts +2 -0
- package/types/io.d.ts +14 -2
|
@@ -36,7 +36,24 @@ async function replay (context) {
|
|
|
36
36
|
await Promise.all(promises)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Forgets the recorded calls, and the arguments they hold on to.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} context
|
|
43
|
+
*/
|
|
44
|
+
function reset (context) {
|
|
45
|
+
const methods = context[METHODS]
|
|
46
|
+
|
|
47
|
+
if (methods === undefined) return
|
|
48
|
+
|
|
49
|
+
for (const method of methods) method[CALLS] = undefined
|
|
50
|
+
|
|
51
|
+
context[METHODS] = undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
const METHODS = Symbol('context methods')
|
|
40
55
|
const CALLS = Symbol('method calls')
|
|
41
56
|
|
|
57
|
+
recall.reset = reset
|
|
58
|
+
|
|
42
59
|
exports.recall = recall
|
|
@@ -90,3 +90,46 @@ it('should not re-call those thrown exceptions', async () => {
|
|
|
90
90
|
|
|
91
91
|
expect(method).toHaveBeenCalledTimes(3)
|
|
92
92
|
})
|
|
93
|
+
|
|
94
|
+
describe('reset', () => {
|
|
95
|
+
it('should be', async () => {
|
|
96
|
+
expect(recall.reset).toBeDefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should forget recorded calls', async () => {
|
|
100
|
+
const context = {}
|
|
101
|
+
const func = recall(context, method)
|
|
102
|
+
|
|
103
|
+
await func(generate())
|
|
104
|
+
|
|
105
|
+
recall.reset(context)
|
|
106
|
+
method.mockClear()
|
|
107
|
+
|
|
108
|
+
await recall(context)
|
|
109
|
+
|
|
110
|
+
expect(method).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should record again after reset', async () => {
|
|
114
|
+
const context = {}
|
|
115
|
+
const func = recall(context, method)
|
|
116
|
+
|
|
117
|
+
await func(generate())
|
|
118
|
+
|
|
119
|
+
recall.reset(context)
|
|
120
|
+
|
|
121
|
+
const args = [generate()]
|
|
122
|
+
|
|
123
|
+
await func(...args)
|
|
124
|
+
method.mockClear()
|
|
125
|
+
|
|
126
|
+
await recall(context)
|
|
127
|
+
|
|
128
|
+
expect(method).toHaveBeenCalledTimes(1)
|
|
129
|
+
expect(method).toHaveBeenCalledWith(...args)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should not throw on a context without records', async () => {
|
|
133
|
+
expect(() => recall.reset({})).not.toThrow()
|
|
134
|
+
})
|
|
135
|
+
})
|
package/source/channel.js
CHANGED
|
@@ -44,17 +44,22 @@ class Channel {
|
|
|
44
44
|
|
|
45
45
|
#closed = false
|
|
46
46
|
|
|
47
|
+
/** @type {(channel: comq.Channel) => void} */
|
|
48
|
+
#release
|
|
49
|
+
|
|
47
50
|
/**
|
|
48
51
|
* @param {comq.amqp.Connection} connection
|
|
49
52
|
* @param {comq.Topology} topology
|
|
50
|
-
* @param {number} index
|
|
53
|
+
* @param {number} [index]
|
|
54
|
+
* @param {(channel: comq.Channel) => void} [release] called once the channel is given back
|
|
51
55
|
*/
|
|
52
|
-
constructor (connection, topology, index) {
|
|
56
|
+
constructor (connection, topology, index, release = noop) {
|
|
53
57
|
this.index = index
|
|
54
58
|
|
|
55
59
|
this.#connection = connection
|
|
56
60
|
this.#topology = topology
|
|
57
61
|
this.#failfast = index !== undefined
|
|
62
|
+
this.#release = release
|
|
58
63
|
|
|
59
64
|
if (this.#failfast) failsafe.disable(this.send, this.publish)
|
|
60
65
|
}
|
|
@@ -63,6 +68,9 @@ class Channel {
|
|
|
63
68
|
if (this.#topology.confirms) this.#channel = await this.#connection.createConfirmChannel()
|
|
64
69
|
else this.#channel = await this.#connection.createChannel()
|
|
65
70
|
|
|
71
|
+
// the consumers of the previous channel went down with it, their tags mean nothing here
|
|
72
|
+
this.#tags = []
|
|
73
|
+
|
|
66
74
|
await this.#channel.prefetch(this.#topology.prefetch)
|
|
67
75
|
|
|
68
76
|
this.#channel.on('drain', this.#unpause)
|
|
@@ -141,6 +149,8 @@ class Channel {
|
|
|
141
149
|
|
|
142
150
|
// a channel that went down with its connection is the outcome this asks for
|
|
143
151
|
await this.#channel?.close().catch(noop)
|
|
152
|
+
|
|
153
|
+
this.#release(this)
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
async seal () {
|
|
@@ -149,6 +159,10 @@ class Channel {
|
|
|
149
159
|
const cancellations = this.#tags.map((tag) => this.#channel.cancel(tag))
|
|
150
160
|
|
|
151
161
|
await Promise.all(cancellations).catch(noop) // won't recover anyway
|
|
162
|
+
|
|
163
|
+
// a sealed channel is not going to consume again, so what it has consumed
|
|
164
|
+
// and the callbacks it was given are of no use anymore
|
|
165
|
+
recall.reset(this)
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
diagnose (event, listener) {
|
|
@@ -260,12 +274,12 @@ class Channel {
|
|
|
260
274
|
*/
|
|
261
275
|
#confirmation () {
|
|
262
276
|
const confirmation = new Promex()
|
|
277
|
+
const settled = () => this.#confirmations.delete(confirmation)
|
|
263
278
|
|
|
264
279
|
this.#confirmations.add(confirmation)
|
|
265
280
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.finally(() => this.#confirmations.delete(confirmation))
|
|
281
|
+
// one derived promise per publish, and a rejection handled with it
|
|
282
|
+
confirmation.then(settled, settled)
|
|
269
283
|
|
|
270
284
|
return confirmation
|
|
271
285
|
}
|
|
@@ -372,10 +386,11 @@ class Channel {
|
|
|
372
386
|
* @param {comq.amqp.Connection} connection
|
|
373
387
|
* @param {comq.Topology} topology
|
|
374
388
|
* @param {number} [index]
|
|
389
|
+
* @param {(channel: comq.Channel) => void} [release]
|
|
375
390
|
* @return {Promise<comq.Channel>}
|
|
376
391
|
*/
|
|
377
|
-
async function create (connection, topology, index) {
|
|
378
|
-
const channel = new Channel(connection, topology, index)
|
|
392
|
+
async function create (connection, topology, index, release) {
|
|
393
|
+
const channel = new Channel(connection, topology, index, release)
|
|
379
394
|
|
|
380
395
|
await channel.create()
|
|
381
396
|
|
package/source/connection.js
CHANGED
|
@@ -20,8 +20,8 @@ class Connection {
|
|
|
20
20
|
/** @type {comq.amqp.Connection} */
|
|
21
21
|
#connection
|
|
22
22
|
|
|
23
|
-
/** @type {comq.Channel
|
|
24
|
-
#channels =
|
|
23
|
+
/** @type {Set<comq.Channel>} */
|
|
24
|
+
#channels = new Set()
|
|
25
25
|
|
|
26
26
|
/** @type {Promex} */
|
|
27
27
|
#recovery = new Promex()
|
|
@@ -64,17 +64,10 @@ class Connection {
|
|
|
64
64
|
async open () {
|
|
65
65
|
this.#closed = false
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.#opening = retry(this.#open).finally(() => { this.#opening = null })
|
|
70
|
-
|
|
71
|
-
await this.#opening
|
|
67
|
+
await this.#reopen()
|
|
72
68
|
|
|
73
69
|
if (this.#closed) return
|
|
74
70
|
|
|
75
|
-
// close may have landed after this attempt succeeded but before `#opening` was cleared
|
|
76
|
-
if (this.#connection === undefined) return this.open()
|
|
77
|
-
|
|
78
71
|
this.#running = true
|
|
79
72
|
}
|
|
80
73
|
|
|
@@ -97,10 +90,13 @@ class Connection {
|
|
|
97
90
|
if (this.#connection === undefined) await this.#recovery
|
|
98
91
|
|
|
99
92
|
const topology = presets[type]
|
|
100
|
-
const channel = await channels.create(this.#connection, topology, index)
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
// a closed channel held here would hold its IO, and a shared connection is
|
|
95
|
+
// in no hurry to make another channel that would have swept it out
|
|
96
|
+
const release = (channel) => this.#channels.delete(channel)
|
|
97
|
+
const channel = await channels.create(this.#connection, topology, index, release)
|
|
98
|
+
|
|
99
|
+
this.#channels.add(channel)
|
|
104
100
|
|
|
105
101
|
return channel
|
|
106
102
|
})
|
|
@@ -109,6 +105,10 @@ class Connection {
|
|
|
109
105
|
this.#diagnostics.on(event, listener)
|
|
110
106
|
}
|
|
111
107
|
|
|
108
|
+
forget (event, listener) {
|
|
109
|
+
this.#diagnostics.off(event, listener)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
112
|
#open = async (retry) => {
|
|
113
113
|
if (this.#closed) return
|
|
114
114
|
|
|
@@ -141,9 +141,10 @@ class Connection {
|
|
|
141
141
|
this.#diagnostics.emit('open')
|
|
142
142
|
|
|
143
143
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
for (const channel of this.#channels) {
|
|
145
|
+
if (channel.closed) this.#channels.delete(channel)
|
|
146
|
+
else await channel.recover(connection)
|
|
147
|
+
}
|
|
147
148
|
} catch (exception) {
|
|
148
149
|
this.#diagnostics.emit('error', exception)
|
|
149
150
|
this.#drop(connection)
|
|
@@ -172,10 +173,29 @@ class Connection {
|
|
|
172
173
|
this.#connection = undefined
|
|
173
174
|
|
|
174
175
|
if (error !== undefined && !this.#closed) {
|
|
175
|
-
this
|
|
176
|
+
this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Establishes the connection, or joins the attempt under way. A lost connection is
|
|
182
|
+
* restored through here and not through `open()`: what a subclass makes of `open()` is
|
|
183
|
+
* its own — the singleton answers it with the first opening, made once and remembered,
|
|
184
|
+
* which is no way to make a second one.
|
|
185
|
+
*
|
|
186
|
+
* @return {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
async #reopen () {
|
|
189
|
+
this.#opening ??= retry(this.#open).finally(() => { this.#opening = null })
|
|
190
|
+
|
|
191
|
+
await this.#opening
|
|
192
|
+
|
|
193
|
+
if (this.#closed) return
|
|
194
|
+
|
|
195
|
+
// close may have landed after this attempt succeeded but before `#opening` was cleared
|
|
196
|
+
if (this.#connection === undefined) return this.#reopen()
|
|
197
|
+
}
|
|
198
|
+
|
|
179
199
|
/**
|
|
180
200
|
* An AMQP connection is only closed once the broker has replied with Close-Ok,
|
|
181
201
|
* which never happens on a connection that has already been lost.
|
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
|
}))
|
|
@@ -169,6 +175,11 @@ class IO {
|
|
|
169
175
|
await Promise.all([this.#requests, this.#replies, this.#events]
|
|
170
176
|
.map((channel) => channel?.close()))
|
|
171
177
|
|
|
178
|
+
// a connection that outlives its IO must not keep it as a listener
|
|
179
|
+
for (const [event, forwarder] of this.#forwarders) this.#connection.forget(event, forwarder)
|
|
180
|
+
|
|
181
|
+
this.#forwarders = []
|
|
182
|
+
|
|
172
183
|
await this.#connection.close()
|
|
173
184
|
})
|
|
174
185
|
|
|
@@ -274,7 +285,7 @@ class IO {
|
|
|
274
285
|
(message) => {
|
|
275
286
|
const payload = decode(message)
|
|
276
287
|
|
|
277
|
-
emitter.emit(message.properties.correlationId, payload, message.properties)
|
|
288
|
+
emitter.emit(message.properties.correlationId, payload, message.properties, message.content.length)
|
|
278
289
|
}
|
|
279
290
|
|
|
280
291
|
/**
|
|
@@ -289,20 +300,21 @@ class IO {
|
|
|
289
300
|
})
|
|
290
301
|
|
|
291
302
|
/**
|
|
303
|
+
* The request holds no copy of what was sent: a retransmission encodes the
|
|
304
|
+
* payload anew, and an unanswered request would otherwise keep two of it.
|
|
305
|
+
*
|
|
292
306
|
* @param {string} queue
|
|
293
|
-
* @param {
|
|
294
|
-
* @param {comq.Encoding} [encoding]
|
|
307
|
+
* @param {comq.Encoding} contentType
|
|
295
308
|
* @return {comq.Request}
|
|
296
309
|
*/
|
|
297
|
-
#createRequest (queue,
|
|
298
|
-
const [buffer, contentType] = this.#encode(payload, encoding)
|
|
310
|
+
#createRequest (queue, contentType) {
|
|
299
311
|
const emitter = this.#emitters.get(queue)
|
|
300
|
-
const correlationId =
|
|
312
|
+
const correlationId = emitter.next()
|
|
301
313
|
|
|
302
314
|
/** @type {comq.amqp.Properties} */
|
|
303
315
|
const properties = { contentType, correlationId, replyTo: emitter.queue }
|
|
304
316
|
|
|
305
|
-
return {
|
|
317
|
+
return { emitter, properties }
|
|
306
318
|
}
|
|
307
319
|
|
|
308
320
|
/**
|
|
@@ -312,7 +324,7 @@ class IO {
|
|
|
312
324
|
#createReply (request) {
|
|
313
325
|
const reply = this.#createPendingReply(request)
|
|
314
326
|
|
|
315
|
-
request.emitter.
|
|
327
|
+
request.emitter.on(request.properties.correlationId, this.#getReplyResolver(request, reply))
|
|
316
328
|
|
|
317
329
|
return reply
|
|
318
330
|
}
|
|
@@ -323,12 +335,12 @@ class IO {
|
|
|
323
335
|
*/
|
|
324
336
|
#createPendingReply (request) {
|
|
325
337
|
const reply = new Promex()
|
|
338
|
+
const settled = () => this.#pendingReplies.delete(reply)
|
|
326
339
|
|
|
327
340
|
this.#pendingReplies.set(reply, request)
|
|
328
341
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
.finally(() => this.#pendingReplies.delete(reply))
|
|
342
|
+
// one derived promise per request, and a rejection handled with it
|
|
343
|
+
reply.then(settled, settled)
|
|
332
344
|
|
|
333
345
|
return reply
|
|
334
346
|
}
|
|
@@ -338,11 +350,14 @@ class IO {
|
|
|
338
350
|
* @param reply
|
|
339
351
|
*/
|
|
340
352
|
#getReplyResolver (request, reply) {
|
|
341
|
-
return async (payload, properties) => {
|
|
353
|
+
return async (payload, properties, size) => {
|
|
342
354
|
const isStream = properties.headers?.index !== undefined
|
|
343
355
|
|
|
356
|
+
// a reply is answered once; a reply stream takes the place of this resolver
|
|
357
|
+
request.emitter.off(request.properties.correlationId)
|
|
358
|
+
|
|
344
359
|
if (isStream) {
|
|
345
|
-
const stream = this.#createReplyStream(request, payload, properties)
|
|
360
|
+
const stream = this.#createReplyStream(request, payload, properties, size)
|
|
346
361
|
|
|
347
362
|
try {
|
|
348
363
|
await stream.confirmation
|
|
@@ -358,10 +373,10 @@ class IO {
|
|
|
358
373
|
}
|
|
359
374
|
}
|
|
360
375
|
|
|
361
|
-
#createReplyStream (request, payload, properties) {
|
|
376
|
+
#createReplyStream (request, payload, properties, size) {
|
|
362
377
|
const stream = new io.ReplyStream(request, this.#reply.bind(this))
|
|
363
378
|
|
|
364
|
-
stream.arrange(payload, properties)
|
|
379
|
+
stream.arrange(payload, properties, size)
|
|
365
380
|
this.#addReplyStream(/** @type {comq.Destroyable} */ stream)
|
|
366
381
|
|
|
367
382
|
return stream
|
|
@@ -453,7 +468,7 @@ class IO {
|
|
|
453
468
|
for (const [reply, request] of this.#pendingReplies) {
|
|
454
469
|
// detaching this attempt alone leaves the listeners of the reply streams
|
|
455
470
|
// that are still flowing over the other shards in place
|
|
456
|
-
request.emitter.
|
|
471
|
+
request.emitter.off(request.properties.correlationId)
|
|
457
472
|
|
|
458
473
|
// trigger failsafe attribute
|
|
459
474
|
reply.reject(RETRANSMISSION)
|
|
@@ -484,6 +499,4 @@ const DEFAULT = 'application/json'
|
|
|
484
499
|
|
|
485
500
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
486
501
|
|
|
487
|
-
function noop () {}
|
|
488
|
-
|
|
489
502
|
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
|
+
})
|