comq 0.17.1 → 0.19.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
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { Promex } = require('promex')
4
4
  const { failsafe, lazy, recall } = require('./attributes')
5
+ const { verdictOf, PARK } = require('./verdicts')
5
6
  const emitter = require('./emitter')
6
7
 
7
8
  /**
@@ -25,6 +26,14 @@ class Channel {
25
26
  /** @type {string[]} */
26
27
  #tags = []
27
28
 
29
+ /**
30
+ * How each queue was declared, so that the queue holding what it could not
31
+ * process is declared to live exactly as long as it does.
32
+ *
33
+ * @type {Map<string, comq.amqp.options.Queue>}
34
+ */
35
+ #queues = new Map()
36
+
28
37
  /** @type {Promex | null} */
29
38
  #paused = null
30
39
 
@@ -71,6 +80,9 @@ class Channel {
71
80
  // the consumers of the previous channel went down with it, their tags mean nothing here
72
81
  this.#tags = []
73
82
 
83
+ // a fresh channel has declared nothing
84
+ this.#queues.clear()
85
+
74
86
  await this.#channel.prefetch(this.#topology.prefetch)
75
87
 
76
88
  this.#channel.on('drain', this.#unpause)
@@ -249,6 +261,8 @@ class Channel {
249
261
 
250
262
  const { queue } = await this.#channel.assertQueue(name, options)
251
263
 
264
+ this.#queues.set(queue, options)
265
+
252
266
  return [queue]
253
267
  }
254
268
 
@@ -308,6 +322,80 @@ class Channel {
308
322
  return [exchange, queue]
309
323
  }
310
324
 
325
+ /**
326
+ * The queue a failed message waits in before it is delivered again. It has no consumer:
327
+ * the broker holds the message for the delay and then dead letters it, and because no
328
+ * routing key is configured the message keeps its own, which names the queue it came
329
+ * from. One queue serves every source queue that shares the delay.
330
+ *
331
+ * It is published to through a fanout exchange rather than directly. Published directly,
332
+ * the message's routing key would be this queue's own name, and on expiry the broker
333
+ * would route it back here — a cycle it resolves by dropping the message at the first
334
+ * expiry, silently.
335
+ *
336
+ * @returns {Promise<void>}
337
+ */
338
+ async #assertRetryQueue () {
339
+ if (!this.#topology.acknowledgments) return
340
+
341
+ // one queue per distinct wait, declared here rather than when a message first fails:
342
+ // asserting a queue from inside the failure handler is the least likely moment for it
343
+ // to succeed, and a failure there would spin the message without ever delaying it
344
+ for (const delay of new Set(this.#delays)) {
345
+ const name = RETRY_PREFIX + delay
346
+
347
+ await this.#channel.assertExchange(name, 'fanout', DURABLE)
348
+
349
+ await this.#assertQueue(name, {
350
+ ...DURABLE,
351
+ arguments: {
352
+ 'x-message-ttl': delay,
353
+ 'x-dead-letter-exchange': DEFAULT
354
+ }
355
+ })
356
+
357
+ await this.#channel.bindQueue(name, name, DEFAULT)
358
+ }
359
+ }
360
+
361
+ /**
362
+ * The queue a message is kept in once it has run out of attempts. It has no consumer
363
+ * either: what is in it is waiting for a person.
364
+ *
365
+ * It is declared to live exactly as long as the queue it serves, which for a groupless
366
+ * subscriber means an exclusive queue whose contents go when the connection does. That
367
+ * is deliberate: such a subscriber is ephemeral by construction, and a durable queue per
368
+ * generated name would leak one per restart, forever. An ephemeral subscriber's failures
369
+ * are ephemeral too.
370
+ *
371
+ * @param {string} queue the queue the message was consumed from
372
+ * @returns {Promise<void>}
373
+ */
374
+ async #assertParkedQueue (queue) {
375
+ if (!this.#topology.acknowledgments) return
376
+
377
+ const options = this.#queues.get(queue) ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
378
+
379
+ await this.#assertQueue(parkedQueueOf(queue), options)
380
+ }
381
+
382
+ /** The waits between attempts, as a ladder even when it is one rung. */
383
+ get #delays () {
384
+ const delay = this.#topology.delay
385
+
386
+ return Array.isArray(delay) ? delay : [delay]
387
+ }
388
+
389
+ /**
390
+ * The queue a message waits in after its nth attempt, and the exchange it is published
391
+ * through: they share a name.
392
+ *
393
+ * @param {number} attempt the attempt that just failed, counting from one
394
+ */
395
+ #retryQueueOf (attempt) {
396
+ return RETRY_PREFIX + this.#delays[attempt - 1]
397
+ }
398
+
311
399
  // endregion
312
400
 
313
401
  /**
@@ -345,69 +433,151 @@ class Channel {
345
433
  }
346
434
 
347
435
  /**
348
- * @param {string} queue
349
- * @param {comq.channels.Consumer} consumer
350
- * @returns {Promise<string>}
436
+ * The retry topology is asserted once per channel and a parked queue once per queue:
437
+ * `lazy` keys an initializer by the arguments it takes, and both again after a recovery.
351
438
  */
352
- async #consume (queue, consumer) {
353
- /** @type {comq.amqp.options.Consume} */
354
- const options = {}
439
+ #consume = lazy(this, [this.#assertRetryQueue, this.#assertParkedQueue],
440
+ /**
441
+ * @param {string} queue
442
+ * @param {comq.channels.Consumer} consumer
443
+ * @returns {Promise<string>}
444
+ */
445
+ async (queue, consumer) => {
446
+ /** @type {comq.amqp.options.Consume} */
447
+ const options = {}
355
448
 
356
- if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(consumer)
357
- else options.noAck = true
449
+ if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(queue, consumer)
450
+ else options.noAck = true
358
451
 
359
- const response = await this.#channel.consume(queue, consumer, options)
452
+ const response = await this.#channel.consume(queue, consumer, options)
360
453
 
361
- this.#tags.push(response.consumerTag)
454
+ this.#tags.push(response.consumerTag)
362
455
 
363
- return response.consumerTag
364
- }
456
+ return response.consumerTag
457
+ })
365
458
 
366
459
  /**
460
+ * @param {string} queue the queue being consumed
367
461
  * @param {comq.channels.Consumer} consumer
368
462
  * @returns {comq.channels.Consumer}
369
463
  */
370
- #getAcknowledgingConsumer = (consumer) =>
464
+ #getAcknowledgingConsumer = (queue, consumer) =>
371
465
  async (message) => {
372
466
  try {
373
467
  await consumer(message)
374
468
 
375
469
  this.#channel.ack(message)
376
470
  } catch (exception) {
377
- if (exception.message === 'Channel closed') { return } // the message is requeued by the broker
471
+ if (exception?.message === 'Channel closed') { return } // the message is requeued by the broker
378
472
 
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
- }
473
+ await this.#failed(queue, message, exception)
386
474
  }
387
475
  }
388
476
 
389
477
  /**
478
+ * A message its consumer could not handle is delayed and given another attempt, and
479
+ * kept once it has had enough of them.
480
+ *
481
+ * Nothing here may reject. amqplib dispatches a delivery through an event emitter and
482
+ * drops the promise it gets back, so a rejection has nobody to catch it and ends the
483
+ * process. What cannot be carried out is handed back to the broker instead.
484
+ *
485
+ * @param {string} queue
486
+ * @param {comq.amqp.Message} message
487
+ * @param {Error} exception
488
+ */
489
+ async #failed (queue, message, exception) {
490
+ try {
491
+ // the header is which attempt this delivery is, and the first does not carry one
492
+ const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 1
493
+
494
+ // a consumer that says the message will never work is taken at its word; otherwise
495
+ // one rung per retry, and a message that has climbed the ladder has nowhere left to wait
496
+ const parking = verdictOf(exception) === PARK || attempt > this.#delays.length
497
+
498
+ if (parking) await this.#park(queue, message, exception)
499
+ else await this.#retry(queue, message, attempt, exception)
500
+ } catch {
501
+ // the message could not be moved: give the delivery back rather than lose it
502
+ this.#return(message)
503
+ }
504
+ }
505
+
506
+ /**
507
+ * @param {string} queue
390
508
  * @param {comq.amqp.Message} message
391
509
  * @param {number} attempt
510
+ * @param {Error} exception
392
511
  */
393
- async #requeue (message, attempt) {
394
- this.#channel.ack(message)
512
+ async #retry (queue, message, attempt, exception) {
513
+ const properties = this.#carry(message, { [ATTEMPT_HEADER]: attempt + 1 })
395
514
 
396
- message.properties.headers[REDELIVERY_HEADER] = attempt + 1
515
+ // the copy is placed before the original is released: a message the broker holds
516
+ // twice can be recovered, one it no longer holds at all cannot
517
+ await this.#publish(this.#retryQueueOf(attempt), queue, message.content, properties)
397
518
 
398
- await this.seal()
399
- await this.#publish(message.fields.exchange, message.fields.routingKey, message.content, message.properties)
519
+ this.#channel.ack(message)
520
+
521
+ // the attempt that just failed, rather than the one it is about to get
522
+ this.#diagnostics.emit('retry', message, exception, attempt)
400
523
  }
401
524
 
402
525
  /**
526
+ * @param {string} queue
403
527
  * @param {comq.amqp.Message} message
404
528
  * @param {Error} [exception]
405
529
  */
406
- #discard (message, exception) {
407
- this.#channel.nack(message, false, false)
530
+ async #park (queue, message, exception) {
531
+ const properties = this.#carry(message, {
532
+ [PARKED_QUEUE_HEADER]: queue,
533
+ [PARKED_REASON_HEADER]: exception?.message,
534
+ // a verdict is usually thrown with the exception that prompted it, and that one
535
+ // says what actually went wrong
536
+ [PARKED_CAUSE_HEADER]: exception?.cause?.message,
537
+ [PARKED_AT_HEADER]: Date.now()
538
+ })
539
+
540
+ await this.#publish(DEFAULT, parkedQueueOf(queue), message.content, properties)
541
+
542
+ this.#channel.ack(message)
543
+
408
544
  this.#diagnostics.emit('discard', message, exception)
409
545
  }
410
546
 
547
+ /**
548
+ * The properties a copy of a failed message is published with.
549
+ *
550
+ * The exchange and routing key are recorded on the first failure and carried from
551
+ * then on: a message that has been through the retry queue comes back through the
552
+ * default exchange, so by the time it is parked its own fields describe that hop
553
+ * rather than where it was published.
554
+ *
555
+ * It is published persistent whatever the channel is: Requests are transient for the
556
+ * sake of latency, and the path a failed message takes is not the one latency is on.
557
+ *
558
+ * @param {comq.amqp.Message} message
559
+ * @param {object} added
560
+ * @returns {comq.amqp.options.Publish}
561
+ */
562
+ #carry (message, added) {
563
+ const headers = {
564
+ [ORIGIN_EXCHANGE_HEADER]: message.fields?.exchange,
565
+ [ORIGIN_KEY_HEADER]: message.fields?.routingKey,
566
+ ...message.properties.headers,
567
+ ...added
568
+ }
569
+
570
+ return { ...message.properties, headers, persistent: true, mandatory: true }
571
+ }
572
+
573
+ /**
574
+ * @param {comq.amqp.Message} message
575
+ */
576
+ #return (message) {
577
+ // a channel that is already gone requeues what it held anyway
578
+ try { this.#channel.nack(message, false, true) } catch { /* nothing left to do with it */ }
579
+ }
580
+
411
581
  #pause () {
412
582
  if (this.#paused !== null) return
413
583
 
@@ -478,8 +648,26 @@ const EXCLUSIVE = { exclusive: true }
478
648
 
479
649
  const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
480
650
 
481
- const MAX_REDELIVERIES = 5
482
- const REDELIVERY_HEADER = 'x-attempt'
651
+ const RETRY_PREFIX = 'comq.retry.'
652
+ const PARKED_PREFIX = 'comq.parked.'
653
+
654
+ const parkedQueueOf = (queue) => PARKED_PREFIX + queue
655
+
656
+ // Everything comq writes onto a message, under its own prefix: AMQP defines no retry
657
+ // counter, so this one is comq's invention rather than a convention, and an unprefixed
658
+ // name would be free to collide with the application's own headers or another library's.
659
+ //
660
+ // What a person looking at a parked message needs, and nothing else. The broker's own
661
+ // `x-death` is no substitute: it names the retry queue rather than where the message
662
+ // came from, and its `count` is a second counter that agrees with this one until it
663
+ // does not.
664
+ const ATTEMPT_HEADER = 'x-comq-attempt'
665
+ const ORIGIN_EXCHANGE_HEADER = 'x-comq-exchange'
666
+ const ORIGIN_KEY_HEADER = 'x-comq-key'
667
+ const PARKED_QUEUE_HEADER = 'x-comq-queue'
668
+ const PARKED_REASON_HEADER = 'x-comq-reason'
669
+ const PARKED_CAUSE_HEADER = 'x-comq-cause'
670
+ const PARKED_AT_HEADER = 'x-comq-at'
483
671
 
484
672
  function noop () {}
485
673
 
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']
package/source/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  const { connect, assert } = require('./connect')
4
+ const { Retry, Park } = require('./verdicts')
4
5
 
5
6
  exports.connect = connect
6
7
  exports.assert = assert
8
+
9
+ exports.Retry = Retry
10
+ exports.Park = Park
package/source/io.js CHANGED
@@ -4,6 +4,7 @@ const stream = require('node:stream')
4
4
  const { setTimeout } = require('node:timers/promises')
5
5
  const { Promex } = require('promex')
6
6
  const { memo, failsafe, lazy, track } = require('./attributes')
7
+ const { verdictOf, PARK } = require('./verdicts')
7
8
 
8
9
  const { decode } = require('./decode')
9
10
  const { encode } = require('./encode')
@@ -302,7 +303,7 @@ class IO {
302
303
  */
303
304
  async (request) => {
304
305
  const payload = decode(request)
305
- const reply = await producer(payload)
306
+ const reply = await produce(producer, payload)
306
307
 
307
308
  if (request.properties.replyTo === undefined) return
308
309
 
@@ -549,4 +550,29 @@ const DEFAULT = 'application/json'
549
550
 
550
551
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
551
552
 
553
+ /**
554
+ * A verdict answers what should happen to a message now that it has failed and nobody
555
+ * is waiting for it. A Producer has a caller waiting, and what it is owed is a reply,
556
+ * so `Park` is a category error there rather than a policy choice — comq's own type
557
+ * vocabulary already draws the line between a Consumer and a Producer.
558
+ *
559
+ * It cannot be caught at wiring time, since nothing there knows what a producer will
560
+ * throw. Caught here instead and re-thrown as an ordinary rejection, which retries and
561
+ * eventually parks, carrying an explanation the parked message keeps as its reason.
562
+ *
563
+ * @param {comq.Producer} producer
564
+ * @param {any} payload
565
+ */
566
+ async function produce (producer, payload) {
567
+ try {
568
+ return await producer(payload)
569
+ } catch (exception) {
570
+ if (verdictOf(exception) !== PARK) throw exception
571
+
572
+ throw new Error('Park is not applicable to a reply producer: a Request has a caller ' +
573
+ 'awaiting a reply, so it is retried and parked on the count like any other failure',
574
+ { cause: exception })
575
+ }
576
+ }
577
+
552
578
  exports.IO = IO
@@ -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
  }
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * How a consumer says what should happen to a message it could not handle.
5
+ *
6
+ * The brand is a registered symbol rather than the class identity: comq is a library,
7
+ * and a dependency that pins it while the application resolves its own tree makes two
8
+ * copies of it the ordinary outcome. `instanceof` is silently `false` across those,
9
+ * which would read a `Park` as an unclassified rejection and retry it to exhaustion
10
+ * before parking it anyway. The global symbol registry is shared; a class is not.
11
+ */
12
+ const VERDICT = Symbol.for('comq.verdict')
13
+
14
+ const RETRY = 'retry'
15
+ const PARK = 'park'
16
+
17
+ /**
18
+ * Whatever the consumer needed was briefly not there. This is what an unclassified
19
+ * rejection already means, so throwing it changes nothing but the reader's certainty.
20
+ */
21
+ class Retry extends Error {
22
+ [VERDICT] = RETRY
23
+
24
+ name = 'Retry'
25
+ }
26
+
27
+ /**
28
+ * This consumer will never process this message, however many times it is handed over.
29
+ * The message is kept rather than retried.
30
+ */
31
+ class Park extends Error {
32
+ [VERDICT] = PARK
33
+
34
+ name = 'Park'
35
+ }
36
+
37
+ /**
38
+ * A rejection nobody classified is a rejection nobody chose: it means Retry.
39
+ *
40
+ * @param {any} exception
41
+ * @returns {'retry' | 'park'}
42
+ */
43
+ const verdictOf = (exception) => {
44
+ const verdict = exception?.[VERDICT]
45
+
46
+ return verdict === PARK || verdict === RETRY ? verdict : RETRY
47
+ }
48
+
49
+ exports.Retry = Retry
50
+ exports.Park = Park
51
+ exports.verdictOf = verdictOf
52
+ exports.VERDICT = VERDICT
53
+ exports.RETRY = RETRY
54
+ exports.PARK = PARK
@@ -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