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/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
|
@@ -9,8 +9,11 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
|
|
|
9
9
|
send: jest.fn(async () => undefined),
|
|
10
10
|
fire: jest.fn(async () => undefined),
|
|
11
11
|
subscribe: jest.fn(async () => undefined),
|
|
12
|
+
bound: jest.fn(async () => undefined),
|
|
12
13
|
publish: jest.fn(async () => undefined),
|
|
14
|
+
route: jest.fn(async () => undefined),
|
|
13
15
|
diagnose: jest.fn(async () => undefined),
|
|
16
|
+
forget: jest.fn(() => undefined),
|
|
14
17
|
seal: jest.fn(async () => undefined),
|
|
15
18
|
close: jest.fn(async () => undefined),
|
|
16
19
|
closed: false,
|
|
@@ -26,7 +29,8 @@ const connection = (sharded = false) => (/** @type {jest.MockedObject<comq.Conne
|
|
|
26
29
|
createChannel: jest.fn(async (type, index) => channel(sharded, index)),
|
|
27
30
|
open: jest.fn(async () => undefined),
|
|
28
31
|
close: jest.fn(async () => undefined),
|
|
29
|
-
diagnose: jest.fn(() => undefined)
|
|
32
|
+
diagnose: jest.fn(() => undefined),
|
|
33
|
+
forget: jest.fn(() => undefined)
|
|
30
34
|
})
|
|
31
35
|
|
|
32
36
|
exports.connection = connection
|
package/test/connection.test.js
CHANGED
|
@@ -356,7 +356,7 @@ describe('create channel', () => {
|
|
|
356
356
|
const preset = presets[type]
|
|
357
357
|
const channel = await connection.createChannel(type)
|
|
358
358
|
|
|
359
|
-
expect(create).toHaveBeenCalledWith(conn, preset, undefined)
|
|
359
|
+
expect(create).toHaveBeenCalledWith(conn, preset, undefined, expect.any(Function))
|
|
360
360
|
expect(channel).toStrictEqual(await create.mock.results[0].value)
|
|
361
361
|
})
|
|
362
362
|
|
|
@@ -366,7 +366,7 @@ describe('create channel', () => {
|
|
|
366
366
|
|
|
367
367
|
await connection.createChannel(type, index)
|
|
368
368
|
|
|
369
|
-
expect(create).toHaveBeenCalledWith(expect.anything(), expect.anything(), index)
|
|
369
|
+
expect(create).toHaveBeenCalledWith(expect.anything(), expect.anything(), index, expect.any(Function))
|
|
370
370
|
})
|
|
371
371
|
|
|
372
372
|
it('should create channel after exception', async () => {
|
|
@@ -467,6 +467,21 @@ describe('closed channels', () => {
|
|
|
467
467
|
|
|
468
468
|
expect(channel.recover).toHaveBeenCalled()
|
|
469
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
|
+
})
|
|
470
485
|
})
|
|
471
486
|
|
|
472
487
|
describe('channel exhaustion', () => {
|
|
@@ -795,3 +810,20 @@ describe('diagnostics', () => {
|
|
|
795
810
|
}
|
|
796
811
|
})
|
|
797
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,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
|
|
5
|
+
const { decode } = require('../source/decode')
|
|
6
|
+
const { encode } = require('../source/encode')
|
|
7
|
+
|
|
8
|
+
const mock = require('./connection.mock')
|
|
9
|
+
const { IO } = require('../source/io')
|
|
10
|
+
|
|
11
|
+
/** @type {comq.IO} */
|
|
12
|
+
let io
|
|
13
|
+
|
|
14
|
+
/** @type {jest.MockedObject<comq.Connection>} */
|
|
15
|
+
let connection
|
|
16
|
+
|
|
17
|
+
/** @type {jest.MockedObject<comq.Channel>} */
|
|
18
|
+
let events
|
|
19
|
+
|
|
20
|
+
const exchange = generate()
|
|
21
|
+
const queue = generate()
|
|
22
|
+
const key = generate()
|
|
23
|
+
const payload = generate()
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
jest.clearAllMocks()
|
|
27
|
+
|
|
28
|
+
connection = mock.connection()
|
|
29
|
+
io = new IO(connection)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('route', () => {
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
await io.route(exchange, key, payload)
|
|
35
|
+
|
|
36
|
+
events = await connection.createChannel.mock.results[0].value
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should create an event channel', async () => {
|
|
40
|
+
expect(connection.createChannel).toHaveBeenCalledWith('event')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should route to the exchange under the key', async () => {
|
|
44
|
+
expect(events.route).toHaveBeenCalledTimes(1)
|
|
45
|
+
|
|
46
|
+
const [name, routingKey, buffer, properties] = events.route.mock.calls[0]
|
|
47
|
+
|
|
48
|
+
expect(name).toStrictEqual(exchange)
|
|
49
|
+
expect(routingKey).toStrictEqual(key)
|
|
50
|
+
expect(decode({ content: buffer, properties })).toStrictEqual(payload)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should pass properties through', async () => {
|
|
54
|
+
jest.clearAllMocks()
|
|
55
|
+
|
|
56
|
+
const properties = { headers: { 'x-region': 'eu' }, persistent: true }
|
|
57
|
+
|
|
58
|
+
await io.route(exchange, key, payload, properties)
|
|
59
|
+
|
|
60
|
+
expect(events.route.mock.calls[0][3]).toMatchObject(properties)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('subscribe', () => {
|
|
65
|
+
const consumer = jest.fn()
|
|
66
|
+
|
|
67
|
+
it('should bind the queue under the key and consume it', async () => {
|
|
68
|
+
await io.subscribe(exchange, queue, key, consumer)
|
|
69
|
+
|
|
70
|
+
events = await connection.createChannel.mock.results[0].value
|
|
71
|
+
|
|
72
|
+
expect(events.bound).toHaveBeenCalledTimes(1)
|
|
73
|
+
|
|
74
|
+
const [name, bound, routingKey] = events.bound.mock.calls[0]
|
|
75
|
+
|
|
76
|
+
expect(name).toStrictEqual(exchange)
|
|
77
|
+
expect(bound).toStrictEqual(queue)
|
|
78
|
+
expect(routingKey).toStrictEqual(key)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should hand the payload to the consumer', async () => {
|
|
82
|
+
await io.subscribe(exchange, queue, key, consumer)
|
|
83
|
+
|
|
84
|
+
events = await connection.createChannel.mock.results[0].value
|
|
85
|
+
|
|
86
|
+
const deliver = events.bound.mock.calls[0][3]
|
|
87
|
+
const properties = { contentType: 'application/json' }
|
|
88
|
+
const message = { content: encode(payload, properties.contentType), properties }
|
|
89
|
+
|
|
90
|
+
await deliver(message)
|
|
91
|
+
|
|
92
|
+
expect(consumer).toHaveBeenCalledWith(payload, properties)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -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/channel.d.ts
CHANGED
|
@@ -19,10 +19,16 @@ declare namespace comq {
|
|
|
19
19
|
|
|
20
20
|
subscribe (exchange: string, queue: string, consumer: channels.Consumer): Promise<void>
|
|
21
21
|
|
|
22
|
+
/** consumes `queue`, bound to a routed `exchange` under `key` */
|
|
23
|
+
bound (exchange: string, queue: string, key: string, consumer: channels.Consumer): Promise<void>
|
|
24
|
+
|
|
22
25
|
send (queue: string, buffer: Buffer, options?: Options.Publish): Promise<void>
|
|
23
26
|
|
|
24
27
|
publish (exchange: string, buffer: Buffer, options?: Options.Publish): Promise<void>
|
|
25
28
|
|
|
29
|
+
/** publishes to a routed exchange under `key`, where `publish` fans out */
|
|
30
|
+
route (exchange: string, key: string, buffer: Buffer, options?: Options.Publish): Promise<void>
|
|
31
|
+
|
|
26
32
|
fire (queue: string, buffer: Buffer, options?: Options.Publish): Promise<boolean>
|
|
27
33
|
|
|
28
34
|
seal (): Promise<void>
|
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
|
}
|
|
@@ -51,6 +63,20 @@ declare namespace comq {
|
|
|
51
63
|
|
|
52
64
|
enqueue(exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
|
|
53
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Publishes to a routed (`direct`) exchange under a key, where `emit` fans out: the
|
|
68
|
+
* message reaches the queues bound under that key and no others.
|
|
69
|
+
*/
|
|
70
|
+
route(exchange: string, key: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
|
|
71
|
+
|
|
72
|
+
route(exchange: string, key: string, payload: any, properties?: _amqp.Properties): Promise<void>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Consumes a named durable queue bound to a routed exchange under a key, where `consume`
|
|
76
|
+
* takes everything published to a fanout.
|
|
77
|
+
*/
|
|
78
|
+
subscribe<T = any>(exchange: string, queue: string, key: string, consumer: Consumer<T>): Promise<void>
|
|
79
|
+
|
|
54
80
|
process<T = any>(queue: string, processor: Consumer<T>): Promise<void>
|
|
55
81
|
|
|
56
82
|
seal(): Promise<void>
|