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.
@@ -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
- confirmation
267
- .catch(noop) // have no idea why, but this is required
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
 
@@ -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
- if (this.#opening !== null) return this.#opening
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
- this.#channels = this.#channels.filter((channel) => !channel.closed)
103
- this.#channels.push(channel)
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
- this.#channels = this.#channels.filter((channel) => !channel.closed)
145
-
146
- for (const channel of this.#channels) await channel.recover(connection)
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.open().catch((exception) => this.#diagnostics.emit('error', exception))
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
- this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
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 request = this.#createRequest(queue, payload, encoding)
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, request.buffer, request.properties)
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 {any} payload
294
- * @param {comq.Encoding} [encoding]
307
+ * @param {comq.Encoding} contentType
295
308
  * @return {comq.Request}
296
309
  */
297
- #createRequest (queue, payload, encoding) {
298
- const [buffer, contentType] = this.#encode(payload, encoding)
310
+ #createRequest (queue, contentType) {
299
311
  const emitter = this.#emitters.get(queue)
300
- const correlationId = randomBytes(8).toString('hex')
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 { buffer, emitter, properties }
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.once(request.properties.correlationId, this.#getReplyResolver(request, reply))
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
- reply
330
- .catch(noop)
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.removeAllListeners(request.properties.correlationId)
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
@@ -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
- // eslint-disable-next-line no-void
10
- void stream.pipeline(source, destination)
11
+ channel.diagnose('pause', pause)
12
+ channel.diagnose('resume', resume)
11
13
 
12
- channel.diagnose('pause', source.pause.bind(source))
13
- channel.diagnose('resume', source.resume.bind(source))
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
  }
@@ -46,6 +46,10 @@ class Connection {
46
46
  this.#diagnostics.on(event, listener)
47
47
  }
48
48
 
49
+ forget (event, listener) {
50
+ this.#diagnostics.off(event, listener)
51
+ }
52
+
49
53
  /**
50
54
  * @param {comq.Connection} connection
51
55
  * @param {number} index
@@ -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
+ })