comq 0.17.0 → 0.18.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/source/channel.js CHANGED
@@ -25,6 +25,14 @@ class Channel {
25
25
  /** @type {string[]} */
26
26
  #tags = []
27
27
 
28
+ /**
29
+ * How each queue was declared, so that the queue holding what it could not
30
+ * process is declared to live exactly as long as it does.
31
+ *
32
+ * @type {Map<string, comq.amqp.options.Queue>}
33
+ */
34
+ #queues = new Map()
35
+
28
36
  /** @type {Promex | null} */
29
37
  #paused = null
30
38
 
@@ -71,6 +79,9 @@ class Channel {
71
79
  // the consumers of the previous channel went down with it, their tags mean nothing here
72
80
  this.#tags = []
73
81
 
82
+ // a fresh channel has declared nothing
83
+ this.#queues.clear()
84
+
74
85
  await this.#channel.prefetch(this.#topology.prefetch)
75
86
 
76
87
  this.#channel.on('drain', this.#unpause)
@@ -249,6 +260,8 @@ class Channel {
249
260
 
250
261
  const { queue } = await this.#channel.assertQueue(name, options)
251
262
 
263
+ this.#queues.set(queue, options)
264
+
252
265
  return [queue]
253
266
  }
254
267
 
@@ -308,6 +321,80 @@ class Channel {
308
321
  return [exchange, queue]
309
322
  }
310
323
 
324
+ /**
325
+ * The queue a failed message waits in before it is delivered again. It has no consumer:
326
+ * the broker holds the message for the delay and then dead letters it, and because no
327
+ * routing key is configured the message keeps its own, which names the queue it came
328
+ * from. One queue serves every source queue that shares the delay.
329
+ *
330
+ * It is published to through a fanout exchange rather than directly. Published directly,
331
+ * the message's routing key would be this queue's own name, and on expiry the broker
332
+ * would route it back here — a cycle it resolves by dropping the message at the first
333
+ * expiry, silently.
334
+ *
335
+ * @returns {Promise<void>}
336
+ */
337
+ async #assertRetryQueue () {
338
+ if (!this.#topology.acknowledgments) return
339
+
340
+ // one queue per distinct wait, declared here rather than when a message first fails:
341
+ // asserting a queue from inside the failure handler is the least likely moment for it
342
+ // to succeed, and a failure there would spin the message without ever delaying it
343
+ for (const delay of new Set(this.#delays)) {
344
+ const name = RETRY_PREFIX + delay
345
+
346
+ await this.#channel.assertExchange(name, 'fanout', DURABLE)
347
+
348
+ await this.#assertQueue(name, {
349
+ ...DURABLE,
350
+ arguments: {
351
+ 'x-message-ttl': delay,
352
+ 'x-dead-letter-exchange': DEFAULT
353
+ }
354
+ })
355
+
356
+ await this.#channel.bindQueue(name, name, DEFAULT)
357
+ }
358
+ }
359
+
360
+ /**
361
+ * The queue a message is kept in once it has run out of attempts. It has no consumer
362
+ * either: what is in it is waiting for a person.
363
+ *
364
+ * It is declared to live exactly as long as the queue it serves, which for a groupless
365
+ * subscriber means an exclusive queue whose contents go when the connection does. That
366
+ * is deliberate: such a subscriber is ephemeral by construction, and a durable queue per
367
+ * generated name would leak one per restart, forever. An ephemeral subscriber's failures
368
+ * are ephemeral too.
369
+ *
370
+ * @param {string} queue the queue the message was consumed from
371
+ * @returns {Promise<void>}
372
+ */
373
+ async #assertParkedQueue (queue) {
374
+ if (!this.#topology.acknowledgments) return
375
+
376
+ const options = this.#queues.get(queue) ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
377
+
378
+ await this.#assertQueue(parkedQueueOf(queue), options)
379
+ }
380
+
381
+ /** The waits between attempts, as a ladder even when it is one rung. */
382
+ get #delays () {
383
+ const delay = this.#topology.delay
384
+
385
+ return Array.isArray(delay) ? delay : [delay]
386
+ }
387
+
388
+ /**
389
+ * The queue a message waits in after its nth attempt, and the exchange it is published
390
+ * through: they share a name.
391
+ *
392
+ * @param {number} attempt the attempt that just failed, counting from one
393
+ */
394
+ #retryQueueOf (attempt) {
395
+ return RETRY_PREFIX + this.#delays[attempt - 1]
396
+ }
397
+
311
398
  // endregion
312
399
 
313
400
  /**
@@ -345,69 +432,145 @@ class Channel {
345
432
  }
346
433
 
347
434
  /**
348
- * @param {string} queue
349
- * @param {comq.channels.Consumer} consumer
350
- * @returns {Promise<string>}
435
+ * The retry topology is asserted once per channel and a parked queue once per queue:
436
+ * `lazy` keys an initializer by the arguments it takes, and both again after a recovery.
351
437
  */
352
- async #consume (queue, consumer) {
353
- /** @type {comq.amqp.options.Consume} */
354
- const options = {}
438
+ #consume = lazy(this, [this.#assertRetryQueue, this.#assertParkedQueue],
439
+ /**
440
+ * @param {string} queue
441
+ * @param {comq.channels.Consumer} consumer
442
+ * @returns {Promise<string>}
443
+ */
444
+ async (queue, consumer) => {
445
+ /** @type {comq.amqp.options.Consume} */
446
+ const options = {}
355
447
 
356
- if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(consumer)
357
- else options.noAck = true
448
+ if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(queue, consumer)
449
+ else options.noAck = true
358
450
 
359
- const response = await this.#channel.consume(queue, consumer, options)
451
+ const response = await this.#channel.consume(queue, consumer, options)
360
452
 
361
- this.#tags.push(response.consumerTag)
453
+ this.#tags.push(response.consumerTag)
362
454
 
363
- return response.consumerTag
364
- }
455
+ return response.consumerTag
456
+ })
365
457
 
366
458
  /**
459
+ * @param {string} queue the queue being consumed
367
460
  * @param {comq.channels.Consumer} consumer
368
461
  * @returns {comq.channels.Consumer}
369
462
  */
370
- #getAcknowledgingConsumer = (consumer) =>
463
+ #getAcknowledgingConsumer = (queue, consumer) =>
371
464
  async (message) => {
372
465
  try {
373
466
  await consumer(message)
374
467
 
375
468
  this.#channel.ack(message)
376
469
  } catch (exception) {
377
- if (exception.message === 'Channel closed') { return } // the message is requeued by the broker
470
+ if (exception?.message === 'Channel closed') { return } // the message is requeued by the broker
378
471
 
379
- const redeliveries = message.properties.headers[REDELIVERY_HEADER] ?? 0
380
-
381
- if (redeliveries >= MAX_REDELIVERIES) this.#discard(message, exception)
382
- else {
383
- await this.#requeue(message, redeliveries)
384
- throw exception
385
- }
472
+ await this.#failed(queue, message, exception)
386
473
  }
387
474
  }
388
475
 
389
476
  /**
477
+ * A message its consumer could not handle is delayed and given another attempt, and
478
+ * kept once it has had enough of them.
479
+ *
480
+ * Nothing here may reject. amqplib dispatches a delivery through an event emitter and
481
+ * drops the promise it gets back, so a rejection has nobody to catch it and ends the
482
+ * process. What cannot be carried out is handed back to the broker instead.
483
+ *
484
+ * @param {string} queue
485
+ * @param {comq.amqp.Message} message
486
+ * @param {Error} exception
487
+ */
488
+ async #failed (queue, message, exception) {
489
+ try {
490
+ // the header is which attempt this delivery is, and the first does not carry one
491
+ const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 1
492
+
493
+ // one rung per retry: a message that has climbed the ladder has nowhere left to wait
494
+ if (attempt > this.#delays.length) await this.#park(queue, message, exception)
495
+ else await this.#retry(queue, message, attempt, exception)
496
+ } catch {
497
+ // the message could not be moved: give the delivery back rather than lose it
498
+ this.#return(message)
499
+ }
500
+ }
501
+
502
+ /**
503
+ * @param {string} queue
390
504
  * @param {comq.amqp.Message} message
391
505
  * @param {number} attempt
506
+ * @param {Error} exception
392
507
  */
393
- async #requeue (message, attempt) {
394
- this.#channel.ack(message)
508
+ async #retry (queue, message, attempt, exception) {
509
+ const properties = this.#carry(message, { [ATTEMPT_HEADER]: attempt + 1 })
395
510
 
396
- message.properties.headers[REDELIVERY_HEADER] = attempt + 1
511
+ // the copy is placed before the original is released: a message the broker holds
512
+ // twice can be recovered, one it no longer holds at all cannot
513
+ await this.#publish(this.#retryQueueOf(attempt), queue, message.content, properties)
397
514
 
398
- await this.seal()
399
- await this.#publish(message.fields.exchange, message.fields.routingKey, message.content, message.properties)
515
+ this.#channel.ack(message)
516
+
517
+ // the attempt that just failed, rather than the one it is about to get
518
+ this.#diagnostics.emit('retry', message, exception, attempt)
400
519
  }
401
520
 
402
521
  /**
522
+ * @param {string} queue
403
523
  * @param {comq.amqp.Message} message
404
524
  * @param {Error} [exception]
405
525
  */
406
- #discard (message, exception) {
407
- this.#channel.nack(message, false, false)
526
+ async #park (queue, message, exception) {
527
+ const properties = this.#carry(message, {
528
+ [PARKED_QUEUE_HEADER]: queue,
529
+ [PARKED_REASON_HEADER]: exception?.message,
530
+ [PARKED_AT_HEADER]: Date.now()
531
+ })
532
+
533
+ await this.#publish(DEFAULT, parkedQueueOf(queue), message.content, properties)
534
+
535
+ this.#channel.ack(message)
536
+
408
537
  this.#diagnostics.emit('discard', message, exception)
409
538
  }
410
539
 
540
+ /**
541
+ * The properties a copy of a failed message is published with.
542
+ *
543
+ * The exchange and routing key are recorded on the first failure and carried from
544
+ * then on: a message that has been through the retry queue comes back through the
545
+ * default exchange, so by the time it is parked its own fields describe that hop
546
+ * rather than where it was published.
547
+ *
548
+ * It is published persistent whatever the channel is: Requests are transient for the
549
+ * sake of latency, and the path a failed message takes is not the one latency is on.
550
+ *
551
+ * @param {comq.amqp.Message} message
552
+ * @param {object} added
553
+ * @returns {comq.amqp.options.Publish}
554
+ */
555
+ #carry (message, added) {
556
+ const headers = {
557
+ [ORIGIN_EXCHANGE_HEADER]: message.fields?.exchange,
558
+ [ORIGIN_KEY_HEADER]: message.fields?.routingKey,
559
+ ...message.properties.headers,
560
+ ...added
561
+ }
562
+
563
+ return { ...message.properties, headers, persistent: true, mandatory: true }
564
+ }
565
+
566
+ /**
567
+ * @param {comq.amqp.Message} message
568
+ */
569
+ #return (message) {
570
+ // a channel that is already gone requeues what it held anyway
571
+ try { this.#channel.nack(message, false, true) } catch { /* nothing left to do with it */ }
572
+ }
573
+
411
574
  #pause () {
412
575
  if (this.#paused !== null) return
413
576
 
@@ -478,8 +641,25 @@ const EXCLUSIVE = { exclusive: true }
478
641
 
479
642
  const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
480
643
 
481
- const MAX_REDELIVERIES = 5
482
- const REDELIVERY_HEADER = 'x-attempt'
644
+ const RETRY_PREFIX = 'comq.retry.'
645
+ const PARKED_PREFIX = 'comq.parked.'
646
+
647
+ const parkedQueueOf = (queue) => PARKED_PREFIX + queue
648
+
649
+ // Everything comq writes onto a message, under its own prefix: AMQP defines no retry
650
+ // counter, so this one is comq's invention rather than a convention, and an unprefixed
651
+ // name would be free to collide with the application's own headers or another library's.
652
+ //
653
+ // What a person looking at a parked message needs, and nothing else. The broker's own
654
+ // `x-death` is no substitute: it names the retry queue rather than where the message
655
+ // came from, and its `count` is a second counter that agrees with this one until it
656
+ // does not.
657
+ const ATTEMPT_HEADER = 'x-comq-attempt'
658
+ const ORIGIN_EXCHANGE_HEADER = 'x-comq-exchange'
659
+ const ORIGIN_KEY_HEADER = 'x-comq-key'
660
+ const PARKED_QUEUE_HEADER = 'x-comq-queue'
661
+ const PARKED_REASON_HEADER = 'x-comq-reason'
662
+ const PARKED_AT_HEADER = 'x-comq-at'
483
663
 
484
664
  function noop () {}
485
665
 
package/source/connect.js CHANGED
@@ -6,45 +6,55 @@ const { SingletonConnection } = require('./singleton')
6
6
  const shards = require('./shards')
7
7
 
8
8
  /** @type {comq.Connect} */
9
- const connect = async (...urls) => {
10
- return create(urls, connectionConstructor(Connection))
9
+ const connect = async (...args) => {
10
+ return create(args, Connection)
11
11
  }
12
12
 
13
13
  /** @type {comq.Connect} */
14
- const assert = async (...urls) => {
15
- return create(urls, connectionConstructor(SingletonConnection))
14
+ const assert = async (...args) => {
15
+ return create(args, SingletonConnection)
16
16
  }
17
17
 
18
18
  /**
19
- * @param {string[]} urls
20
- * @param {(urls: string[]) => comq.Connection} constructor
19
+ * The urls are variadic, so the overrides are the trailing argument when there is one
20
+ * that is not a url.
21
+ *
22
+ * @param {(string | comq.topology.Overrides)[]} args
23
+ * @return {[string[], comq.topology.Overrides]}
24
+ */
25
+ const split = (args) => {
26
+ const last = args[args.length - 1]
27
+
28
+ const urls = /** @type {string[]} */ (typeof last === 'object' ? args.slice(0, -1) : args)
29
+ const overrides = /** @type {comq.topology.Overrides} */ (typeof last === 'object' ? last : {})
30
+
31
+ return [urls, overrides]
32
+ }
33
+
34
+ /**
35
+ * @param {(string | comq.topology.Overrides)[]} args
36
+ * @param {new (url: string, overrides: comq.topology.Overrides) => comq.Connection} ConnectionClass
21
37
  * @return {Promise<IO>}
22
38
  */
23
- const create = async (urls, constructor) => {
24
- const connection = constructor(urls)
39
+ const create = async (args, ConnectionClass) => {
40
+ const [urls, overrides] = split(args)
41
+ const connection = connectionOf(urls, overrides, ConnectionClass)
25
42
 
26
43
  await connection.open()
27
44
 
28
45
  return new IO(connection)
29
46
  }
30
47
 
31
- const connectionConstructor = (ConnectionClass) =>
32
- /**
33
- * @param {string[]} urls
34
- * @return {comq.Connection}
35
- */
36
- (urls) => {
37
- if (urls.length === 1) return new ConnectionClass(urls[0])
38
- else return shardedConnection(urls, ConnectionClass)
39
- }
40
-
41
48
  /**
42
49
  * @param {string[]} urls
43
- * @param {function} ConnectionClass
50
+ * @param {comq.topology.Overrides} overrides
51
+ * @param {Function} ConnectionClass
44
52
  * @return {comq.Connection}
45
53
  */
46
- const shardedConnection = (urls, ConnectionClass) => {
47
- const connections = urls.map((url) => new ConnectionClass(url))
54
+ const connectionOf = (urls, overrides, ConnectionClass) => {
55
+ const connections = urls.map((url) => new ConnectionClass(url, overrides))
56
+
57
+ if (connections.length === 1) return connections[0]
48
58
 
49
59
  return new shards.Connection(connections)
50
60
  }
@@ -38,6 +38,9 @@ class Connection {
38
38
  /** @type {NodeJS.Timeout | null} */
39
39
  #heartbeatTimer = null
40
40
 
41
+ /** @type {comq.topology.Overrides} */
42
+ #overrides
43
+
41
44
  /** @type {import('node:net').Socket | null} */
42
45
  #heartbeatSocket = null
43
46
 
@@ -49,8 +52,13 @@ class Connection {
49
52
  /**
50
53
  * @param {string} url
51
54
  */
52
- constructor (url) {
55
+ /**
56
+ * @param {string} url
57
+ * @param {comq.topology.Overrides} [overrides] per channel type, merged over the presets
58
+ */
59
+ constructor (url, overrides = {}) {
53
60
  this.#url = heartbeaten(url)
61
+ this.#overrides = overrides
54
62
  }
55
63
 
56
64
  get connected () {
@@ -89,7 +97,8 @@ class Connection {
89
97
  async (type, index) => {
90
98
  if (this.#connection === undefined) await this.#recovery
91
99
 
92
- const topology = presets[type]
100
+ // a copy: the presets are shared by every connection in the process
101
+ const topology = { ...presets[type], ...this.#overrides[type] }
93
102
 
94
103
  // a closed channel held here would hold its IO, and a shared connection is
95
104
  // in no hurry to make another channel that would have swept it out
package/source/events.js CHANGED
@@ -4,4 +4,4 @@
4
4
  exports.connection = ['open', 'close', 'error', 'reconnect', 'exhausted']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
- exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'retry', 'pause', 'resume', 'return', 'lost', 'remove']
@@ -64,6 +64,10 @@ class Channel {
64
64
  await this.#every((channel) => channel.subscribe(queue, group, consumer))
65
65
  }
66
66
 
67
+ async bound (exchange, queue, key, consumer) {
68
+ await this.#every((channel) => channel.bound(exchange, queue, key, consumer))
69
+ }
70
+
67
71
  async send (queue, buffer, options) {
68
72
  await this.#one((channel) => channel.send(queue, buffer, options))
69
73
  }
@@ -72,6 +76,10 @@ class Channel {
72
76
  await this.#one((channel) => channel.publish(exchange, buffer, options))
73
77
  }
74
78
 
79
+ async route (exchange, key, buffer, options) {
80
+ await this.#one((channel) => channel.route(exchange, key, buffer, options))
81
+ }
82
+
75
83
  async fire (queue, buffer, options) {
76
84
  // noinspection JSValidateTypes
77
85
  return await this.#one((channel) => channel.fire(queue, buffer, options))
@@ -9,12 +9,32 @@ class SingletonConnection extends Connection {
9
9
  /** @type {Promise<void>} */
10
10
  #opened = null
11
11
 
12
- constructor (url) {
13
- if (instances.has(url)) return instances.get(url)
14
-
15
- super(url)
12
+ /** @type {comq.topology.Overrides} */
13
+ #overrides
14
+
15
+ /**
16
+ * An instance is shared, so its topology is whatever the first caller asked for.
17
+ * A later caller asking for something else would get a connection retrying on a
18
+ * ladder it did not choose, which is expensive to debug and cheap to refuse.
19
+ *
20
+ * @param {string} url
21
+ * @param {comq.topology.Overrides} [overrides]
22
+ */
23
+ constructor (url, overrides = {}) {
24
+ const instance = instances.get(url)
25
+
26
+ if (instance !== undefined) {
27
+ if (!same(instance.#overrides, overrides)) {
28
+ throw new Error(`Connection to ${url} has already been asserted with a different topology`)
29
+ }
30
+
31
+ return instance
32
+ }
33
+
34
+ super(url, overrides)
16
35
 
17
36
  this.#url = url
37
+ this.#overrides = overrides
18
38
 
19
39
  instances.set(url, this)
20
40
  }
@@ -65,4 +85,27 @@ const instances = new Map()
65
85
  /** @type {Map<string, number>} */
66
86
  const counters = new Map()
67
87
 
88
+ /**
89
+ * Topology overrides are a couple of small plain objects, so this is enough and
90
+ * does not pull in a dependency for it.
91
+ *
92
+ * @param {object} one
93
+ * @param {object} another
94
+ * @returns {boolean}
95
+ */
96
+ function same (one, another) {
97
+ return JSON.stringify(sorted(one)) === JSON.stringify(sorted(another))
98
+ }
99
+
100
+ /**
101
+ * @param {any} value
102
+ * @returns {any} the value with every object's keys in a stable order
103
+ */
104
+ function sorted (value) {
105
+ if (Array.isArray(value)) return value.map(sorted)
106
+ if (value === null || typeof value !== 'object') return value
107
+
108
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]))
109
+ }
110
+
68
111
  exports.SingletonConnection = SingletonConnection
@@ -3,5 +3,11 @@
3
3
  "confirms": true,
4
4
  "durable": true,
5
5
  "acknowledgments": true,
6
- "persistent": true
6
+ "persistent": true,
7
+ "delay": [
8
+ 1000,
9
+ 10000,
10
+ 30000,
11
+ 90000
12
+ ]
7
13
  }
@@ -3,5 +3,11 @@
3
3
  "confirms": false,
4
4
  "durable": true,
5
5
  "acknowledgments": true,
6
- "persistent": false
6
+ "persistent": false,
7
+ "delay": [
8
+ 1000,
9
+ 3000,
10
+ 5000,
11
+ 10000
12
+ ]
7
13
  }
@@ -39,7 +39,7 @@ it('should return instance of IO', async () => {
39
39
 
40
40
  it('should create singleton connection', async () => {
41
41
  expect(SingletonConnection).toHaveBeenCalled()
42
- expect(SingletonConnection).toHaveBeenCalledWith(url)
42
+ expect(SingletonConnection).toHaveBeenCalledWith(url, {})
43
43
  expect(IO).toHaveBeenCalledWith(SingletonConnection.mock.instances[0])
44
44
  })
45
45
 
@@ -50,7 +50,7 @@ it('should create sharded connection', async () => {
50
50
 
51
51
  io = await assert(...urls)
52
52
 
53
- for (const url of urls) expect(SingletonConnection).toHaveBeenCalledWith(url)
53
+ for (const url of urls) expect(SingletonConnection).toHaveBeenCalledWith(url, {})
54
54
 
55
55
  expect(shards.Connection).toHaveBeenCalledWith(SingletonConnection.mock.instances)
56
56
  expect(IO).toHaveBeenCalledWith(shards.Connection.mock.instances[0])
@@ -7,7 +7,8 @@ const preset = () => ({
7
7
  confirms: flip(),
8
8
  durable: flip(),
9
9
  acknowledgments: flip(),
10
- persistent: flip()
10
+ persistent: flip(),
11
+ delay: [1000, 1000, 1000, 1000]
11
12
  })
12
13
 
13
14
  exports.preset = preset
@@ -93,7 +93,7 @@ describe('bound', () => {
93
93
 
94
94
  const { queue: asserted } = await chan.assertQueue.mock.results[0].value
95
95
 
96
- expect(chan.bindQueue).toHaveBeenCalledTimes(1)
96
+ expect(bindings()).toHaveLength(1)
97
97
  expect(chan.bindQueue).toHaveBeenCalledWith(asserted, exchange, key)
98
98
  })
99
99
 
@@ -107,13 +107,17 @@ describe('bound', () => {
107
107
  await channel.bound(exchange, queue, key, consumer)
108
108
  await channel.bound(exchange, queue, key, consumer)
109
109
 
110
- expect(chan.bindQueue).toHaveBeenCalledTimes(1)
110
+ expect(bindings()).toHaveLength(1)
111
111
  })
112
112
 
113
113
  it('should bind again for another key', async () => {
114
114
  await channel.bound(exchange, queue, key, consumer)
115
115
  await channel.bound(exchange, generate(), generate(), consumer)
116
116
 
117
- expect(chan.bindQueue).toHaveBeenCalledTimes(2)
117
+ expect(bindings()).toHaveLength(2)
118
118
  })
119
+
120
+ /** The bindings of the caller, less the one comq makes for its own retry queue. */
121
+ const bindings = () =>
122
+ chan.bindQueue.mock.calls.filter(([, exchange]) => !exchange.startsWith('comq.'))
119
123
  })