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/.github/workflows/{dev.yaml → features.yaml} +14 -3
- package/discussions/poison-message-handling.md +1124 -0
- package/docs/headers.md +37 -0
- package/features/events.poison.feature +43 -12
- package/features/steps/context.js +18 -2
- package/features/steps/events.js +20 -1
- package/features/steps/poison.js +66 -4
- package/package.json +1 -1
- package/readme.md +138 -14
- package/source/channel.js +218 -30
- package/source/connect.js +31 -21
- package/source/connection.js +11 -2
- package/source/events.js +1 -1
- package/source/index.js +4 -0
- package/source/io.js +27 -1
- package/source/singleton.js +47 -4
- package/source/topology/event.json +7 -1
- package/source/topology/request.json +7 -1
- package/source/verdicts.js +54 -0
- package/test/assert.test.js +2 -2
- package/test/channel.fixtures.js +2 -1
- package/test/channel.routed.test.js +7 -3
- package/test/channel.test.js +697 -5
- package/test/connect.test.js +31 -2
- package/test/helpers.js +15 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.reply.test.js +66 -0
- package/test/presets.test.js +4 -2
- package/test/singleton.test.js +1 -1
- package/test/verdicts.test.js +57 -0
- package/types/connection.d.ts +6 -1
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +17 -0
- package/types/io.d.ts +3 -1
- package/types/topology.d.ts +16 -0
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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 `
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
Scenario: Consuming poison events
|
|
44
|
+
Scenario: A consumer refusing a message parks it at once
|
|
17
45
|
|
|
18
|
-
|
|
19
|
-
|
|
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 `
|
|
22
|
-
|
|
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
|
-
|
|
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
|
package/features/steps/events.js
CHANGED
|
@@ -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
|
package/features/steps/poison.js
CHANGED
|
@@ -1,15 +1,77 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
|
-
const {
|
|
4
|
+
const { until } = require('../../test/helpers')
|
|
5
5
|
const { Then } = require('@cucumber/cucumber')
|
|
6
6
|
|
|
7
|
-
Then('the
|
|
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
|
-
|
|
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.
|
|
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
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
|
-
|
|
465
|
-
|
|
466
|
-
If
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 [
|
|
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
|