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.
@@ -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
+ })
@@ -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
@@ -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
+ }
@@ -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
+ })
@@ -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>
@@ -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
- interface ReplyEmitter extends EventEmitter {
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>