comq 0.17.1 → 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/docs/headers.md CHANGED
@@ -15,3 +15,39 @@ 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-at` | a parked message | When it was parked, as `Date.now()`. |
32
+
33
+ `x-comq-exchange` and `x-comq-key` are recorded on the first failure rather than read at parking
34
+ time: a message returning from the retry queue arrives through the default exchange, so by then
35
+ its own delivery fields describe that hop rather than where it was published.
36
+
37
+ AMQP defines no retry counter, so `x-comq-attempt` is comq's own rather than a convention —
38
+ implementations that roll their own commonly use `x-retry-count` or `x-retries`, and none of
39
+ those are standard either. It is prefixed for the same reason as the rest: an unprefixed name
40
+ would be free to collide with the application's own headers, or with another library's.
41
+
42
+ > Before this, the header was named `x-attempt`. A message already in flight under the old name
43
+ > is read as a first delivery and gets a full ladder of attempts rather than the remainder of
44
+ > one.
45
+
46
+ A retried message also carries RabbitMQ's own [`x-death`](https://www.rabbitmq.com/docs/dlx),
47
+ whose `x-death[0].count` looks like a second attempt counter. It is not the same number: it
48
+ counts the times the message expired out of a retry queue, where `x-comq-attempt` counts the
49
+ times it was delivered, so on a parked message with the default four-rung ladder they read `4`
50
+ and `5`.
51
+
52
+ **`x-comq-attempt` is the authoritative one** — it is comq's, and it is what the `attempts`
53
+ setting is compared against. `x-death` is the broker's record, useful for its timestamps.
@@ -1,22 +1,42 @@
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
8
22
 
9
- This scenario will cause an exception, run it once.
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.
10
25
 
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
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
14
32
 
15
- @manual
16
- Scenario: Consuming poison events
33
+ Scenario: A retried event is not redelivered to the other consumers of its exchange
17
34
 
18
- After the poison event is sent, run this scenario five times
19
- and verify that the poison event was discarded.
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.
20
37
 
21
- Given that events from the `poison` exchange are causing exceptions
22
- 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
@@ -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
@@ -44,7 +44,10 @@ Given('(that )events from the {token} exchange are causing exceptions',
44
44
  * @this {comq.features.Context}
45
45
  */
46
46
  async function (exchange) {
47
- await this.io.consume(exchange, 'exceptions', () => {
47
+ await this.io.consume(exchange, 'exceptions', (payload, properties) => {
48
+ // absent on the first delivery, which is attempt one
49
+ this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
50
+
48
51
  throw new Error('Expected exception')
49
52
  })
50
53
  })
@@ -181,6 +184,7 @@ async function consume (group, exchange) {
181
184
  const consumer = async (payload, properties) => {
182
185
  this.consumed[group] = { payload, properties }
183
186
  this.eventsConsumedCount++
187
+ this.counts[group] = (this.counts[group] ?? 0) + 1
184
188
  }
185
189
 
186
190
  return group === undefined
@@ -1,15 +1,76 @@
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
+ // derived rather than stated, so it cannot drift from the configured ladder
61
+ assert.equal(headers['x-comq-attempt'], this.attempts.length,
62
+ 'The parked message was not retried to exhaustion')
63
+ })
64
+
65
+ Then('{token} has received {int} event(s)',
66
+ /**
67
+ * @param {string} group
68
+ * @param {number} count
69
+ * @this {comq.features.Context}
70
+ */
71
+ async function (group, count) {
72
+ await until(() => this.counts[group] >= count)
13
73
 
14
- assert.equal(this.events.discard, true, 'The {message} was not discarded')
74
+ assert.equal(this.counts[group] ?? 0, count,
75
+ `Expected ${group} to receive ${count} event(s), got ${this.counts[group] ?? 0}`)
15
76
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.17.1",
3
+ "version": "0.18.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,90 @@ 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
+ #### Parked messages
519
+
520
+ A message that has run out of attempts is published to `comq.parked.<queue>` and acknowledged
521
+ only once the broker confirms it. It is not deleted, and it does not depend on a broker-side
522
+ policy. The [`discard`](#diagnostics) diagnostic event is emitted when it happens, and
523
+ [`retry`](#diagnostics) on every attempt before it.
524
+
525
+ A parked message carries what a person looking at it needs: `x-comq-exchange` and `x-comq-key`
526
+ name where it was originally published, `x-comq-queue` the queue it was consumed from,
527
+ `x-comq-reason` the exception's message, and `x-comq-at` when it was parked. Its original
528
+ properties are kept as they were.
529
+
530
+ Parked queues grow until somebody drains them, which is deliberate — the alternative is deleting
531
+ evidence. Alert on [`discard`](#diagnostics), and do not delete `comq.retry.*` or `comq.parked.*`
532
+ queues on a running system.
533
+
534
+ > **A parked Request is never answered.** A Consumer awaiting its Reply waits indefinitely, and
535
+ > with a limited prefetch that can deadlock it. Parking keeps the Request rather than deleting
536
+ > it — and it keeps `replyTo` and `correlationId`, so a Reply can still be produced from it by
537
+ > hand while the caller is alive — but comq itself sends no Reply and reports no error to the
538
+ > caller. Give a Request a timeout on the calling side if you cannot tolerate that.
539
+
540
+ #### What is guaranteed
541
+
542
+ The copy is published *before* the original is acknowledged, so a process that dies between the
543
+ two leaves the broker holding both and the message is handled twice: **consumers must be
544
+ idempotent**. That is the deliberate trade — a message the broker holds twice can be recovered,
545
+ one it no longer holds at all cannot.
546
+
547
+ A retried message re-enters its queue behind the messages published while it waited, so
548
+ **ordering is not preserved across a failure**.
467
549
 
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.
550
+ Retries and parked messages are published *persistent* whatever the channel is, so they survive a
551
+ restart of the broker even on the Request channel. Ordinary publishing is untouched: Requests and
552
+ Replies stay [delivery mode 1](#messages), and only a message that has already failed is written
553
+ to disk. Two weaker points remain on that channel: it
554
+ does not use publisher confirms, so comq has no positive acknowledgement that the broker took the
555
+ copy; and the return hop of a retry is performed by the broker's dead-lettering, which on classic
556
+ queues is at-most-once and can lose the message if the source queue is unavailable when the delay
557
+ expires.
471
558
 
472
559
  See:
473
560
 
474
561
  - [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
562
  - [Dead Letter Exchanges](https://www.rabbitmq.com/dlx.html)
563
+ - [At-Least-Once Dead Lettering](https://www.rabbitmq.com/blog/2022/03/29/at-least-once-dead-lettering)
477
564
 
478
565
  ### Cheatsheet
479
566
 
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 |
567
+ | Message | Prefetch | Confirms | Queue | Acknowledgment | Persistent | Retries |
568
+ |---------|-----------|----------|-----------|----------------|------------|------------------|
569
+ | Request | limited | no | durable | manual | no | 1s, 3s, 5s, 10s |
570
+ | Reply | unlimited | no | exclusive | automatic | no | — |
571
+ | Event | limited | yes | durable | manual | yes | 1s, 10s, 30s, 90s |
485
572
 
486
573
  ## Graceful shutdown
487
574
 
@@ -553,10 +640,12 @@ Subscribe to one of the diagnostic events:
553
640
  - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
554
641
  re-sent. Channel type is passed.
555
642
  - `recover`: channel's topology is recovered. Channel type is passed.
556
- - `discard`: message is [discarded](#messages) as it repeatedly caused
557
- exceptions. Channel type,
643
+ - `discard`: message is [parked](#parked-messages), having run out of attempts. Channel type,
558
644
  raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
559
645
  and the exception are passed as arguments.
646
+ - `retry`: message caused an exception and has been published to its
647
+ [retry queue](#retries). Channel type, the raw amqp message object, the exception and the
648
+ attempt number are passed as arguments.
560
649
  - `return`: message is returned by the broker as unroutable. Channel type and the raw
561
650
  [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish) are
562
651
  passed as arguments. In the case of a [sharded connection](#sharded-connection), the message is