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/docs/headers.md CHANGED
@@ -15,3 +15,40 @@ to [amqplib.publish](https://amqp-node.github.io/amqplib/channel_api.html#channe
15
15
 
16
16
  `consumer` function's signature
17
17
  is `async? (payload: any, [properties: comq.amqp.Properties]): void`
18
+
19
+ ## Headers set by comq
20
+
21
+ A message that failed at least once carries headers comq wrote, and a consumer sees them among
22
+ the message properties.
23
+
24
+ | Header | On | Meaning |
25
+ |---|---|---|
26
+ | `x-comq-attempt` | a retried message | Which attempt this delivery is, counting from one. Absent on the first, so read it as `headers?.['x-comq-attempt'] ?? 1`. The last attempt is one more than the number of rungs in the [backoff ladder](../readme.md#retries). |
27
+ | `x-comq-exchange` | a retried or parked message | The exchange it was originally published to. |
28
+ | `x-comq-key` | a retried or parked message | The routing key it was originally published with. |
29
+ | `x-comq-queue` | a parked message | The queue it was consumed from. |
30
+ | `x-comq-reason` | a parked message | The `message` of the exception that ended its attempts. |
31
+ | `x-comq-cause` | a parked message | The `message` of that exception's `cause`, when it has one — a verdict is usually thrown with the failure that prompted it. |
32
+ | `x-comq-at` | a parked message | When it was parked, as `Date.now()`. |
33
+
34
+ `x-comq-exchange` and `x-comq-key` are recorded on the first failure rather than read at parking
35
+ time: a message returning from the retry queue arrives through the default exchange, so by then
36
+ its own delivery fields describe that hop rather than where it was published.
37
+
38
+ AMQP defines no retry counter, so `x-comq-attempt` is comq's own rather than a convention —
39
+ implementations that roll their own commonly use `x-retry-count` or `x-retries`, and none of
40
+ those are standard either. It is prefixed for the same reason as the rest: an unprefixed name
41
+ would be free to collide with the application's own headers, or with another library's.
42
+
43
+ > Before this, the header was named `x-attempt`. A message already in flight under the old name
44
+ > is read as a first delivery and gets a full ladder of attempts rather than the remainder of
45
+ > one.
46
+
47
+ A retried message also carries RabbitMQ's own [`x-death`](https://www.rabbitmq.com/docs/dlx),
48
+ whose `x-death[0].count` looks like a second attempt counter. It is not the same number: it
49
+ counts the times the message expired out of a retry queue, where `x-comq-attempt` counts the
50
+ times it was delivered, so on a parked message with the default four-rung ladder they read `4`
51
+ and `5`.
52
+
53
+ **`x-comq-attempt` is the authoritative one** — it is comq's, and it is what the `attempts`
54
+ setting is compared against. `x-death` is the broker's record, useful for its timestamps.
@@ -1,22 +1,53 @@
1
1
  Feature: Poison events
2
2
 
3
+ A message whose consumer throws is retried a few times and then kept, and the
4
+ consumers around it go on working.
5
+
3
6
  Background:
4
7
  Given an active connection to the broker
5
8
 
6
- @manual
7
- Scenario: Sending a poison event
9
+ Scenario: A poison event is retried and then parked
10
+
11
+ Four attempts, the default: the first delivery and three retries, each waiting
12
+ longer than the last. The counter is the `x-comq-attempt` header, which the first
13
+ delivery does not carry.
14
+
15
+ Given that events from the `poison_retried` exchange are causing exceptions
16
+ When an event is emitted to the `poison_retried` exchange
17
+ Then the event is attempted 4 times
18
+ And the message is parked
19
+ And the parked message is kept, and says it came from the `poison_retried` exchange
20
+
21
+ Scenario: A poison event does not stop the other consumers
22
+
23
+ The events channel is shared by every consumption in the process, so a
24
+ failure that sealed it would take this second consumer down with it.
25
+
26
+ Given that events from the `poison_isolated` exchange are causing exceptions
27
+ And that `witness` is consuming events from the `poison_witness` exchange
28
+ When an event is emitted to the `poison_isolated` exchange
29
+ And after 300ms
30
+ And an event is emitted to the `poison_witness` exchange
31
+ Then `witness` receives the event
32
+
33
+ Scenario: A retried event is not redelivered to the other consumers of its exchange
8
34
 
9
- This scenario will cause an exception, run it once.
35
+ The exchange is a fanout: republishing the retry to it rather than to the
36
+ retry queue would hand the message to every subscriber again, once per attempt.
10
37
 
11
- Given that events from the `poison` exchange are causing exceptions
12
- Then an event is emitted to the `poison` exchange
13
- And after 100ms
38
+ Given that events from the `poison_open` exchange are causing exceptions
39
+ And that `bystander` is consuming events from the `poison_open` exchange
40
+ When an event is emitted to the `poison_open` exchange
41
+ And after 1000ms
42
+ Then `bystander` has received 1 event
14
43
 
15
- @manual
16
- Scenario: Consuming poison events
44
+ Scenario: A consumer refusing a message parks it at once
17
45
 
18
- After the poison event is sent, run this scenario five times
19
- and verify that the poison event was discarded.
46
+ A `Park` says the consumer will never process this message, so climbing the
47
+ ladder would only delay the inevitable.
20
48
 
21
- Given that events from the `poison` exchange are causing exceptions
22
- And after 100ms
49
+ Given that events from the `poison_refused` exchange are refused as poison
50
+ When an event is emitted to the `poison_refused` exchange
51
+ Then the event is attempted 1 time
52
+ And the message is parked
53
+ And the parked message is kept, and says it came from the `poison_refused` exchange
@@ -18,6 +18,9 @@ class Context extends World {
18
18
  eventsPublishedCount = 0
19
19
  eventsConsumedCount = 0
20
20
  events = {}
21
+ attempts = []
22
+ counts = {}
23
+ parked = {}
21
24
  processed
22
25
  enqueued
23
26
  tasksProcessedCount = 0
@@ -63,6 +66,9 @@ class Context extends World {
63
66
  this.io = undefined
64
67
  this.connected = false
65
68
  this.events = {}
69
+ this.attempts = []
70
+ this.counts = {}
71
+ this.parked = {}
66
72
  }
67
73
 
68
74
  /**
@@ -73,7 +79,9 @@ class Context extends World {
73
79
  async #connect (urls, method = connect) {
74
80
  if (this.io !== undefined) await this.disconnect()
75
81
 
76
- this.io = await method(...urls)
82
+ // the retry delay is a topology setting, so the suite need not wait out a
83
+ // production one to see the mechanism work
84
+ this.io = await method(...urls, TOPOLOGY)
77
85
  this.connected = true
78
86
 
79
87
  for (const event of EVENTS) this.io.diagnose(event, () => (this.events[event] = true))
@@ -121,6 +129,14 @@ class Context extends World {
121
129
  const PROTOCOL = 'amqp://'
122
130
 
123
131
  /** @type {comq.diagnostics.Event[]} */
124
- const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume', 'exhausted']
132
+ const EVENTS = ['open', 'close', 'flow', 'discard', 'retry', 'pause', 'resume', 'exhausted']
133
+
134
+ /** @type {comq.topology.Overrides} */
135
+ // as many rungs as the presets have, so the suite exercises the shipped attempt count,
136
+ // at a wall clock it can wait out
137
+ const LADDER = [50, 100, 150, 200]
138
+
139
+ /** @type {comq.topology.Overrides} */
140
+ const TOPOLOGY = { event: { delay: LADDER }, request: { delay: LADDER } }
125
141
 
126
142
  exports.Context = Context
@@ -38,13 +38,31 @@ Given('(that )events are exclusively consumed from the {token} exchange',
38
38
  this.consumptionPromise = consume.call(this, undefined, exchange)
39
39
  })
40
40
 
41
+ Given('(that )events from the {token} exchange are refused as poison',
42
+ /**
43
+ * @param {string} exchange
44
+ * @this {comq.features.Context}
45
+ */
46
+ async function (exchange) {
47
+ const { Park } = require('../../')
48
+
49
+ await this.io.consume(exchange, 'exceptions', (payload, properties) => {
50
+ this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
51
+
52
+ throw new Park('Expected refusal')
53
+ })
54
+ })
55
+
41
56
  Given('(that )events from the {token} exchange are causing exceptions',
42
57
  /**
43
58
  * @param {string} exchange
44
59
  * @this {comq.features.Context}
45
60
  */
46
61
  async function (exchange) {
47
- await this.io.consume(exchange, 'exceptions', () => {
62
+ await this.io.consume(exchange, 'exceptions', (payload, properties) => {
63
+ // absent on the first delivery, which is attempt one
64
+ this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
65
+
48
66
  throw new Error('Expected exception')
49
67
  })
50
68
  })
@@ -181,6 +199,7 @@ async function consume (group, exchange) {
181
199
  const consumer = async (payload, properties) => {
182
200
  this.consumed[group] = { payload, properties }
183
201
  this.eventsConsumedCount++
202
+ this.counts[group] = (this.counts[group] ?? 0) + 1
184
203
  }
185
204
 
186
205
  return group === undefined
@@ -1,15 +1,77 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { timeout } = require('../../test/helpers')
4
+ const { until } = require('../../test/helpers')
5
5
  const { Then } = require('@cucumber/cucumber')
6
6
 
7
- Then('the message is discarded',
7
+ Then('the event is attempted {int} time(s)',
8
+ /**
9
+ * @param {number} times
10
+ * @this {comq.features.Context}
11
+ */
12
+ async function (times) {
13
+ await until(() => this.attempts.length >= times)
14
+
15
+ const expected = Array.from({ length: times }, (_, index) => index + 1)
16
+
17
+ assert.deepEqual(this.attempts, expected,
18
+ `Expected attempts ${expected.join(', ')} but saw ${this.attempts.join(', ')}`)
19
+ })
20
+
21
+ Then('the message is parked',
8
22
  /**
9
23
  * @this {comq.features.Context}
10
24
  */
11
25
  async function () {
12
- if (this.events.discard !== true) await timeout(300)
26
+ await until(() => this.events.discard === true)
27
+
28
+ assert.equal(this.events.discard, true, 'The message was not parked')
29
+ })
30
+
31
+ Then('the parked message is kept, and says it came from the {token} exchange',
32
+ /**
33
+ * @param {string} exchange
34
+ * @this {comq.features.Context}
35
+ */
36
+ async function (exchange) {
37
+ const queue = exchange + '..exceptions' // the group the throwing consumer uses
38
+ const parked = 'comq.parked.' + queue
39
+
40
+ await this.io.process(parked, (payload, properties) => {
41
+ this.parked[queue] = { payload, properties }
42
+ })
43
+
44
+ await until(() => this.parked[queue] !== undefined)
45
+
46
+ const message = this.parked[queue]
47
+
48
+ assert.notEqual(message, undefined, `Nothing was parked in ${parked}`)
49
+
50
+ const headers = message.properties.headers
51
+
52
+ assert.equal(headers['x-comq-queue'], queue, 'The parked message does not name its queue')
53
+ assert.notEqual(headers['x-comq-reason'], undefined, 'The parked message has no reason')
54
+
55
+ // the message came back through the default exchange on every retry, so this
56
+ // is only right if the origin was recorded on the first failure
57
+ assert.equal(headers['x-comq-exchange'], exchange,
58
+ `The parked message says it came from '${headers['x-comq-exchange']}'`)
59
+
60
+ // absent on a message parked from its first delivery, exactly as a consumer reads it;
61
+ // derived rather than stated, so it cannot drift from the configured ladder
62
+ assert.equal(headers['x-comq-attempt'] ?? 1, this.attempts.length,
63
+ 'The parked message did not have as many attempts as the consumer saw')
64
+ })
65
+
66
+ Then('{token} has received {int} event(s)',
67
+ /**
68
+ * @param {string} group
69
+ * @param {number} count
70
+ * @this {comq.features.Context}
71
+ */
72
+ async function (group, count) {
73
+ await until(() => this.counts[group] >= count)
13
74
 
14
- assert.equal(this.events.discard, true, 'The {message} was not discarded')
75
+ assert.equal(this.counts[group] ?? 0, count,
76
+ `Expected ${group} to receive ${count} event(s), got ${this.counts[group] ?? 0}`)
15
77
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -425,6 +425,24 @@ dynamic, such as those that depend on runtime data like incoming messages, makin
425
425
  impossible or hard to maintain. The tradeoff of potentially encountering runtime topology
426
426
  declaration exceptions, which are more likely to happen during development, is deemed acceptable.
427
427
 
428
+ ### Settings
429
+
430
+ Each channel type has a preset, and the trailing argument of `connect` overrides it:
431
+
432
+ ```javascript
433
+ const io = await comq.connect(url, {
434
+ event: { delay: [5000, 60000] }, // two retries
435
+ request: { delay: 1000 } // one
436
+ })
437
+ ```
438
+
439
+ `delay` governs [retries](#retries); the rest of [the settings](./types/topology.d.ts) are not
440
+ meant to be changed.
441
+
442
+ > Changing `delay` declares new retry queues rather than redeclaring the existing ones, so a
443
+ > rolling deploy that changes it has no window in which either version fails. The queues left
444
+ > behind are empty and can be removed once nothing is publishing to them.
445
+
428
446
  ### Channels
429
447
 
430
448
  `IO` lazy creates individual channels for Requests, Replies, and Events.
@@ -450,6 +468,12 @@ requests and are expecting replies.
450
468
  the other: asserting it as both is what the broker refuses.
451
469
  - Queues for Replies are _exclusive_ and _auto deleted_.
452
470
 
471
+ comq declares two kinds of queue of its own, for [failed messages](#retries):
472
+
473
+ - `comq.retry.<delay>`, with a fanout exchange of the same name, one pair per distinct rung of
474
+ the [backoff ladder](#retries), shared by every queue that uses it.
475
+ - `comq.parked.<queue>`, one per consumed queue, declared to live as long as it does.
476
+
453
477
  See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.html#channel_assertQueue).
454
478
 
455
479
  ### Messages
@@ -461,27 +485,125 @@ See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.ht
461
485
  manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#acknowledgment-modes),
462
486
  and Replies are consumed using automatic mode.
463
487
 
464
- If an incoming message causes an exception, the corresponding channel is sealed, the message is republished, and the
465
- exception is thrown.
466
- If the message causes exceptions five times in a row, it is discarded.
488
+ #### Retries
489
+
490
+ If an incoming message causes an exception, comq publishes it to a *retry queue* and only then
491
+ acknowledges the original. The retry queue has no consumer: it holds the message for
492
+ `delay` milliseconds and then returns it to the queue it came from, so the wait is the broker's
493
+ and outlives a restart of this process without holding a delivery against the
494
+ [prefetch limit](#channels).
495
+
496
+ Each attempt increments the [`x-comq-attempt`](./docs/headers.md) header, which the consumer
497
+ receives among the message properties. `delay` is a backoff ladder with **one rung per retry**,
498
+ so its length decides how many there are: the four rungs of the default are five attempts, and
499
+ once a message has climbed it there is nowhere left to wait and it is *parked*.
500
+
501
+ The channel keeps consuming throughout. A message one consumer cannot handle stops neither the
502
+ other consumers nor that consumer's next message; only the message that failed is delayed.
503
+
504
+ `delay` is a [topology](#topology) setting:
505
+
506
+ | | ladder | attempts | total |
507
+ |---|---|---|---|
508
+ | Event | 1s, 10s, 30s, 90s | 5 | 131s |
509
+ | Request | 1s, 3s, 5s, 10s | 5 | 19s |
510
+
511
+ Requests are shorter because a caller is blocked on one with no timeout, and a Reply arriving
512
+ long after it gave up has nowhere useful to land. Nobody waits on an Event.
513
+
514
+ One retry queue and one exchange are declared per distinct wait and shared by every queue that
515
+ uses them, so their number grows with the length of the ladder rather than with the number of
516
+ queues.
517
+
518
+ #### Saying how it failed
519
+
520
+ A consumer can classify its failure, which decides whether the message is worth another
521
+ attempt at all:
522
+
523
+ ```javascript
524
+ const { Retry, Park } = require('comq')
525
+
526
+ await io.consume('orders', 'billing', async (order) => {
527
+ if (typeof order.total !== 'number') throw new Park('no total on order')
528
+
529
+ try {
530
+ await billing.charge(order)
531
+ } catch (e) {
532
+ throw new Retry('billing unavailable', { cause: e })
533
+ }
534
+ })
535
+ ```
536
+
537
+ - `Park` says this consumer will never process this message, however many times it is handed
538
+ over. It is [parked](#parked-messages) at once, without climbing the ladder.
539
+ - `Retry` says whatever was missing may be back shortly. This is what an unclassified rejection
540
+ already means, so throwing it changes nothing but the reader's certainty.
541
+
542
+ **A bare rejection means `Retry`** — a failure nobody classified is a failure nobody chose — so
543
+ consumers written before this behave exactly as they did.
544
+
545
+ Both are `Error`s and both take a `cause`, which is recorded on the parked message alongside the
546
+ verdict's own message.
547
+
548
+ > **`Park` is not applicable to a `Producer` given to [`IO.reply`](#reply).** A Request
549
+ > has a caller awaiting a reply, so what happens to a failed one is not a policy choice. A `Park`
550
+ > thrown there is treated as an ordinary failure — retried, then parked on the count — and the
551
+ > parked message says so as its reason.
552
+
553
+ #### Parked messages
554
+
555
+ A message that has run out of attempts is published to `comq.parked.<queue>` and acknowledged
556
+ only once the broker confirms it. It is not deleted, and it does not depend on a broker-side
557
+ policy. The [`discard`](#diagnostics) diagnostic event is emitted when it happens, and
558
+ [`retry`](#diagnostics) on every attempt before it.
559
+
560
+ A parked message carries what a person looking at it needs: `x-comq-exchange` and `x-comq-key`
561
+ name where it was originally published, `x-comq-queue` the queue it was consumed from,
562
+ `x-comq-reason` the exception's message, and `x-comq-at` when it was parked. Its original
563
+ properties are kept as they were.
564
+
565
+ Parked queues grow until somebody drains them, which is deliberate — the alternative is deleting
566
+ evidence. Alert on [`discard`](#diagnostics), and do not delete `comq.retry.*` or `comq.parked.*`
567
+ queues on a running system.
568
+
569
+ > **A parked Request is never answered.** A Consumer awaiting its Reply waits indefinitely, and
570
+ > with a limited prefetch that can deadlock it. Parking keeps the Request rather than deleting
571
+ > it — and it keeps `replyTo` and `correlationId`, so a Reply can still be produced from it by
572
+ > hand while the caller is alive — but comq itself sends no Reply and reports no error to the
573
+ > caller. Give a Request a timeout on the calling side if you cannot tolerate that.
574
+
575
+ #### What is guaranteed
576
+
577
+ The copy is published *before* the original is acknowledged, so a process that dies between the
578
+ two leaves the broker holding both and the message is handled twice: **consumers must be
579
+ idempotent**. That is the deliberate trade — a message the broker holds twice can be recovered,
580
+ one it no longer holds at all cannot.
581
+
582
+ A retried message re-enters its queue behind the messages published while it waited, so
583
+ **ordering is not preserved across a failure**.
467
584
 
468
- > It is highly recommended to set up a dead letter exchange policy to analyze messages that caused
469
- > exceptions. Note that in some cases, if the problematic message is a Request, a Consumer will
470
- > never receive a Reply, and this can result in a prefetch deadlock of a Consumer.
585
+ Retries and parked messages are published *persistent* whatever the channel is, so they survive a
586
+ restart of the broker even on the Request channel. Ordinary publishing is untouched: Requests and
587
+ Replies stay [delivery mode 1](#messages), and only a message that has already failed is written
588
+ to disk. Two weaker points remain on that channel: it
589
+ does not use publisher confirms, so comq has no positive acknowledgement that the broker took the
590
+ copy; and the return hop of a retry is performed by the broker's dead-lettering, which on classic
591
+ queues is at-most-once and can lose the message if the source queue is unavailable when the delay
592
+ expires.
471
593
 
472
594
  See:
473
595
 
474
596
  - [Consumer Acknowledgments and Publisher Confirms](https://www.rabbitmq.com/confirms.html)
475
- - [Negative Acknowledgment and Requeuing of Deliveries](https://www.rabbitmq.com/confirms.html#consumer-nacks-requeue)
476
597
  - [Dead Letter Exchanges](https://www.rabbitmq.com/dlx.html)
598
+ - [At-Least-Once Dead Lettering](https://www.rabbitmq.com/blog/2022/03/29/at-least-once-dead-lettering)
477
599
 
478
600
  ### Cheatsheet
479
601
 
480
- | Message | Prefetch | Confirms | Queue | Acknowledgment | Persistent |
481
- |---------|-----------|----------|-----------|----------------|------------|
482
- | Request | limited | no | durable | manual | no |
483
- | Reply | unlimited | no | exclusive | automatic | no |
484
- | Event | limited | yes | durable | manual | yes |
602
+ | Message | Prefetch | Confirms | Queue | Acknowledgment | Persistent | Retries |
603
+ |---------|-----------|----------|-----------|----------------|------------|------------------|
604
+ | Request | limited | no | durable | manual | no | 1s, 3s, 5s, 10s |
605
+ | Reply | unlimited | no | exclusive | automatic | no | — |
606
+ | Event | limited | yes | durable | manual | yes | 1s, 10s, 30s, 90s |
485
607
 
486
608
  ## Graceful shutdown
487
609
 
@@ -553,10 +675,12 @@ Subscribe to one of the diagnostic events:
553
675
  - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
554
676
  re-sent. Channel type is passed.
555
677
  - `recover`: channel's topology is recovered. Channel type is passed.
556
- - `discard`: message is [discarded](#messages) as it repeatedly caused
557
- exceptions. Channel type,
678
+ - `discard`: message is [parked](#parked-messages), having run out of attempts. Channel type,
558
679
  raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
559
680
  and the exception are passed as arguments.
681
+ - `retry`: message caused an exception and has been published to its
682
+ [retry queue](#retries). Channel type, the raw amqp message object, the exception and the
683
+ attempt number are passed as arguments.
560
684
  - `return`: message is returned by the broker as unroutable. Channel type and the raw
561
685
  [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish) are
562
686
  passed as arguments. In the case of a [sharded connection](#sharded-connection), the message is