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
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
|
+
})
|
package/test/channel.test.js
CHANGED
|
@@ -971,3 +971,61 @@ const getCreatedChannel = (conn) => {
|
|
|
971
971
|
|
|
972
972
|
return (conn ?? connection)[method].mock.results[0].value
|
|
973
973
|
}
|
|
974
|
+
|
|
975
|
+
describe('consumer tags', () => {
|
|
976
|
+
const queue = generate()
|
|
977
|
+
const consumer = jest.fn()
|
|
978
|
+
|
|
979
|
+
beforeEach(async () => {
|
|
980
|
+
channel = await create(connection, topology)
|
|
981
|
+
chan = await getCreatedChannel()
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
// the consumers of a lost channel went down with it, and their tags would pile
|
|
985
|
+
// up with every reconnection to be cancelled on a channel that never had them
|
|
986
|
+
it('should cancel the consumers of the current channel only', async () => {
|
|
987
|
+
await channel.consume(queue, consumer)
|
|
988
|
+
|
|
989
|
+
const replacement = await amqplib.connect()
|
|
990
|
+
|
|
991
|
+
await channel.recover(replacement)
|
|
992
|
+
|
|
993
|
+
const repl = await getCreatedChannel(replacement)
|
|
994
|
+
|
|
995
|
+
expect(repl.consume).toHaveBeenCalledTimes(1)
|
|
996
|
+
|
|
997
|
+
const { consumerTag } = await repl.consume.mock.results[0].value
|
|
998
|
+
|
|
999
|
+
await channel.seal()
|
|
1000
|
+
|
|
1001
|
+
expect(repl.cancel).toHaveBeenCalledTimes(1)
|
|
1002
|
+
expect(repl.cancel).toHaveBeenCalledWith(consumerTag)
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
it('should let go of the recorded subscriptions once sealed', async () => {
|
|
1006
|
+
await channel.consume(queue, consumer)
|
|
1007
|
+
await channel.seal()
|
|
1008
|
+
|
|
1009
|
+
const replacement = await amqplib.connect()
|
|
1010
|
+
|
|
1011
|
+
await channel.recover(replacement)
|
|
1012
|
+
|
|
1013
|
+
const repl = await getCreatedChannel(replacement)
|
|
1014
|
+
|
|
1015
|
+
expect(repl.consume).not.toHaveBeenCalled()
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
describe('release', () => {
|
|
1020
|
+
it('should be called once the channel is closed', async () => {
|
|
1021
|
+
const release = jest.fn()
|
|
1022
|
+
|
|
1023
|
+
channel = await create(connection, topology, undefined, release)
|
|
1024
|
+
|
|
1025
|
+
expect(release).not.toHaveBeenCalled()
|
|
1026
|
+
|
|
1027
|
+
await channel.close()
|
|
1028
|
+
|
|
1029
|
+
expect(release).toHaveBeenCalledWith(channel)
|
|
1030
|
+
})
|
|
1031
|
+
})
|
package/test/connection.mock.js
CHANGED
|
@@ -11,6 +11,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
|
|
|
11
11
|
subscribe: jest.fn(async () => undefined),
|
|
12
12
|
publish: jest.fn(async () => undefined),
|
|
13
13
|
diagnose: jest.fn(async () => undefined),
|
|
14
|
+
forget: jest.fn(() => undefined),
|
|
14
15
|
seal: jest.fn(async () => undefined),
|
|
15
16
|
close: jest.fn(async () => undefined),
|
|
16
17
|
closed: false,
|
|
@@ -26,7 +27,8 @@ const connection = (sharded = false) => (/** @type {jest.MockedObject<comq.Conne
|
|
|
26
27
|
createChannel: jest.fn(async (type, index) => channel(sharded, index)),
|
|
27
28
|
open: jest.fn(async () => undefined),
|
|
28
29
|
close: jest.fn(async () => undefined),
|
|
29
|
-
diagnose: jest.fn(() => undefined)
|
|
30
|
+
diagnose: jest.fn(() => undefined),
|
|
31
|
+
forget: jest.fn(() => undefined)
|
|
30
32
|
})
|
|
31
33
|
|
|
32
34
|
exports.connection = connection
|
package/test/connection.test.js
CHANGED
|
@@ -184,7 +184,7 @@ describe('reconnection', () => {
|
|
|
184
184
|
|
|
185
185
|
const boom = new Error('reconnect failed')
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
amqplib.connect.mockRejectedValueOnce(boom)
|
|
188
188
|
|
|
189
189
|
conn.emit('close', new Error('broker down'))
|
|
190
190
|
|
|
@@ -194,8 +194,38 @@ describe('reconnection', () => {
|
|
|
194
194
|
|
|
195
195
|
expect(errors).toContain(boom)
|
|
196
196
|
expect(unhandled).not.toHaveBeenCalled()
|
|
197
|
+
|
|
198
|
+
// the attempt is retried in the background, and would connect in a later case
|
|
199
|
+
await connection.close()
|
|
197
200
|
})
|
|
198
201
|
|
|
202
|
+
// the singleton answers `open()` with the first opening, made once and remembered
|
|
203
|
+
it('should reconnect a connection whose open() is remembered', async () => {
|
|
204
|
+
class Remembered extends Connection {
|
|
205
|
+
#opened = null
|
|
206
|
+
|
|
207
|
+
async open () {
|
|
208
|
+
this.#opened ??= super.open()
|
|
209
|
+
|
|
210
|
+
await this.#opened
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const remembered = new Remembered(generate())
|
|
215
|
+
|
|
216
|
+
await remembered.open()
|
|
217
|
+
|
|
218
|
+
const first = await amqplib.connect.mock.results.at(-1).value
|
|
219
|
+
|
|
220
|
+
first.emit('close', new Error('lost'))
|
|
221
|
+
|
|
222
|
+
await expect(remembered.createChannel('event')).resolves.toBeDefined()
|
|
223
|
+
|
|
224
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(3)
|
|
225
|
+
|
|
226
|
+
await remembered.close()
|
|
227
|
+
}, 10000)
|
|
228
|
+
|
|
199
229
|
it('should reconnect when connection is lost while channels recover', async () => {
|
|
200
230
|
const channel = await connection.createChannel('request')
|
|
201
231
|
|
|
@@ -326,7 +356,7 @@ describe('create channel', () => {
|
|
|
326
356
|
const preset = presets[type]
|
|
327
357
|
const channel = await connection.createChannel(type)
|
|
328
358
|
|
|
329
|
-
expect(create).toHaveBeenCalledWith(conn, preset, undefined)
|
|
359
|
+
expect(create).toHaveBeenCalledWith(conn, preset, undefined, expect.any(Function))
|
|
330
360
|
expect(channel).toStrictEqual(await create.mock.results[0].value)
|
|
331
361
|
})
|
|
332
362
|
|
|
@@ -336,7 +366,7 @@ describe('create channel', () => {
|
|
|
336
366
|
|
|
337
367
|
await connection.createChannel(type, index)
|
|
338
368
|
|
|
339
|
-
expect(create).toHaveBeenCalledWith(expect.anything(), expect.anything(), index)
|
|
369
|
+
expect(create).toHaveBeenCalledWith(expect.anything(), expect.anything(), index, expect.any(Function))
|
|
340
370
|
})
|
|
341
371
|
|
|
342
372
|
it('should create channel after exception', async () => {
|
|
@@ -437,6 +467,21 @@ describe('closed channels', () => {
|
|
|
437
467
|
|
|
438
468
|
expect(channel.recover).toHaveBeenCalled()
|
|
439
469
|
})
|
|
470
|
+
|
|
471
|
+
// a closed channel held by a shared connection would hold its IO until the
|
|
472
|
+
// connection happens to make another channel
|
|
473
|
+
it('should let go of one that has been given back', async () => {
|
|
474
|
+
const channel = await connection.createChannel('event')
|
|
475
|
+
const release = create.mock.calls[0][3]
|
|
476
|
+
|
|
477
|
+
release(channel)
|
|
478
|
+
|
|
479
|
+
conn.emit('close', new Error())
|
|
480
|
+
|
|
481
|
+
await timeout(50)
|
|
482
|
+
|
|
483
|
+
expect(channel.recover).not.toHaveBeenCalled()
|
|
484
|
+
})
|
|
440
485
|
})
|
|
441
486
|
|
|
442
487
|
describe('channel exhaustion', () => {
|
|
@@ -765,3 +810,20 @@ describe('diagnostics', () => {
|
|
|
765
810
|
}
|
|
766
811
|
})
|
|
767
812
|
})
|
|
813
|
+
|
|
814
|
+
describe('forget', () => {
|
|
815
|
+
afterEach(async () => {
|
|
816
|
+
await connection.close()
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
it('should stop reporting to the listener', async () => {
|
|
820
|
+
const listener = /** @type {Function} */ jest.fn()
|
|
821
|
+
|
|
822
|
+
await connection.diagnose('open', listener)
|
|
823
|
+
connection.forget('open', listener)
|
|
824
|
+
|
|
825
|
+
await connection.open()
|
|
826
|
+
|
|
827
|
+
expect(listener).not.toHaveBeenCalled()
|
|
828
|
+
})
|
|
829
|
+
})
|
|
@@ -93,3 +93,14 @@ const findChannel = (type) => {
|
|
|
93
93
|
|
|
94
94
|
return connection.createChannel.mock.results[index].value
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
// a connection is shared and outlives its IOs, so a listener left on it would hold the IO
|
|
98
|
+
it('should forget the connection listeners on close', async () => {
|
|
99
|
+
await io.close()
|
|
100
|
+
|
|
101
|
+
for (const [event, listener] of connection.diagnose.mock.calls) {
|
|
102
|
+
expect(connection.forget).toHaveBeenCalledWith(event, listener)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(connection.forget).toHaveBeenCalledTimes(connection.diagnose.mock.calls.length)
|
|
106
|
+
})
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Watches what a closed IO leaves behind. Every scenario keeps weak references to
|
|
4
|
+
// what it has opened and closed, and expects the garbage collector to have taken
|
|
5
|
+
// all of it; the heap growth per iteration is reported alongside.
|
|
6
|
+
|
|
7
|
+
const v8 = require('node:v8')
|
|
8
|
+
const vm = require('node:vm')
|
|
9
|
+
const stream = require('node:stream')
|
|
10
|
+
const { generate } = require('randomstring')
|
|
11
|
+
const { timeout } = require('./helpers')
|
|
12
|
+
const mock = require('./amqplib.mock')
|
|
13
|
+
const { amqplib, connect } = mock
|
|
14
|
+
|
|
15
|
+
jest.mock('amqplib', () => mock.amqplib)
|
|
16
|
+
|
|
17
|
+
const { connect: open, assert } = require('../source')
|
|
18
|
+
const { SingletonConnection } = require('../source/singleton')
|
|
19
|
+
|
|
20
|
+
v8.setFlagsFromString('--expose-gc')
|
|
21
|
+
|
|
22
|
+
const gc = vm.runInNewContext('gc')
|
|
23
|
+
|
|
24
|
+
/** @type {{ scenario: string, iterations: number, watched: number, retained: number, growth: string }[]} */
|
|
25
|
+
const report = []
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
jest.clearAllMocks()
|
|
29
|
+
amqplib.connect.mockImplementation(async (url) => wired(await connect(url)))
|
|
30
|
+
|
|
31
|
+
global.COMQ_TESTING_SHUTDOWN_TIMEOUT = 1
|
|
32
|
+
global.COMQ_TESTING_WATCHDOG_INTERVAL = 60_000
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
SingletonConnection.__lets_pretend_this_method_doesnt_exist()
|
|
37
|
+
|
|
38
|
+
delete global.COMQ_TESTING_SHUTDOWN_TIMEOUT
|
|
39
|
+
delete global.COMQ_TESTING_WATCHDOG_INTERVAL
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
console.info('Memory report (heap growth is per iteration, after garbage collection)\n' +
|
|
44
|
+
formatted(report))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should let go of an IO and its channels once closed', async () => {
|
|
48
|
+
const retained = await measure('connect, reply, consume, emit, close', 100, async () => {
|
|
49
|
+
const io = await open(generate())
|
|
50
|
+
|
|
51
|
+
await io.reply(generate(), () => generate())
|
|
52
|
+
await io.consume(generate(), generate(), () => undefined)
|
|
53
|
+
await io.emit(generate(), { value: generate() })
|
|
54
|
+
await io.enqueue(generate(), { value: generate() })
|
|
55
|
+
|
|
56
|
+
const channels = await amqpChannels()
|
|
57
|
+
|
|
58
|
+
await io.close()
|
|
59
|
+
|
|
60
|
+
return [io, ...channels]
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(retained).toStrictEqual(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// a singleton connection outlives every IO opened on it
|
|
67
|
+
it('should let go of the IOs of a singleton connection', async () => {
|
|
68
|
+
const url = generate()
|
|
69
|
+
|
|
70
|
+
const retained = await measure('assert, reply, close (singleton)', 100, async () => {
|
|
71
|
+
const io = await assert(url)
|
|
72
|
+
|
|
73
|
+
await io.reply(generate(), () => generate())
|
|
74
|
+
|
|
75
|
+
const channels = await amqpChannels()
|
|
76
|
+
|
|
77
|
+
await io.close()
|
|
78
|
+
|
|
79
|
+
return [io, ...channels]
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(retained).toStrictEqual(0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should let go of the streams passed to request and emit', async () => {
|
|
86
|
+
const io = await open(generate())
|
|
87
|
+
const queue = generate()
|
|
88
|
+
const exchange = generate()
|
|
89
|
+
|
|
90
|
+
await io.reply(queue, (request) => request)
|
|
91
|
+
|
|
92
|
+
const retained = await measure('request and emit with a stream', 100, async () => {
|
|
93
|
+
const requests = stream.Readable.from([{ a: generate() }, { b: generate() }])
|
|
94
|
+
const events = stream.Readable.from([{ a: generate() }, { b: generate() }])
|
|
95
|
+
|
|
96
|
+
const replies = await io.request(queue, requests)
|
|
97
|
+
|
|
98
|
+
// eslint-disable-next-line no-void, no-unused-vars
|
|
99
|
+
for await (const _ of replies) void 0
|
|
100
|
+
|
|
101
|
+
await io.emit(exchange, events)
|
|
102
|
+
|
|
103
|
+
return [requests, events]
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await io.close()
|
|
107
|
+
|
|
108
|
+
expect(retained).toStrictEqual(0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should let go of the channels lost with a connection', async () => {
|
|
112
|
+
const io = await open(generate())
|
|
113
|
+
|
|
114
|
+
await io.reply(generate(), () => generate())
|
|
115
|
+
await io.consume(generate(), generate(), () => undefined)
|
|
116
|
+
|
|
117
|
+
const retained = await measure('reconnection', 50, async () => {
|
|
118
|
+
const before = await amqpChannels()
|
|
119
|
+
const conn = await amqplib.connect.mock.results.at(-1).value
|
|
120
|
+
|
|
121
|
+
let recovered = 0
|
|
122
|
+
|
|
123
|
+
const recovery = new Promise((resolve) => io.diagnose('recover', () => { if (++recovered === 3) resolve() }))
|
|
124
|
+
|
|
125
|
+
conn.emit('close', new Error(generate()))
|
|
126
|
+
|
|
127
|
+
await recovery
|
|
128
|
+
|
|
129
|
+
return before
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await io.close()
|
|
133
|
+
|
|
134
|
+
expect(retained).toStrictEqual(0)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} scenario
|
|
139
|
+
* @param {number} iterations
|
|
140
|
+
* @param {(index: number) => Promise<object[]>} iteration returning what must be collected afterwards
|
|
141
|
+
* @return {Promise<number>} how many of the watched objects are still alive
|
|
142
|
+
*/
|
|
143
|
+
async function measure (scenario, iterations, iteration) {
|
|
144
|
+
/** @type {WeakRef<object>[]} */
|
|
145
|
+
const refs = []
|
|
146
|
+
|
|
147
|
+
await collect()
|
|
148
|
+
|
|
149
|
+
const before = process.memoryUsage().heapUsed
|
|
150
|
+
|
|
151
|
+
// in a frame of its own, so that nothing of the last iteration is left on the stack
|
|
152
|
+
await watch(refs, iterations, iteration)
|
|
153
|
+
|
|
154
|
+
// mocks remember what they returned, and spies are registered until restored,
|
|
155
|
+
// either of which would keep what they were made on alive
|
|
156
|
+
jest.clearAllMocks()
|
|
157
|
+
jest.restoreAllMocks()
|
|
158
|
+
|
|
159
|
+
await collect()
|
|
160
|
+
|
|
161
|
+
const after = process.memoryUsage().heapUsed
|
|
162
|
+
const retained = refs.filter((ref) => ref.deref() !== undefined).length
|
|
163
|
+
const growth = ((after - before) / iterations / 1024).toFixed(1) + ' KB'
|
|
164
|
+
|
|
165
|
+
report.push({ scenario, iterations, watched: refs.length, retained, growth })
|
|
166
|
+
|
|
167
|
+
return retained
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {WeakRef<object>[]} refs
|
|
172
|
+
* @param {number} iterations
|
|
173
|
+
* @param {(index: number) => Promise<object[]>} iteration
|
|
174
|
+
*/
|
|
175
|
+
async function watch (refs, iterations, iteration) {
|
|
176
|
+
for (let i = 0; i < iterations; i++) {
|
|
177
|
+
const objects = await iteration(i)
|
|
178
|
+
|
|
179
|
+
for (const object of objects) refs.push(new WeakRef(object))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The channels amqplib has been asked for since the last call.
|
|
185
|
+
*
|
|
186
|
+
* @return {Promise<object[]>}
|
|
187
|
+
*/
|
|
188
|
+
async function amqpChannels () {
|
|
189
|
+
const channels = []
|
|
190
|
+
|
|
191
|
+
for (const { value } of amqplib.connect.mock.results) {
|
|
192
|
+
const conn = await value
|
|
193
|
+
|
|
194
|
+
for (const method of ['createChannel', 'createConfirmChannel']) {
|
|
195
|
+
for (const result of conn[method].mock.results) channels.push(await result.value)
|
|
196
|
+
|
|
197
|
+
conn[method].mockClear()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return channels
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A broker of sorts: what is published to a queue is delivered to its consumer,
|
|
206
|
+
* so that requests are answered and streams of them come to an end.
|
|
207
|
+
*
|
|
208
|
+
* @param {jest.MockedObject<comq.amqp.Connection>} conn
|
|
209
|
+
* @return {jest.MockedObject<comq.amqp.Connection>}
|
|
210
|
+
*/
|
|
211
|
+
function wired (conn) {
|
|
212
|
+
const consumers = new Map()
|
|
213
|
+
const tags = new Map()
|
|
214
|
+
|
|
215
|
+
// a broker forgets the consumers of a connection that is gone
|
|
216
|
+
conn.on('close', () => consumers.clear())
|
|
217
|
+
|
|
218
|
+
for (const method of ['createChannel', 'createConfirmChannel']) {
|
|
219
|
+
const create = conn[method].getMockImplementation()
|
|
220
|
+
|
|
221
|
+
conn[method].mockImplementation(async () => {
|
|
222
|
+
const chan = await create()
|
|
223
|
+
|
|
224
|
+
chan.consume.mockImplementation(async (queue, callback) => {
|
|
225
|
+
const consumerTag = generate()
|
|
226
|
+
|
|
227
|
+
consumers.set(queue, callback)
|
|
228
|
+
tags.set(consumerTag, queue)
|
|
229
|
+
|
|
230
|
+
return { consumerTag }
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
chan.cancel.mockImplementation(async (consumerTag) => {
|
|
234
|
+
consumers.delete(tags.get(consumerTag))
|
|
235
|
+
tags.delete(consumerTag)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
chan.publish.mockImplementation((exchange, routingKey, content, properties, resolve) => {
|
|
239
|
+
resolve?.(null)
|
|
240
|
+
|
|
241
|
+
const callback = consumers.get(routingKey)
|
|
242
|
+
|
|
243
|
+
if (callback !== undefined) setImmediate(callback, { content, properties, fields: { exchange, routingKey } })
|
|
244
|
+
|
|
245
|
+
return true
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return chan
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return conn
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function collect () {
|
|
256
|
+
// an object a WeakRef was made for in the current task is kept until the task ends
|
|
257
|
+
await timeout(0)
|
|
258
|
+
|
|
259
|
+
gc()
|
|
260
|
+
gc()
|
|
261
|
+
|
|
262
|
+
await timeout(0)
|
|
263
|
+
|
|
264
|
+
gc()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatted (rows) {
|
|
268
|
+
const columns = ['scenario', 'iterations', 'watched', 'retained', 'growth']
|
|
269
|
+
const width = (column) => Math.max(column.length, ...rows.map((row) => String(row[column]).length))
|
|
270
|
+
const line = (cells) => cells.map((cell, i) => String(cell).padEnd(width(columns[i]))).join(' ')
|
|
271
|
+
|
|
272
|
+
return [line(columns), ...rows.map((row) => line(columns.map((column) => row[column])))].join('\n')
|
|
273
|
+
}
|
package/test/pipeline.test.js
CHANGED
|
@@ -78,4 +78,25 @@ class Channel extends EventEmitter {
|
|
|
78
78
|
diagnose (event, callback) {
|
|
79
79
|
this.on(event, callback)
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
forget (event, callback) {
|
|
83
|
+
this.off(event, callback)
|
|
84
|
+
}
|
|
81
85
|
}
|
|
86
|
+
|
|
87
|
+
// the source must not stay a listener of the channel once the pipeline is done
|
|
88
|
+
it('should let go of the source once done', async () => {
|
|
89
|
+
const input = stream.Readable.from([1, 2, 3])
|
|
90
|
+
const pipe = pipeline(input, (x) => x, channel)
|
|
91
|
+
|
|
92
|
+
expect(channel.listenerCount('pause')).toBe(1)
|
|
93
|
+
expect(channel.listenerCount('resume')).toBe(1)
|
|
94
|
+
|
|
95
|
+
// eslint-disable-next-line no-void, no-unused-vars
|
|
96
|
+
for await (const _ of pipe) void 0
|
|
97
|
+
|
|
98
|
+
await timeout(0)
|
|
99
|
+
|
|
100
|
+
expect(channel.listenerCount('pause')).toBe(0)
|
|
101
|
+
expect(channel.listenerCount('resume')).toBe(0)
|
|
102
|
+
})
|
|
@@ -132,3 +132,18 @@ describe.each(/** @type {comq.diagnostics.Event[]} */ ['open', 'close'])('diagno
|
|
|
132
132
|
expect(listener).toHaveBeenCalledWith(event, ...args, index)
|
|
133
133
|
})
|
|
134
134
|
})
|
|
135
|
+
|
|
136
|
+
describe('forget', () => {
|
|
137
|
+
it('should stop re-emitting to the listener', async () => {
|
|
138
|
+
const listener = /** @type {Function} */ jest.fn()
|
|
139
|
+
|
|
140
|
+
connection.diagnose('open', listener)
|
|
141
|
+
connection.forget('open', listener)
|
|
142
|
+
|
|
143
|
+
const call = connections[0].diagnose.mock.calls.find((call) => call[0] === 'open')
|
|
144
|
+
|
|
145
|
+
call[1]()
|
|
146
|
+
|
|
147
|
+
expect(listener).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
})
|
package/types/connection.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ declare namespace comq {
|
|
|
18
18
|
createChannel(type: _topology.type, index: number): Promise<_channel.Channel>
|
|
19
19
|
|
|
20
20
|
diagnose(event: _diagnostics.Event, listener: Function): void
|
|
21
|
+
|
|
22
|
+
forget(event: _diagnostics.Event, listener: Function): void
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
type Connect = (...urls: string[]) => Promise<_io.IO>
|
package/types/io.d.ts
CHANGED
|
@@ -10,8 +10,21 @@ declare namespace comq {
|
|
|
10
10
|
type Producer<Input = any, Output = any> = (message: Input) => Output | Promise<Output>
|
|
11
11
|
type Consumer<T = any> = (message: T, headers?: _amqp.Properties) => void | Promise<void>
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
type ReplyHandler = (payload: any, properties: _amqp.Properties, size?: number) => void
|
|
14
|
+
|
|
15
|
+
interface ReplyEmitter {
|
|
14
16
|
readonly queue: string
|
|
17
|
+
|
|
18
|
+
/** A correlation identifier unique across every consumer of a producer. */
|
|
19
|
+
next(): string
|
|
20
|
+
|
|
21
|
+
on(correlationId: string, handler: ReplyHandler): void
|
|
22
|
+
|
|
23
|
+
off(correlationId: string, handler?: ReplyHandler): void
|
|
24
|
+
|
|
25
|
+
emit(correlationId: string, payload: any, properties: _amqp.Properties, size?: number): boolean
|
|
26
|
+
|
|
27
|
+
readonly pending: number
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
interface Destroyable extends EventEmitter {
|
|
@@ -19,7 +32,6 @@ declare namespace comq {
|
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
interface Request {
|
|
22
|
-
buffer: Buffer
|
|
23
35
|
emitter: ReplyEmitter
|
|
24
36
|
properties: _amqp.Properties
|
|
25
37
|
}
|