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.
@@ -1,8 +1,18 @@
1
1
  'use strict'
2
2
 
3
+ /**
4
+ * Runs the initializers once per distinct set of arguments before the method.
5
+ *
6
+ * The record of what has been initialized is kept per context, so that a context
7
+ * that is dropped takes its record with it, and it is looked up by argument rather
8
+ * than scanned — a channel publishing to a thousand queues asks for the thousandth
9
+ * as fast as for the first.
10
+ *
11
+ * @param {object} context
12
+ * @param {Function | Function[]} initializers
13
+ * @param {(...args: unknown[]) => Promise<unknown>} method
14
+ */
3
15
  function lazy (context, initializers, method) {
4
- if (context[LOCK] === undefined) context[LOCK] = Symbol('methods locking key')
5
-
6
16
  if (!Array.isArray(initializers)) initializers = [initializers]
7
17
 
8
18
  return async function (...args) {
@@ -24,7 +34,7 @@ async function call (context, initializers, args) {
24
34
  let override
25
35
 
26
36
  for (const init of initializers) {
27
- const result = await resolve(context, init, args)
37
+ const result = await lock(context, init, args)
28
38
 
29
39
  if (result !== undefined) override = result
30
40
  }
@@ -33,46 +43,76 @@ async function call (context, initializers, args) {
33
43
  }
34
44
 
35
45
  /**
46
+ * An initializer expecting N arguments is run once per distinct N leading
47
+ * arguments, which are the keys of a trie of Maps N levels deep.
48
+ *
36
49
  * @param {object} context
37
- * @param {Function} init
38
- * @param {any[]} args
39
- * @returns {Promise}
50
+ * @param {(...args: unknown[]) => Promise<unknown>} init
51
+ * @param {unknown[]} args
52
+ * @returns {Promise<unknown>}
40
53
  */
41
- function resolve (context, init, args) {
42
- const key = context[LOCK]
43
- const expected = args.slice(0, init.length)
54
+ function lock (context, init, args) {
55
+ const arity = init.length
56
+
57
+ let node = table(context, init)
58
+
59
+ for (let i = 0; i < arity - 1; i++) node = branch(node, args[i])
60
+
61
+ const key = arity === 0 ? NONE : args[arity - 1]
62
+ const found = node.get(key)
63
+
64
+ if (found !== undefined) return found
65
+
66
+ const promise = init.apply(context, args.slice(0, arity))
67
+
68
+ node.set(key, promise)
44
69
 
45
- return lock(context, init, expected, key)
70
+ return promise
46
71
  }
47
72
 
48
73
  /**
49
74
  * @param {object} context
50
- * @param {(...args: unknown[]) => Promise<unknown>} init
51
- * @param {unknown[]} args
52
- * @param {Symbol} key
53
- * @returns {Promise<unknown>}
75
+ * @param {Function} init
76
+ * @return {Map<unknown, unknown>}
54
77
  */
55
- function lock (context, init, args, key) {
56
- if (init[key] === undefined) init[key] = []
78
+ function table (context, init) {
79
+ let tables = TABLES.get(context)
57
80
 
58
- const locks = init[key]
59
- const found = locks.find((lock) => lock.args.reduce((match, argument, i) => match && argument === args[i], true))
81
+ if (tables === undefined) {
82
+ tables = new Map()
83
+ TABLES.set(context, tables)
84
+ }
60
85
 
61
- if (found !== undefined) return found.promise
86
+ return branch(tables, init)
87
+ }
62
88
 
63
- const promise = init.apply(context, args)
64
- const lock = { args, promise }
89
+ /**
90
+ * @param {Map<unknown, unknown>} node
91
+ * @param {unknown} key
92
+ * @return {Map<unknown, unknown>}
93
+ */
94
+ function branch (node, key) {
95
+ let next = node.get(key)
65
96
 
66
- locks.push(lock)
97
+ if (next === undefined) {
98
+ next = new Map()
99
+ node.set(key, next)
100
+ }
67
101
 
68
- return promise
102
+ return /** @type {Map<unknown, unknown>} */ next
69
103
  }
70
104
 
105
+ /**
106
+ * @param {object} context
107
+ */
71
108
  function reset (context) {
72
- context[LOCK] = context[LOCK] = Symbol('methods locking key')
109
+ TABLES.delete(context)
73
110
  }
74
111
 
75
- const LOCK = Symbol('context locking key')
112
+ /** @type {WeakMap<object, Map<Function, Map<unknown, unknown>>>} */
113
+ const TABLES = new WeakMap()
114
+
115
+ const NONE = Symbol('no arguments')
76
116
 
77
117
  lazy.reset = reset
78
118
 
@@ -297,3 +297,42 @@ describe('reset', () => {
297
297
  expect(initialize2).toHaveBeenCalledTimes(2)
298
298
  })
299
299
  })
300
+
301
+ class Exposed {
302
+ do = lazy(this, this.initialize, async () => undefined)
303
+
304
+ async initialize (_) {}
305
+ }
306
+
307
+ // an initializer is a method shared by every instance of its class, so a record
308
+ // kept on it would outlive the instances it was made for
309
+ it('should keep no record on the initializer or the context', async () => {
310
+ const instance = new Exposed()
311
+
312
+ await instance.do(generate())
313
+ lazy.reset(instance)
314
+ await instance.do(generate())
315
+
316
+ expect(Object.getOwnPropertySymbols(Exposed.prototype.initialize)).toHaveLength(0)
317
+ expect(Object.getOwnPropertySymbols(instance)).toHaveLength(0)
318
+ })
319
+
320
+ it('should tell arguments apart by identity', async () => {
321
+ let calls = 0
322
+
323
+ class Counted {
324
+ do = lazy(this, this.initialize, async () => undefined)
325
+
326
+ async initialize (_) {
327
+ calls++
328
+ }
329
+ }
330
+
331
+ const instance = new Counted()
332
+
333
+ await instance.do(undefined)
334
+ await instance.do('undefined')
335
+ await instance.do(undefined)
336
+
337
+ expect(calls).toStrictEqual(2)
338
+ })
@@ -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)
@@ -93,6 +101,24 @@ class Channel {
93
101
  if (!this.#sealed) await this.#consume(queue, callback)
94
102
  })))
95
103
 
104
+ /**
105
+ * Consumes a named queue bound to a direct exchange under a routing key, where `subscribe`
106
+ * binds to a fanout and takes everything published to it.
107
+ */
108
+ bound = recall(this,
109
+ failsafe(this, this.#recover,
110
+ lazy(this, [this.#assertRouted, this.#assertKeyedQueue],
111
+ /**
112
+ * @param {string} exchange
113
+ * @param {string} queue
114
+ * @param {string} key
115
+ * @param {comq.channels.Consumer} callback
116
+ * @returns {Promise<void>}
117
+ */
118
+ async (exchange, queue, key, callback) => {
119
+ if (!this.#sealed) await this.#consume(queue, callback)
120
+ })))
121
+
96
122
  send = failsafe(this, this.#recover,
97
123
  lazy(this, this.#assertQueue,
98
124
  /**
@@ -115,6 +141,21 @@ class Channel {
115
141
  await this.#publish(exchange, DEFAULT, buffer, options)
116
142
  }))
117
143
 
144
+ /**
145
+ * Publishes to a direct exchange under a routing key, where `publish` fans out.
146
+ */
147
+ route = failsafe(this, this.#recover,
148
+ lazy(this, this.#assertRouted,
149
+ /**
150
+ * @param {string} exchange
151
+ * @param {string} key
152
+ * @param {Buffer} buffer
153
+ * @param {comq.amqp.options.Publish} [options]
154
+ */
155
+ async (exchange, key, buffer, options) => {
156
+ await this.#publish(exchange, key, buffer, options)
157
+ }))
158
+
118
159
  async fire (queue, buffer, options) {
119
160
  try {
120
161
  await this.#publish(DEFAULT, queue, buffer, options)
@@ -141,6 +182,8 @@ class Channel {
141
182
 
142
183
  // a channel that went down with its connection is the outcome this asks for
143
184
  await this.#channel?.close().catch(noop)
185
+
186
+ this.#release(this)
144
187
  }
145
188
 
146
189
  async seal () {
@@ -149,6 +192,10 @@ class Channel {
149
192
  const cancellations = this.#tags.map((tag) => this.#channel.cancel(tag))
150
193
 
151
194
  await Promise.all(cancellations).catch(noop) // won't recover anyway
195
+
196
+ // a sealed channel is not going to consume again, so what it has consumed
197
+ // and the callbacks it was given are of no use anymore
198
+ recall.reset(this)
152
199
  }
153
200
 
154
201
  diagnose (event, listener) {
@@ -216,6 +263,33 @@ class Channel {
216
263
  await this.#channel.assertExchange(exchange, 'fanout', options)
217
264
  }
218
265
 
266
+ /**
267
+ * A routed exchange is `direct`: a message reaches the queues bound under the key it was
268
+ * published with, and none of the others.
269
+ *
270
+ * @param {string} exchange
271
+ * @returns {Promise<void>}
272
+ */
273
+ async #assertRouted (exchange) {
274
+ /** @type {comq.amqp.options.Exchange} */
275
+ const options = { durable: this.#topology.durable }
276
+
277
+ await this.#channel.assertExchange(exchange, 'direct', options)
278
+ }
279
+
280
+ /**
281
+ * @param {string} exchange
282
+ * @param {string} queue
283
+ * @param {string} key
284
+ * @returns {Promise<string[]>}
285
+ */
286
+ async #assertKeyedQueue (exchange, queue, key) {
287
+ queue = (await this.#assertQueue(queue))[0]
288
+ await this.#channel.bindQueue(queue, exchange, key)
289
+
290
+ return [exchange, queue, key]
291
+ }
292
+
219
293
  /**
220
294
  *
221
295
  * @param {string} exchange
@@ -260,12 +334,12 @@ class Channel {
260
334
  */
261
335
  #confirmation () {
262
336
  const confirmation = new Promex()
337
+ const settled = () => this.#confirmations.delete(confirmation)
263
338
 
264
339
  this.#confirmations.add(confirmation)
265
340
 
266
- confirmation
267
- .catch(noop) // have no idea why, but this is required
268
- .finally(() => this.#confirmations.delete(confirmation))
341
+ // one derived promise per publish, and a rejection handled with it
342
+ confirmation.then(settled, settled)
269
343
 
270
344
  return confirmation
271
345
  }
@@ -372,10 +446,11 @@ class Channel {
372
446
  * @param {comq.amqp.Connection} connection
373
447
  * @param {comq.Topology} topology
374
448
  * @param {number} [index]
449
+ * @param {(channel: comq.Channel) => void} [release]
375
450
  * @return {Promise<comq.Channel>}
376
451
  */
377
- async function create (connection, topology, index) {
378
- const channel = new Channel(connection, topology, index)
452
+ async function create (connection, topology, index, release) {
453
+ const channel = new Channel(connection, topology, index, release)
379
454
 
380
455
  await channel.create()
381
456
 
@@ -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()
@@ -90,10 +90,13 @@ class Connection {
90
90
  if (this.#connection === undefined) await this.#recovery
91
91
 
92
92
  const topology = presets[type]
93
- const channel = await channels.create(this.#connection, topology, index)
94
93
 
95
- this.#channels = this.#channels.filter((channel) => !channel.closed)
96
- 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)
97
100
 
98
101
  return channel
99
102
  })
@@ -102,6 +105,10 @@ class Connection {
102
105
  this.#diagnostics.on(event, listener)
103
106
  }
104
107
 
108
+ forget (event, listener) {
109
+ this.#diagnostics.off(event, listener)
110
+ }
111
+
105
112
  #open = async (retry) => {
106
113
  if (this.#closed) return
107
114
 
@@ -134,9 +141,10 @@ class Connection {
134
141
  this.#diagnostics.emit('open')
135
142
 
136
143
  try {
137
- this.#channels = this.#channels.filter((channel) => !channel.closed)
138
-
139
- 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
+ }
140
148
  } catch (exception) {
141
149
  this.#diagnostics.emit('error', exception)
142
150
  this.#drop(connection)