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.
@@ -0,0 +1,1124 @@
1
+ # Fix comq's poison-message handling
2
+
3
+ > **The design record for how comq handles a message its consumer could not process.**
4
+ > Reviewed from the consumer side in
5
+ > [#272](https://github.com/toa-io/comq/discussions/272) and revised there: the retry queue
6
+ > became shared and delay-named, the delay became configurable, and message provenance moved to
7
+ > where it survives a retry.
8
+ >
9
+ > **All three parts are implemented.** Text below is written as a proposal because that is what
10
+ > it was; it describes what the code now does.
11
+
12
+ When a consumer callback rejects, comq is supposed to retry the message a few times and then
13
+ give up on it. What it actually does is kill the process, and take every other consumer in that
14
+ process with it. Six verified defects, all in [source/channel.js](../source/channel.js) — full
15
+ detail in **Context** at the end.
16
+
17
+ One of the six — the last attempt silently deleting the message — needs a mechanism comq does
18
+ not have today, and the obvious candidate is not one. That comes first, because everything else
19
+ is routine by comparison.
20
+
21
+ - **Part 1** — where a message goes when comq gives up on it. comq parks it in a queue it owns.
22
+ Includes why RabbitMQ's dead-lettering is *not* the mechanism for this, and the one
23
+ compatibility trap to avoid.
24
+ - **Part 2** — the other five defects. No upgrade impact.
25
+ - **Part 3** — the proposed `Retry` / `Park` verdict API, and what it does and does not change
26
+ here (short answer: it adds one branch to Part 2, and one naming decision to Part 1).
27
+
28
+ ---
29
+
30
+ # Part 1 — Where a message goes when comq gives up on it
31
+
32
+ ## The problem
33
+
34
+ The last failed attempt destroys the message. `#discard` ([:406](../source/channel.js#L406)) is:
35
+
36
+ ```js
37
+ this.#channel.nack(message, false, false)
38
+ ```
39
+
40
+ `requeue: false` means "the broker may dead-letter this" — but only if the queue was declared
41
+ with a dead-letter exchange, and **comq never declares one**. `grep` for `x-dead-letter` or
42
+ `arguments:` across `source/` returns nothing; `#assertQueue`
43
+ ([:246-253](../source/channel.js#L246)) passes only `{ durable: true }` or `{ exclusive: true }`.
44
+ So the message is silently deleted. [readme.md:468](../readme.md#L468) tells the reader to
45
+ configure a policy on the broker by hand — a library whose documented failure mode is silent
46
+ deletion.
47
+
48
+ ## The mechanism: comq parks it in a queue it owns
49
+
50
+ On the last attempt, comq publishes the message to `comq.parked.<queue>`, waits for the publisher
51
+ confirm, and only then acks the original.
52
+
53
+ **This is about retention, not delivery.** The message is not going to be processed again — that
54
+ is settled. What is at stake is whether it is still *there* afterwards. A message that has
55
+ exhausted its attempts is evidence that something is wrong: a bad producer, a schema change, a
56
+ bug in the consumer. Today comq deletes that evidence. The requirement is that when comq says it
57
+ has kept the message, that is true and not merely likely.
58
+
59
+ Publishing before acking is what makes it true: the broker holds the parked copy before it
60
+ releases the original, so there is no window in which it holds neither. If comq dies between the
61
+ two, the original is redelivered and parked again — two copies of the same evidence in a queue
62
+ nothing consumes, which is harmless. (Consumers do need to be idempotent, but because of the
63
+ retry path and redelivery generally, not because of this.)
64
+
65
+ > ### Two mechanisms, easily confused
66
+ >
67
+ > | | **Part 2 — retry** | **Part 1 — parking** |
68
+ > |---|---|---|
69
+ > | when | attempts 1–5 | after the 6th failure |
70
+ > | queue | the shared retry queue | `comq.parked.<queue>` |
71
+ > | how it gets there | comq publishes it | comq publishes it |
72
+ > | what happens next | the broker returns it to the source queue after the TTL | nothing — it waits for a human |
73
+ > | purpose | try again later | keep the evidence |
74
+ >
75
+ > Both are new queues comq declares, and **dead-lettering appears in both doing different
76
+ > jobs**: in Part 2 it is the routing trick that carries a message *back* to its source when the
77
+ > TTL fires; in Part 1 it is the rejected way of moving a message to its final resting place.
78
+
79
+ ## Why RabbitMQ's dead-lettering is not the mechanism
80
+
81
+ The obvious alternative is to declare `x-dead-letter-exchange` on the source queue and let the
82
+ broker move the message. It was considered and rejected, for a reason worth recording because
83
+ the readme currently recommends exactly this to users.
84
+
85
+ **Dead-lettering is a routing rule, not a retention guarantee.** It is what the broker does with
86
+ messages it has already given up on, and best-effort is what it was designed to be. RabbitMQ
87
+ republishes to the DLX *internally, without publisher confirms*: a dead-lettered message can be
88
+ lost if the target queue is unavailable, if its length limit is reached under `reject-publish`
89
+ overflow, or across a network partition. If the dead-letter exchange does not exist, messages
90
+ are silently dropped.
91
+
92
+ At-least-once dead-lettering does exist, but requires **quorum queues** plus
93
+ `dead-letter-strategy: at-least-once` plus `overflow: reject-publish`. **Classic queues do not
94
+ support it**, and comq declares classic queues — `#assertQueue` passes `{ durable: true }` with
95
+ no `x-queue-type`.
96
+
97
+ So this was never a choice between two implementations of one thing. We need the message kept;
98
+ dead-lettering does not promise to keep it. Using it would reintroduce silent loss on the exact
99
+ path built to prevent it — narrower than today, but the same class of bug.
100
+
101
+ - [Dead Letter Exchanges](https://www.rabbitmq.com/docs/dlx)
102
+ - [At-Least-Once Dead Lettering](https://www.rabbitmq.com/blog/2022/03/29/at-least-once-dead-lettering)
103
+ - [Quorum Queues](https://www.rabbitmq.com/docs/quorum-queues)
104
+
105
+ > **If comq ever moves to quorum queues** — with `dead-letter-strategy: at-least-once` and
106
+ > `overflow: reject-publish` — dead-lettering becomes lossless and this decision is worth
107
+ > revisiting. It would also close the Part 2 retry queue's caveat. Much larger change; its own
108
+ > decision, someday.
109
+
110
+ ## The compatibility trap this avoids
111
+
112
+ Worth stating explicitly, because it is the reason the rejected alternative is *also* expensive:
113
+ declaring `x-dead-letter-exchange` means adding `arguments` to `#assertQueue`. Every durable
114
+ queue in every comq deployment today was declared **without** `arguments`, and RabbitMQ rejects
115
+ a redeclaration whose arguments differ with `406 PRECONDITION_FAILED`.
116
+
117
+ That failure would be loud rather than silent — the declaration happens inside the
118
+ `failsafe(this, this.#recover, …)` wrapper, `permanent()` ([:463](../source/channel.js#L463))
119
+ classifies `PRECONDITION_FAILED` as unrecoverable, and `io.consume(...)` rejects at wiring
120
+ time — but "the application does not start after the upgrade" is still a migration for every
121
+ user. Parking sidesteps it entirely: `comq.parked.<queue>` is a **new** queue, and the source
122
+ queue's declaration is untouched.
123
+
124
+ A migration would have been acceptable, so this is not what decided it — retention is. But it is a real saving, and it keeps the destination soft: a publish target can
125
+ be renamed or changed at any time, where `arguments` on a durable queue are permanent and every
126
+ later change is another migration.
127
+
128
+ ## What the parked message carries
129
+
130
+ Only what a person doing a post-mortem needs. There is no compatibility requirement — nothing
131
+ reads these but a human — so do not reproduce RabbitMQ's `x-death` format, and do not build
132
+ multi-hop machinery (`x-first-death-*` / `x-last-death-*`): a comq message parks once.
133
+
134
+ **The origin must be captured on the first failure, not at parking time.** This is the one
135
+ non-obvious part, and it was got wrong in an earlier draft. A message that has been retried
136
+ comes back through the *default* exchange, so by the time it is parked:
137
+
138
+ ```json
139
+ "exchange": "",
140
+ "routingKey": "orders..billing",
141
+ "headers": {
142
+ "x-comq-attempt": 1,
143
+ "x-death": [{ "reason": "expired", "queue": "comq.retry.30000",
144
+ "exchange": "comq.retry.30000", "routing-keys": ["orders..billing"] }],
145
+ "x-first-death-exchange": "comq.retry.30000"
146
+ }
147
+ ```
148
+
149
+ `message.fields.exchange` is `''` — it has to be — and `fields.routingKey` is now the source
150
+ queue name. So reading `fields` at parking time would record `''` as the origin of **every**
151
+ parked message that was ever retried, which is all of them except one parked by a first-delivery
152
+ `Park`. The broker does not rescue it either: `x-death[0].exchange` and `x-first-death-exchange`
153
+ both name the *retry* exchange. The original is nowhere unless comq writes it down.
154
+
155
+ So `#retry` writes `x-comq-exchange` / `x-comq-key` on the first failure and later retries
156
+ preserve them (Part 2 §4), and `#dispose` prefers those headers, falling back to `message.fields`
157
+ only for the first-delivery `Park` path where nothing has been written yet:
158
+
159
+ ```js
160
+ const headers = {
161
+ ...message.properties.headers, // x-comq-attempt and, after one retry, the origin
162
+ 'x-comq-queue': queue, // threaded in by Part 2 §3
163
+ 'x-comq-exchange': message.properties.headers?.['x-comq-exchange'] ?? message.fields.exchange,
164
+ 'x-comq-key': message.properties.headers?.['x-comq-key'] ?? message.fields.routingKey,
165
+ 'x-comq-reason': exception?.message,
166
+ 'x-comq-at': Date.now()
167
+ }
168
+ ```
169
+
170
+ **Two attempt counters will be on the message, and the docs must say which is authoritative.**
171
+ RabbitMQ maintains `x-death[0].count`, and it counts *deaths* where `x-comq-attempt` counts
172
+ *deliveries*: on a parked message with the default settings they read `4` and `5`. They will not
173
+ agree, and a move to quorum queues changes `x-death` semantics besides. `x-comq-attempt` is
174
+ authoritative — it is comq's, and it is what the ladder is measured against; `x-death` is the
175
+ broker's own record, useful for its timestamps.
176
+
177
+
178
+ ## The seam
179
+
180
+ Route the terminal branch through one method, so the destination is one function body:
181
+
182
+ ```js
183
+ async #park (queue, message, exception) {
184
+ await this.#dispose(queue, message, exception)
185
+ this.#diagnostics.emit('discard', message, exception)
186
+ }
187
+
188
+ // Where a message that has run out of attempts goes: a queue comq owns, published to
189
+ // before the original is released, so a message the broker holds twice is recoverable
190
+ // and one it no longer holds at all is not.
191
+ async #dispose (queue, message, exception) {
192
+ const headers = { /* as above */ }
193
+ const properties = { ...message.properties, headers, mandatory: true, persistent: true }
194
+
195
+ await this.#publish(DEFAULT, parkedQueueOf(queue), message.content, properties)
196
+ this.#channel.ack(message)
197
+ }
198
+ ```
199
+
200
+ `#dispose` is `async` and every caller sits inside `#failed`'s try/catch (Part 2 §4), so it
201
+ inherits the "cannot reject into amqplib" guarantee for free: if the parking publish fails, the
202
+ message is nacked back to the broker rather than lost.
203
+
204
+ The parking queue is per source queue — depth per consumer is how anyone notices a problem — and
205
+ is declared next to the shared retry topology in `#consume` (Part 2 §3), mirroring the source
206
+ queue's durability. The diagnostic keeps its existing name, `discard`, so nothing downstream
207
+ breaks.
208
+
209
+ ## Parked Requests: what is kept, and what is still not answered
210
+
211
+ A parked Request is preserved; its caller is still not answered. The promise never settles —
212
+ the prefetch deadlock [readme.md:469](../readme.md#L469) already documents — and nothing here
213
+ changes that. Parking makes the request *recoverable*, not *answered*.
214
+
215
+ **The parked copy must be published `persistent: true` regardless of the source topology.**
216
+ `source/topology/request.json` is `persistent: false`, so a parked Request would otherwise be
217
+ delivery-mode 1: sitting in a durable queue, but dropped by a broker restart. Requests are
218
+ non-persistent for latency and the failure path is not the latency path — parking is rare and
219
+ terminal, so the disk write costs nothing. The same applies to the retry copy, where a broker
220
+ restart during the TTL would otherwise lose a retried Request. Both `#retry` and `#dispose`
221
+ therefore set `persistent: true` explicitly rather than inheriting it.
222
+
223
+ (The remaining weakness on that channel is that `confirms: false` means the parking publish gets
224
+ no publisher confirm. Channel command ordering still has the broker process the publish before
225
+ the ack, so this is not a loss race — but there is no positive acknowledgement that the broker
226
+ took it. Fixing that means confirms on the request channel, which is a throughput change well
227
+ outside this work.)
228
+
229
+ **A parked Request carries enough to answer it later.** `#createRequest`
230
+ ([io.js:365](../source/io.js#L365)) sets `correlationId` and `replyTo` in the message properties,
231
+ and both `#retry` and `#dispose` spread `...message.properties`, so both survive into the
232
+ parking queue. Whether an answer would *land* depends on the caller:
233
+
234
+ - **Still running** — and it will be, hanging indefinitely, which is the case that matters. Its
235
+ reply queue is still declared, so a reply published to `replyTo` with that `correlationId`
236
+ would resolve the hung promise.
237
+ - **Restarted** — reply queues are `<queue>..<16 random hex>`
238
+ ([createReplyEmitter.js:33](../source/.io/createReplyEmitter.js#L33)) declared *exclusive*
239
+ (`reply.json` is `durable: false`, so `#assertQueue` uses `EXCLUSIVE`), so it died with the
240
+ old connection. The reply is unroutable, and because `#reply` sets `mandatory: true` the broker
241
+ returns it and the `return` diagnostic fires rather than it vanishing silently.
242
+
243
+ `correlationId` carries a per-process prefix, so it is unique across processes and a replay
244
+ cannot cross wires. None of this is built here — but it is worth recording that the door is
245
+ open, because it turns the deadlock from permanent into merely manual.
246
+
247
+ ## The one thing this does not fix
248
+
249
+ A parked message is out of the broker's way but still needs someone to look at it.
250
+ `comq.parked.*` queues grow without bound unless an operator drains them. That is the right
251
+ default — the alternative is deleting evidence — but the readme must say so, and the `discard`
252
+ diagnostic is the hook for alerting on it.
253
+
254
+ # Part 2 — The remaining fixes
255
+
256
+ None of these change an existing queue declaration, so none of them break an upgrade.
257
+
258
+ ## Design
259
+
260
+ ### The retry loop
261
+
262
+ On failure, comq publishes the message to a **shared retry queue**: a queue with no consumer,
263
+ holding it for a TTL and then dead-lettering it back to the queue it came from. Only after the
264
+ publish is confirmed is the original acked.
265
+
266
+ ```
267
+ exchange comq.retry.30000 (fanout)
268
+ queue comq.retry.30000 bound to it
269
+ x-message-ttl: 30000
270
+ x-dead-letter-exchange: ''
271
+ (no x-dead-letter-routing-key — the original key is preserved)
272
+
273
+ publish → exchange comq.retry.30000, routingKey = <source queue>
274
+ expiry → default exchange, routing key preserved → <source queue>
275
+ ```
276
+
277
+ **How one queue returns messages to many destinations.** The routing key is a property of the
278
+ *message*, not of the queue: it is set at publish time, travels with the message, and RabbitMQ
279
+ reuses it when dead-lettering. Tracing an event that failed in `orders..billing`:
280
+
281
+ | step | exchange | routingKey | lands in |
282
+ |---|---|---|---|
283
+ | comq publishes the retry | `comq.retry.30000` (fanout) | `orders..billing` | `comq.retry.30000` |
284
+ | waits out the TTL | — | `orders..billing` (carried) | — |
285
+ | TTL fires, dead-lettered | `''` (default) | `orders..billing` (**reused**) | `orders..billing` |
286
+
287
+ Three broker behaviours carry it:
288
+
289
+ 1. **A fanout exchange ignores the routing key when routing** — it delivers to whatever is bound,
290
+ which is the single retry queue — but preserves the key on the message. The key is therefore
291
+ free to name the destination rather than being consumed by the routing.
292
+ 2. **No `x-dead-letter-routing-key` on the retry queue.** Set, that argument *overrides* the
293
+ message's key with one fixed value — which is precisely what makes a retry queue per-source.
294
+ Unset, the broker reuses whatever key the message already carries, so each message returns to
295
+ its own source queue.
296
+ 3. **The default exchange has an implicit binding to every queue by name**, so a key of
297
+ `orders..billing` reaches the queue `orders..billing`. Standard AMQP; comq declares nothing
298
+ for it.
299
+
300
+ This is also the mechanical reason the message cannot be published to the retry queue directly:
301
+ through the default exchange its key would be the retry queue's own name, and on expiry it would
302
+ route back to itself — the cycle the broker drops silently at the first TTL.
303
+
304
+ And it is why the origin has to be captured on the first failure: after the round-trip the
305
+ message's `fields.exchange` is `''` and its `fields.routingKey` is the source queue, so the
306
+ exchange it was originally published to is genuinely gone from the message.
307
+
308
+ One queue and one exchange **per distinct delay**, shared by every source queue — not one pair
309
+ per consumed queue. The delay comes from the channel topology, so the count does not depend on
310
+ how many queues are consumed.
311
+
312
+ Worked through, because the rule alone is easy to misread. An application with 100 request
313
+ endpoints and 100 consumed events:
314
+
315
+ | | count | scales with |
316
+ |---|---|---|
317
+ | source queues (unchanged, exist today) | 200 | endpoints + events |
318
+ | **retry queues** | **2** (+ 2 exchanges) | distinct `delay` values — *not* queue count |
319
+ | parking queues | 200 | one per source queue |
320
+
321
+ The 100 endpoints share `comq.retry.5000` and the 100 events share `comq.retry.30000`; add a
322
+ thousand more of each and it is still two. A per-queue retry design would have made it 400 new
323
+ queues instead of 202.
324
+
325
+ Three things do move the retry number, and nothing else does: overriding `delay` (the count is
326
+ distinct values in use), a sharded connection (each shard is its own broker with its own pair),
327
+ and nothing further — more services, replicas and connections all declare the same names, and
328
+ `assertQueue` is idempotent.
329
+
330
+ **The fanout exchange is not optional.** Publishing to the retry queue through the default
331
+ exchange would make the message's own routing key the retry queue's name, so on expiry it would
332
+ dead-letter straight back into itself. RabbitMQ does not loop on that — it detects the cycle
333
+ and, since no rejection occurred in it, drops the message. Verified against a broker: the
334
+ message is gone at the **first** expiry, at exactly one TTL, not after a lap or two. So the
335
+ naive shape fails as "every retried message vanishes silently after one TTL" — no loop to
336
+ notice, no queue growth to alert on. A reader who half-remembers that RabbitMQ detects cycles
337
+ may expect something visible to happen; nothing does.
338
+
339
+ Publishing through a fanout exchange leaves the routing key free to name the destination. A
340
+ direct exchange would need a binding per source queue, which is the proliferation being removed.
341
+
342
+ **Naming: `comq.retry.<delay>`, and the delay is in the name deliberately.** `x-message-ttl` is
343
+ fixed at declare time, so a queue whose name did not carry it could never have that value
344
+ changed — every redeclaration with a different TTL is `406 PRECONDITION_FAILED`, and by the
345
+ placement below that means the application does not start. With the delay in the name, a change
346
+ declares a *new* queue and the old one keeps working: during a rolling deploy, old pods publish
347
+ to the old queue and new pods to the new one, and both dead-letter back to the same unchanged
348
+ source queue. There is no window at all.
349
+
350
+ **Orphans stay, and `x-expires` is not the cleanup.** A retired delay leaves an empty queue
351
+ behind. `x-expires` looks like the answer and is not: a retry queue never has a consumer, so
352
+ "unused" is its permanent state, and the timer would delete it along with whatever is waiting
353
+ inside. An empty queue per retired delay value is the price, and it is a housekeeping item
354
+ rather than a hazard.
355
+
356
+ **Declared at consume time, not on first failure.** Declaring inside the catch block means a
357
+ declaration error surfaces at 3am under load, and a failure there falls through to
358
+ `nack(requeue: true)` → immediate redelivery → immediate re-failure → a hot loop with no delay
359
+ and no attempt counting. Declaring in `#consume` puts it inside the
360
+ `failsafe(this, this.#recover, …)` wrapper, where `io.consume(...)` rejects at wiring time with
361
+ a legible error, and it is re-declared for free on `recover()` (which does `lazy.reset` +
362
+ `recall(this)`).
363
+
364
+ **Sharing centralises the blast radius.** Delete or misconfigure `comq.retry.30000` and every
365
+ retry in the application stops, where a per-queue design would lose one queue's. Re-declaring at
366
+ consume time is the mitigation; the documentation line that already says not to delete these
367
+ queues on a running system covers it.
368
+
369
+ **Durability and exclusive source queues.** The shared queue is always durable, and that is
370
+ always right: every topology that acknowledges is also durable (`request.json` and `event.json`
371
+ are both `durable: true`; `reply.json` is `acknowledgments: false` and has no retry path).
372
+ An *exclusive* source queue — from `io.consume(exchange, callback)` with no group — is the one
373
+ case worth tracing. While that ephemeral subscriber is alive the retry is delivered normally.
374
+ Once its connection is gone there is nobody to deliver to, and the message is dropped at expiry
375
+ because the default exchange finds no such queue. A per-queue retry design loses it too, at
376
+ disconnect rather than at expiry. Neither can do better, so sharing costs nothing here.
377
+
378
+ **Not per-message TTL.** RabbitMQ discards an expired message only once it reaches the head of
379
+ its queue, so a long wait would hold up every shorter one behind it. The wait therefore has to
380
+ be a property of the queue — which is why a backoff ladder is a queue per rung rather than an
381
+ `expiration` per message.
382
+
383
+ **There is no separate attempt count.** `delay` is a **ladder with one rung per retry**, so its
384
+ length decides how many there are and four rungs is five attempts. `MAX_REDELIVERIES`
385
+ ([:481](../source/channel.js#L481)) disappears into it: a retry and its wait are the same event,
386
+ and a count kept beside the ladder would be a second source of truth free to disagree with it.
387
+
388
+ The header is the attempt number, counting from one, and the first delivery does not carry it: a
389
+ consumer reads `headers?.['x-comq-attempt'] ?? 1`.
390
+
391
+ The original code kept a `MAX_REDELIVERIES` named for attempts while counting retries, which made
392
+ `5` mean six deliveries and left [readme.md:466](../readme.md#L466) — "causes exceptions five
393
+ times in a row, it is discarded" — off by one.
394
+
395
+ > **The retry queue inherits the at-most-once caveat described in Part 1.** The return hop is
396
+ > broker dead-lettering, which on classic queues republishes without publisher confirms — so a
397
+ > retried message can be lost if the source queue is unavailable when the TTL fires (target
398
+ > unavailable, length limit under `reject-publish`, or a network partition between the nodes
399
+ > hosting the two queues). Still a large improvement on today's ack-before-publish loss, and the
400
+ > alternative — an in-process `setTimeout` — is worse, since it loses the delay on any restart.
401
+ > A known limitation; it belongs in the docs (§8), not left implicit. Moving comq to quorum
402
+ > queues would close it; see Part 1.
403
+
404
+ ### The backoff ladder is public
405
+
406
+ `delay` is a `Topology` field, defaulted per channel type and overridable per deployment. It is
407
+ a list with **one rung per retry**, so its length is the number of retries and there is no
408
+ separate count to fall out of step with it.
409
+
410
+ | | ladder | attempts | total |
411
+ |---|---|---|---|
412
+ | `event` | 1s, 10s, 30s, 90s | 5 | 131s |
413
+ | `request` | 1s, 3s, 5s, 10s | 5 | 19s |
414
+
415
+ **Events.** A fast first rung catches a momentary blip without making it wait out a long one,
416
+ and the ladder then climbs toward the failures worth retrying — a database primary stepping down,
417
+ a storage reconnect, a third-party blip, and a component mid-rollout. That last one is the most
418
+ common and the easiest to misplace: on the event path "nothing is listening yet" is not a failure
419
+ at all, since the message waits in a durable queue. It is reached *indirectly* — a receiver's
420
+ handler calls a component that is mid-rollout, the call raises, the handler throws, the event is
421
+ retried — and a rollout with `maxUnavailable: 0` across several replicas is minutes rather than
422
+ seconds. A ladder covering only a few seconds parks everything that was merely slow, which makes
423
+ the parking queue mean "something was briefly slow" instead of "something is wrong".
424
+
425
+ **Requests are shorter, and deliberately so.** The failure durations are identical; what differs
426
+ is that a request has someone blocked on it with no timeout. Exhausting the attempts means the
427
+ caller gets **no answer at all**, ever — so giving up early is not a cheaper failure, it is an
428
+ unbounded one. What bounds how much coverage to buy is that a reply arriving long after the
429
+ caller gave up lands in an exclusive reply queue that may no longer exist, and comes back
430
+ unroutable.
431
+
432
+ Different defaults per channel type are free: `event.json` and `request.json` already differ on
433
+ four other settings. The two ladders share the rungs `1000` and `10000`, so between them they
434
+ mean **six** retry queues and six exchanges for a whole application — and that number follows the
435
+ rungs, not the number of queues consumed.
436
+
437
+ And it removes a constraint that should never have set a production number: the feature suite
438
+ can run at 100ms without either default being chosen for it. The original `1000` was picked for
439
+ the suite's wall clock, which is the wrong reason for a production constant.
440
+
441
+
442
+ ### Nothing in the failure path may reject
443
+
444
+ amqplib has nowhere to put a rejection, so every path out of the consumer wrapper must be
445
+ total. Fallback is `nack(message, false, true)` — hand the delivery back to the broker rather
446
+ than lose it — itself wrapped so it can't throw either.
447
+
448
+ `#publish` can reject three ways and all three correctly resolve to "give it back":
449
+ `'Channel closed'` from a dying channel, `INTERRUPTION` ([:479](../source/channel.js#L479)) from
450
+ `#unpaused()` on a failfast/sharded channel, and confirmation rejection during `recover()`
451
+ ([:230](../source/channel.js#L230)).
452
+
453
+ Two extra defects found while designing, fixed in the same pass:
454
+ - [:377](../source/channel.js#L377) `exception.message` throws if a consumer does `throw undefined`
455
+ → use `exception?.message`.
456
+ - [:381](../source/channel.js#L381) `this.#discard(...)` is not awaited; once it becomes the
457
+ dead-letter seam (Part 1), an unawaited rejection there escapes.
458
+
459
+ ### Consequences to accept and document
460
+
461
+ - **Ordering is not preserved across a failure.** A retried message re-enters the queue behind
462
+ messages published while it waited. (Ordering was already not guaranteed — `prefetch: 300`
463
+ means up to 300 concurrent in-flight deliveries.) Retries do not reorder among themselves.
464
+ - **At-least-once, not exactly-once.** Publish-before-ack means a crash between the two leaves
465
+ the broker holding both the retry copy and the unacked original. Consumers must be
466
+ idempotent. This is the deliberate trade against today's at-most-once silent loss.
467
+ - **A retry published to a deleted queue is silently dropped**, and publisher confirms still
468
+ ack it. `mandatory: true` makes the broker return it so the `return` diagnostic fires, but
469
+ asynchronously. Document "do not delete `comq.retry.*` on a running system".
470
+ - **`x-comq-attempt` becomes user-visible** — consumers receive it in `properties.headers` and can
471
+ tell which attempt they're on. Worth documenting as a feature. RabbitMQ also appends
472
+ `x-death` on the way back and rewrites `routingKey` to the source queue name.
473
+
474
+ ## Implementation
475
+
476
+ All in [source/channel.js](../source/channel.js) unless noted.
477
+
478
+ ### 1. Record each queue's declaration options
479
+
480
+ The parking queue must mirror its source queue's lifetime — a durable parking queue behind an
481
+ exclusive `amq.gen-*` source leaks a durable queue nothing will ever read. `#consume` cannot
482
+ re-derive this, because only `#assertBoundQueue` ([:303](../source/channel.js#L303)) passes the
483
+ `{ exclusive: true }` override. Record it where it is known:
484
+
485
+ - new field `#queues = new Map()` next to `#tags` ([:26](../source/channel.js#L26))
486
+ - `this.#queues.clear()` in `create()` next to `this.#tags = []` ([:72](../source/channel.js#L72))
487
+ — a fresh amqplib channel has declared nothing
488
+ - `this.#queues.set(queue, options)` in `#assertQueue` ([:250](../source/channel.js#L250))
489
+
490
+ (The retry queue does not need this: it is shared and always durable, per the Design section.)
491
+
492
+ ### 2. The retry and parking topology
493
+
494
+ The retry exchange and queue are per *delay*, so they are declared once per distinct value
495
+ rather than once per source queue. `#assertRetryQueue` takes no queue argument, which is what
496
+ makes `lazy`'s per-argument memo collapse them:
497
+
498
+ ```js
499
+ async #assertRetryQueue () {
500
+ const delay = this.#topology.delay
501
+ const name = RETRY_PREFIX + delay
502
+
503
+ // fanout, so the routing key stays free to name the destination. Publishing through
504
+ // the default exchange instead would make the message's own routing key this queue's
505
+ // name, and the broker would drop it as a cycle at the first expiry.
506
+ await this.#channel.assertExchange(name, 'fanout', DURABLE)
507
+
508
+ await this.#assertQueue(name, {
509
+ ...DURABLE,
510
+ arguments: {
511
+ 'x-message-ttl': delay,
512
+ 'x-dead-letter-exchange': DEFAULT
513
+ // no x-dead-letter-routing-key: the original key is preserved, and it names
514
+ // the source queue because that is what we published with
515
+ }
516
+ })
517
+
518
+ await this.#channel.bindQueue(name, name, '')
519
+
520
+ return name
521
+ }
522
+
523
+ async #assertParkedQueue (queue) {
524
+ const options = this.#queues.get(queue) ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
525
+
526
+ return (await this.#assertQueue(parkedQueueOf(queue), options))[0]
527
+ }
528
+ ```
529
+
530
+ Plus `RETRY_PREFIX = 'comq.retry.'`, `PARKED_PREFIX = 'comq.parked.'` and
531
+ `parkedQueueOf = (queue) => PARKED_PREFIX + queue`, where `MAX_REDELIVERIES` sits today
532
+ ([:481](../source/channel.js#L481)) — that constant moves into the topology presets as the
533
+ `RETRY_DELAY` never becomes a constant at all; it is `topology.delay` from the start, and
534
+ `MAX_REDELIVERIES` disappears into the ladder's length.
535
+
536
+ ### 3. `#consume` declares them and threads the queue name
537
+
538
+ The queue name is fully resolved by `#consume` for all three paths — `lazy` splices the
539
+ initializer's return over `args` before the method body runs, so `consume`→`#assertQueue`,
540
+ `subscribe`→`#assertBoundQueue`, `bound`→`#assertKeyedQueue` all deliver the broker-confirmed
541
+ name (including generated `amq.gen-*` names for the exclusive case, `io.js:112`).
542
+
543
+ ```js
544
+ // :352
545
+ if (this.#topology.acknowledgments) {
546
+ await this.#assertRetryQueue()
547
+ await this.#assertParkedQueue(queue)
548
+ consumer = this.#getAcknowledgingConsumer(queue, consumer)
549
+ } else options.noAck = true
550
+ ```
551
+
552
+ `#getAcknowledgingConsumer` becomes `(queue, consumer) => async (message) => {...}`; single call
553
+ site at [:356](../source/channel.js#L356). Declare the source queue **before** the others —
554
+ existing tests at [test/channel.test.js:386](../test/channel.test.js#L386) and `:407` read
555
+ `assertQueue.mock.results[0]`.
556
+
557
+
558
+ ### 4. Rewrite the failure path
559
+
560
+ Replace `#getAcknowledgingConsumer` / `#requeue` ([:370-400](../source/channel.js#L370)) with:
561
+
562
+ ```js
563
+ #getAcknowledgingConsumer = (queue, consumer) =>
564
+ async (message) => {
565
+ try {
566
+ await consumer(message)
567
+ this.#channel.ack(message)
568
+ } catch (exception) {
569
+ if (exception?.message === 'Channel closed') return // the broker requeues it
570
+ await this.#failed(queue, message, exception)
571
+ }
572
+ }
573
+
574
+ // Nothing here may reject: amqplib dispatches a delivery through an event emitter and
575
+ // drops the promise it gets back, so a rejection has no one to catch it and takes the
576
+ // process down.
577
+ async #failed (queue, message, exception) {
578
+ try {
579
+ const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 0
580
+
581
+ if (attempt >= this.#topology.attempts) await this.#park(queue, message, exception)
582
+ else await this.#retry(queue, message, attempt, exception)
583
+ } catch {
584
+ this.#nack(message) // hand it back rather than lose it
585
+ }
586
+ }
587
+
588
+ async #retry (queue, message, attempt, exception) {
589
+ const headers = {
590
+ // the origin is recorded on the first failure and carried from then on: after one
591
+ // trip through the retry queue the message comes back through the default exchange,
592
+ // so message.fields no longer describes where it was published
593
+ ...origin(message),
594
+ ...message.properties.headers,
595
+ [ATTEMPT_HEADER]: attempt + 1
596
+ }
597
+
598
+ // persistent regardless of the source topology: a retried Request would otherwise be
599
+ // delivery-mode 1 and would not survive a broker restart during the TTL
600
+ const properties = { ...message.properties, headers, mandatory: true, persistent: true }
601
+
602
+ await this.#publish(RETRY_PREFIX + this.#topology.delay, queue, message.content, properties)
603
+
604
+ this.#channel.ack(message)
605
+ this.#diagnostics.emit('retry', message, exception, attempt + 1)
606
+ }
607
+
608
+ #nack (message) {
609
+ try { this.#channel.nack(message, false, true) } catch { /* nothing left to do */ }
610
+ }
611
+
612
+ // spread *under* the existing headers, so the first failure writes it and later ones
613
+ // leave it alone
614
+ const origin = (message) => ({
615
+ 'x-comq-exchange': message.fields.exchange,
616
+ 'x-comq-key': message.fields.routingKey
617
+ })
618
+ ```
619
+
620
+ Two things worth being explicit about:
621
+
622
+ - **`#retry` copies the properties** rather than mutating them as [:396](../source/channel.js#L396)
623
+ does — the mutated message must not reach the fallback `nack`, and a user consumer may still
624
+ hold a reference.
625
+ - **The publish target is the retry *exchange*, and the routing key is the source queue.** That
626
+ is what survives the expiry and routes the message home.
627
+
628
+ `#park` / `#dispose` are as given in Part 1.
629
+
630
+
631
+ ### 5. Wire the `retry` diagnostic
632
+
633
+ - [source/events.js:7](../source/events.js#L7) — add `'retry'` to `exports.channel`. This single
634
+ switch makes both `source/io.js:271` forward it to `IO.diagnose` **and**
635
+ `source/shards/channel.js:183` `#pipe` forward it with the shard index appended.
636
+ - [types/diagnostic.d.ts:4](../types/diagnostic.d.ts#L4) — add `'retry'` to the `Event` union.
637
+ - [types/io.d.ts:106](../types/io.d.ts#L106) — add the `retry` overload. **Also fix the
638
+ pre-existing bug on that line**: `discard` is declared `(channel, message, index?)` but
639
+ `channel.js:408` has always passed the exception too.
640
+ - [test/io.diagnostics.test.js:31](../test/io.diagnostics.test.js#L31) — add `'retry'` to the
641
+ hardcoded re-emit list.
642
+ - [features/steps/context.js:124](../features/steps/context.js#L124) — add `'retry'` to `EVENTS`.
643
+
644
+ ### 5b. Make the backoff ladder configurable
645
+
646
+ Small and self-contained; the reasoning and the defaults are under *Delay and attempts are both
647
+ public*.
648
+
649
+ - `source/topology/{request,event}.json` — add `"attempts": 5`, and `"delay": 30000` /
650
+ `"delay": 5000`. Leave `reply.json` alone; it is `acknowledgments: false`, so nothing there
651
+ ever counts an attempt or waits a delay.
652
+ - [types/topology.d.ts](../types/topology.d.ts) — add `attempts: number` and `delay: number` to
653
+ `Topology`.
654
+ - [source/connection.js:92](../source/connection.js#L92) — `const topology = presets[type]` hands
655
+ out the shared module-level preset object. Make it merge into a copy,
656
+ `{ ...presets[type], ...this.#overrides?.[type] }`, and never mutate the preset.
657
+ - [source/connect.js](../source/connect.js) — `Connect` is variadic
658
+ (`(...urls: string[]) => Promise<IO>`), so the overrides are an optional trailing argument,
659
+ taken when `typeof last !== 'string'` and typed as `[...urls: string[], options: Options]`.
660
+ Thread it through `create()` → `IO` → `Connection`.
661
+ - [test/presets.test.js](../test/presets.test.js) — all three cases assert with `toStrictEqual`,
662
+ so all three change.
663
+ - New tests: an override reaches the channel; an absent override leaves the preset untouched
664
+ (assert the module object is not mutated across two connections); a consumer configured with
665
+ `attempts: 1` parks on the first delivery, having had its one attempt; two channels with different
666
+ `delay` values declare two differently-named retry queues and neither redeclares the other's.
667
+
668
+ ### 6. Unit tests — new `describe('retry')` in [test/channel.test.js](../test/channel.test.js)
669
+
670
+ `test/amqplib.mock.js` needs no changes. **`test/channel.fixtures.js` randomises `durable`,
671
+ `confirms` and `acknowledgments` with `flip()`** — every new test must set the ones it asserts
672
+ on explicitly, and the file should be run repeatedly.
673
+
674
+ Declaration: the retry **exchange** asserted as `fanout` and the retry queue asserted with the
675
+ delay-derived name, the TTL and **no** `x-dead-letter-routing-key`, and bound to that exchange;
676
+ the parking queue asserted with the right name and no arguments. All asserted *after* the source
677
+ queue; none asserted when `acknowledgments: false`; the parking queue exclusive when the source
678
+ is exclusive (test both values of `durable`) while the retry queue stays durable regardless;
679
+ asserted for the `bound` path; re-asserted after `recover()`. And the one that guards the shared
680
+ design: **two consumers on the same channel declare the retry topology once**, not once each.
681
+
682
+ Failure path — the regression tests that matter:
683
+ - **publishes before acking** —
684
+ `chan.publish.mock.invocationCallOrder[0] < chan.ack.mock.invocationCallOrder[0]`
685
+ - **waits for the confirmation** — `confirms: true`, capture the 5th publish arg, assert `ack`
686
+ not yet called, invoke the callback, assert it then is
687
+ - **does not republish to the original exchange** — build the message with a random
688
+ `fields.exchange`; assert the publish goes to the retry exchange with the **source queue** as
689
+ the routing key, never to `fields.exchange`. *(the fanout re-delivery regression)*
690
+ - **records the origin on the first failure** — `fields.exchange` and `fields.routingKey` are
691
+ written as `x-comq-exchange` / `x-comq-key`; a message that already carries them (a returned
692
+ retry, whose `fields.exchange` is now `''`) keeps the originals rather than overwriting them
693
+ with `''`. *(the wrong-provenance regression his broker run found)*
694
+ - **tolerates a message with no headers** — `properties = {}`. *(the non-comq-publisher
695
+ `TypeError` regression)*
696
+ - **does not throw** — `await expect(callback(message)).resolves.not.toThrow()`. *(the
697
+ process-crash regression)*
698
+ - **does not seal** — `chan.cancel` not called, `seal` spy not called, and a subsequent
699
+ `channel.consume(...)` still works *(proves `#sealed` is still false)*
700
+ - increments `x-comq-attempt`; does not mutate `message.properties`; emits `retry` with the attempt
701
+ - **publishes persistent even from a non-persistent topology** — `topology.persistent = false`
702
+ (the request preset), assert the publish options carry `persistent: true`, for both the retry
703
+ and the parking publish. *(a parked Request would otherwise not survive a broker restart)*
704
+ - **carries `replyTo` and `correlationId` through** — a message with both in its properties is
705
+ parked with both intact, so a hung caller remains answerable
706
+ - **defensive fallback**: publish throws → `nack(message, false, true)`, no `ack`, resolves;
707
+ publish *and* nack both throw → still resolves; confirmation rejected → same as publish throw
708
+ - consumer throwing `'Channel closed'` → no publish, no nack, no ack (complements the existing
709
+ test at [:144](../test/channel.test.js#L144), which covers `ack` throwing)
710
+ - consumer doing `throw undefined` → resolves *(covers the `exception?.message` guard)*
711
+ - **terminal**: `x-comq-attempt: 5` → published to `comq.parked.<queue>`, **not** to the retry queue;
712
+ published before acking, same order assertion as above; carries the `x-comq-*` headers
713
+ including an `x-comq-exchange` that names the original exchange rather than `''`; `nack` not
714
+ called. Restores
715
+ the spirit of the `it.each` requeue test `1df2afe` deleted. The existing `should emit
716
+ 'discard' event` ([:894](../test/channel.test.js#L894)) needs its message fixture extended with
717
+ `fields` — it currently has none, which works only because today's `#discard` never reads
718
+ them.
719
+ - **parking failure**: publish to the parking queue throws → `nack(message, false, true)`, no
720
+ `ack`, resolves. The message survives an unavailable parking queue.
721
+
722
+ ### 7. Feature tests — rewrite [features/events.poison.feature](../features/events.poison.feature)
723
+
724
+ Both scenarios drop `@manual`. Two structural facts shape this:
725
+
726
+ - `features/steps/brokers.js` starts containers in `BeforeAll` and `hooks.js` only disconnects,
727
+ so **durable queues and their contents survive across scenarios**. A message still cycling
728
+ through a retry queue will land in the next scenario's consumer. **Use a distinct exchange
729
+ name per scenario** — do not share `poison`.
730
+ - [features/steps/events.js:41](../features/steps/events.js#L41) builds the throwing consumer.
731
+ Change it to record `properties.headers?.['x-comq-attempt']` into a new `this.attempts` array,
732
+ which turns the whole retry loop into an end-to-end assertion against a real broker.
733
+
734
+ ```gherkin
735
+ Scenario: A poison event is retried and then discarded
736
+ Given that events from the `poison_retried` exchange are causing exceptions
737
+ When an event is emitted to the `poison_retried` exchange
738
+ Then the event is attempted 6 times
739
+ And the message is parked
740
+
741
+ Scenario: A poison event does not stop other consumers
742
+ Given that events from the `poison_isolated` exchange are causing exceptions
743
+ And that `checker` is consuming events from the `numbers_added` exchange
744
+ When an event is emitted to the `poison_isolated` exchange
745
+ And after 1500ms
746
+ And an event is emitted to the `numbers_added` exchange
747
+ Then `checker` receives the event
748
+
749
+ Scenario: A retried event is not re-delivered to the other consumers of its exchange
750
+ Given that events from the `poison_open` exchange are causing exceptions
751
+ And that `witness` is consuming events from the `poison_open` exchange
752
+ When an event is emitted to the `poison_open` exchange
753
+ And after 3000ms
754
+ Then `witness` has received 1 event
755
+ ```
756
+
757
+ Scenario 2 is the direct proof the channel is not sealed — `numbers_added` and
758
+ `poison_isolated` share the one `#events` channel, so under today's code the second
759
+ `emit`/`consume` is dead. Scenario 3 is the fanout regression: exactly once, not once per retry.
760
+
761
+ Steps needed:
762
+ - `Then the event is attempted {int} times` (new, `features/steps/poison.js`) — poll
763
+ `this.attempts` to a deadline, then `assert.deepEqual(this.attempts, [1, 2, 3, 4, 5])`.
764
+ Asserts count, header increment, and that the delay round-trips through the broker. Six
765
+ entries, per the arithmetic above — the first delivery has no `x-comq-attempt` header.
766
+ - `Then the message is discarded` — **already exists** at
767
+ [features/steps/poison.js:7](../features/steps/poison.js#L7) and is currently orphaned (no
768
+ feature file references it). It becomes live; change its `await timeout(300)` to a polling
769
+ wait, via a new `until(predicate, ms)` helper in `test/helpers.js` next to `timeout`.
770
+ - `Then the message is parked` (new) — the real end-to-end assertion, and only possible because
771
+ Part 1 gives comq a queue it owns: `io.process('comq.parked.<queue>', …)` and assert the payload
772
+ arrives, carrying `x-comq-attempt: 5` and the `x-comq-*` headers. Under today's code, and under a
773
+ broker-side DLX, there would be nothing to consume. Prefer this over the diagnostic-flag step
774
+ wherever both would work — it proves the message actually survived, not just that comq
775
+ believes it did.
776
+ - `Then {token} has received {int} event(s)` (new, `features/steps/events.js`) — needs
777
+ per-group counts; today only a single global `eventsConsumedCount` and a last-value-only
778
+ `consumed[group]` exist.
779
+ - `features/steps/context.js` — add `attempts = []` and `counts = {}`, reset on disconnect.
780
+
781
+ ### 8. Docs
782
+
783
+ Replace [readme.md:464-470](../readme.md#L464) (which currently documents the sealed channel and
784
+ the "configure a DLX yourself" workaround) with prose covering: the retry queue and its name;
785
+ `x-message-ttl` + `x-dead-letter-exchange` as the delay mechanism; `x-comq-attempt` visible to
786
+ consumers; **five attempts by default** — the first delivery and four retries — then `discard`,
787
+ which is what [readme.md:466](../readme.md#L466) already claimed, and noting that both
788
+ the ladder is a topology setting (`delay`) with per-channel-type defaults, and its length is
789
+ the number of retries; **the channel keeps consuming**; ordering not
790
+ preserved across a failure; at-least-once / consumers must be idempotent; retries confirmed for
791
+ Events but best-effort for Requests (`confirms: false`, `persistent: false`), and a discarded
792
+ Request is never answered — the caller waits indefinitely, which with a limited prefetch can
793
+ deadlock it; do not delete `comq.retry.*` or `comq.parked.*` on a running system; and **the retry
794
+ queue's return hop is at-most-once on classic queues** — a retry can be lost if the source queue
795
+ is unavailable when the TTL fires.
796
+
797
+ Then the parking half, from Part 1: comq publishes the exhausted message to `comq.parked.<queue>`
798
+ and acks only once the broker confirms it, so it is not deleted and not dependent on a
799
+ broker-side policy; the `x-comq-*` headers it carries; that these queues **grow until someone
800
+ drains them**, which is deliberate — the alternative is deleting evidence — and that `discard`
801
+ is the diagnostic to alert on.
802
+
803
+ **Delete the current recommendation to configure a dead-letter policy**
804
+ ([readme.md:468-470](../readme.md#L468)). It is no longer needed, and the reasoning in Part 1 —
805
+ that broker dead-lettering is at-most-once on classic queues — means it was never the guarantee
806
+ it appeared to be. A DLX on the parking queue itself remains a user's choice; say nothing about
807
+ it either way.
808
+
809
+ The topology section ([readme.md:428](../readme.md#L428)) gains the retry exchange and queue —
810
+ one pair per distinct `delay`, shared by every source queue — and the per-queue parking queue,
811
+ so the queue list an operator sees is documented rather than discovered.
812
+
813
+ Add `retry` to the diagnostics list at [readme.md:556](../readme.md#L556), and to
814
+ `docs/headers.md`: `x-comq-attempt`, the `x-comq-*` parking headers, and a note that a retried message
815
+ also carries the broker's `x-death` — whose `count` is a *second* attempt counter that happens to
816
+ agree with `x-comq-attempt` today. Say which is authoritative (`x-comq-attempt`, comq's own; `x-death` is
817
+ the broker's record and is useful for its timestamps), because two counters on one message is
818
+ otherwise a debugging trap.
819
+
820
+ ## Not affected — checked
821
+
822
+ - `source/shards/channel.js:59` delegates `consume`/`subscribe`/`bound` to per-shard `Channel`s.
823
+ Each shard is a separate broker with its own copy of the queue, declares its own retry
824
+ topology and `comq.parked.*`, and returns retries to its own source. Self-contained —
825
+ though note a parked message lives on the shard that failed it, so a post-mortem means
826
+ looking at every shard. `#retry` uses
827
+ `#publish` directly (not the `failsafe`-wrapped `publish`), so on a paused failfast shard it
828
+ throws `INTERRUPTION` → `#nack` → the message stays on that shard. Acceptable.
829
+ - `IO.seal()` / `IO.close()` and the `#sealed` guards at `:88`, `:101`, `:119` are the
830
+ *explicit* seal and stay exactly as they are. Only the failure path stops calling `seal()`.
831
+ - `source/emitter.js:11` swallows listener exceptions, so a bad `retry` listener can't
832
+ re-introduce the crash.
833
+ - Existing assertions the extra `assertQueue` call could have broken, all verified safe:
834
+ `channel.test.js:185`/`:191` (`send` path, no retry queue), `:358` (`assertExchange` count),
835
+ `:387` (`bindQueue` count — the retry queue is never bound), `:74`/`:363`
836
+ (`toHaveBeenCalledWith`, not counts). Only the `mock.results[0]` reads depend on ordering,
837
+ which the "source queue first" rule preserves.
838
+ - `test/io.*.test.js` use the channel-level `test/connection.mock.js`; unaffected.
839
+
840
+ ## Verification
841
+
842
+ 1. `npm test` (`standard && jest`) — run `npx jest test/channel.test.js` **several times**,
843
+ since `test/channel.fixtures.js` randomises the topology per run.
844
+ 2. `npm run features` (needs Docker; Testcontainers starts two RabbitMQ containers). Time the
845
+ poison feature specifically — 5 attempts in one step at a 1s delay is the tightest new
846
+ timing in the suite, against a 30s per-step cucumber timeout.
847
+ 3. The end-to-end proof is that `features/events.poison.feature` becomes ordinary automatic
848
+ scenarios: a consumer that throws, a message that comes back five times with an incrementing
849
+ `x-comq-attempt` (five deliveries in total), a message that ends up disposed rather than silently
850
+ gone, and — the point of the whole exercise — a *sibling* consumer still alive afterwards.
851
+ 4. Confirm the process no longer exits: today, running the poison scenario ends the node
852
+ process. After the change it should complete the scenario.
853
+ 5. Inspect the broker after a features run — the retry queues empty, `comq.parked.*` holding
854
+ exactly the messages the poison scenarios parked, with `x-comq-exchange` naming the original
855
+ exchange rather than `''`. That last one is the check that the origin is captured on the
856
+ first failure rather than at parking time.
857
+ 6. Confirm the cycle claim directly, once, rather than trusting the document: declare a queue
858
+ with a TTL, `x-dead-letter-exchange: ''` and no routing key, publish to the default exchange
859
+ with that queue as the key, and watch the message vanish at the first expiry. It is the
860
+ reason the retry exchange exists.
861
+ 7. Branch off `dev` (not `release`).
862
+
863
+ ---
864
+
865
+ # Part 3 — A consumer says how it failed (`Retry` / `Park`)
866
+
867
+ A follow-up proposal lets a consumer classify its failure: `throw new Retry()` for a
868
+ dependency that was briefly absent, `throw new Park()` for a message this consumer will never
869
+ process. A bare rejection means `Retry`.
870
+
871
+ ## What it changes in this plan: almost nothing
872
+
873
+ **None of the six defects change.** Every one of them exists whether or not a consumer can
874
+ classify its failure — the verdict is a *routing* decision layered on the failure path, not a
875
+ fix to it. Parts 1 and 2 stand exactly as written, and remain worth landing on their own: a
876
+ `Park()` that reaches today's `#requeue` still seals the channel and still kills the process.
877
+
878
+ **It adds one branch**, in `#failed` (Part 2 §4). The seam already exists:
879
+
880
+ ```js
881
+ const park = verdictOf(exception) === PARK || attempt >= this.#topology.attempts
882
+
883
+ if (park) await this.#park(queue, message, exception)
884
+ else await this.#retry(queue, message, attempt, exception)
885
+ ```
886
+
887
+ `Retry` still respects the ladder — a consumer can say "worth another attempt" without
888
+ being able to say "forever". comq keeps its own count, which is what the proposal's "no
889
+ counts" asks for.
890
+
891
+ **Part 1 already covers it.** Parking is the destination whether the message got there by
892
+ exhausting its attempts or by a consumer saying so on the first delivery. `Park()` does raise
893
+ the stakes — parking stops being a rare terminal event and becomes a routine outcome a consumer
894
+ chooses deliberately — which is a further argument for Part 1's decision, and an argument
895
+ against ever making it opt-in.
896
+
897
+ ## Thrown, not returned — the return channel is already taken
898
+
899
+ [source/io.js:305](../source/io.js#L305): `const reply = await producer(payload)`. For `io.reply`,
900
+ **what the consumer returns is the reply**, and [io.js:308](../source/io.js#L308) then inspects it
901
+ for iterator-ness to choose streaming versus a single reply. A returned symbol or verdict object
902
+ would be encoded and sent to the caller as the answer. `types/io.d.ts:10` says the same:
903
+ `Producer<Input, Output> = (message) => Output | Promise<Output>`.
904
+
905
+ Event consumers ([io.js:345](../source/io.js#L345)) do ignore their return value, so returning would
906
+ work *there* — but a verdict that works for `consume`/`process` and not for `reply` is worse
907
+ than one that works everywhere. Second reason: a thrown verdict can be raised from anywhere in
908
+ the call stack, so a validation helper three frames down can park the message; a return can only
909
+ be made by the top-level function.
910
+
911
+ ## Shape
912
+
913
+ ```js
914
+ // source/verdicts.js — its own module, so source/channel.js can require it
915
+ // without going through the package entry point
916
+ 'use strict'
917
+
918
+ // the global registry, so two copies of comq in one dependency tree still agree
919
+ const VERDICT = Symbol.for('comq.verdict')
920
+
921
+ const RETRY = 'retry'
922
+ const PARK = 'park'
923
+
924
+ class Retry extends Error { [VERDICT] = RETRY }
925
+ class Park extends Error { [VERDICT] = PARK }
926
+
927
+ // a rejection nobody classified is a rejection nobody chose
928
+ const verdictOf = (exception) => exception?.[VERDICT] ?? RETRY
929
+ ```
930
+
931
+ Two deliberate choices:
932
+
933
+ - **`Symbol.for` brand, not `instanceof`.** Required, not defensive: a consumer of comq that
934
+ pins it inside its own binding while the application resolves its own tree makes two copies
935
+ the ordinary resolution rather than the pathological one, and `instanceof` is silently `false`
936
+ across them. A `Park` would then be read as a bare rejection, retried to exhaustion, and
937
+ parked anyway — the right outcome, several retry cycles late, with a spurious `retry`
938
+ diagnostic for each. The global symbol registry is shared across copies; a class identity is not.
939
+ - **`extends Error`**, so a verdict carries a stack, a `message`, and `cause`. A bare
940
+ `new Park()` would throw the cause away, and the `discard` diagnostic's second argument
941
+ ([channel.js:408](../source/channel.js#L408)) would be a marker with nothing about what actually
942
+ went wrong. Use `new Park('…', { cause })` and record both in the parking headers —
943
+ `x-comq-reason` from the verdict's own message, and the cause's message alongside it.
944
+
945
+ ## Consumer code
946
+
947
+ ```js
948
+ const { Retry, Park } = require('comq')
949
+
950
+ // events — fanout
951
+ await io.consume('orders', 'billing', async (order) => {
952
+ if (typeof order.total !== 'number') throw new Park('no total on order')
953
+
954
+ try {
955
+ await billing.charge(order)
956
+ } catch (e) {
957
+ throw new Retry('billing unavailable', { cause: e }) // or just: throw e
958
+ }
959
+ })
960
+
961
+ // tasks
962
+ await io.process('emails', async (job) => {
963
+ await mailer.send(job) // a bare throw means Retry
964
+ })
965
+
966
+ // requests — the return value is the reply, so the verdict must be thrown
967
+ await io.reply('compute', async (input) => {
968
+ if (typeof input.n !== 'number') throw new Park('n must be a number')
969
+
970
+ return { result: input.n * 2 } // ← the return channel is already spoken for
971
+ })
972
+ ```
973
+
974
+ ## `Park` from a Producer is a category error
975
+
976
+ `Park` on an `io.reply` producer would mean the caller never gets a reply and its promise never
977
+ settles — the prefetch deadlock [readme.md:469](../readme.md#L469) already admits. Today that
978
+ happens by accident once the attempts are spent; a `Park` would make it a documented path.
979
+
980
+ **Resolved: reject it.** `consume` and `process` take a `Consumer` — no caller, so somebody has
981
+ to decide what happens to a failed message. `reply` takes a `Producer` — a caller is waiting, and
982
+ what it is owed is a reply. Retry-or-park is not a policy choice there, and comq's own type
983
+ vocabulary already draws the line.
984
+
985
+ The rejection cannot be static — nothing at wiring time knows what a producer will throw — so
986
+ concretely: a `Park` from a producer is treated as a bare rejection, so the message retries and
987
+ eventually parks on the count, and comq emits a diagnostic saying `Park` is not applicable to a
988
+ reply producer. Loud, no new protocol, and the message is not lost.
989
+
990
+ The alternative of sending an error reply was rejected on its own terms: it adds an element to
991
+ the reply protocol that every caller must learn to distinguish, and it collides with a
992
+ distinction callers already make between an error returned as a value and an exception rethrown
993
+ in them. A third kind arriving from the transport would have to be mapped onto one of those.
994
+
995
+ Burning every attempt on a request nobody can process does **not** wedge the consumer, which was
996
+ an earlier worry here: a retry publishes and acks, so the delivery is released immediately and
997
+ the message waits in the retry queue, not in the consumer's unacked set. The cost is a handful
998
+ of pointless invocations.
999
+
1000
+ ## Sequencing
1001
+
1002
+ Land Parts 1 and 2 first. The crash is a dead process today; the verdict API is additive and
1003
+ non-breaking (bare rejection = `Retry` = the behaviour Part 2 establishes), so it composes
1004
+ cleanly on top as a minor release.
1005
+
1006
+ One thing argues for doing them together: Part 1 names the parking queue and Part 3 names the
1007
+ class, and they should agree — `Park` → `comq.parked.<queue>`, or `Dead` →
1008
+ `comq.parked.<queue>`. Naming it twice means renaming a queue in a later release, which orphans
1009
+ whatever is sitting in the old one. **Pick the noun once**, before Part 1 is implemented; it is
1010
+ the only thing Part 3 needs decided up front.
1011
+
1012
+ ## Surface it adds
1013
+
1014
+ - `source/index.js` currently exports only `connect` and `assert`, and comq has no exported
1015
+ error classes at all — `Retry`/`Park` would be the first. Clean addition, plus
1016
+ `types/index.d.ts` (which re-exports from `./io`; add the two classes there).
1017
+ - `source/verdicts.js` is required directly by `source/channel.js`, not through
1018
+ `source/index.js` — `index.js` pulls in `connect`, and the channel must not depend on it.
1019
+ - Unit tests: `verdictOf` returns `RETRY` for `undefined`, `null`, a string throw, a plain
1020
+ `Error`, and an object with a *different* symbol key; returns `PARK` only for the brand. Then
1021
+ in `test/channel.test.js`, a consumer throwing `Park` publishes to the parking queue on the
1022
+ **first** delivery with no retry publish, and a consumer throwing `Retry` behaves exactly like
1023
+ a bare rejection.
1024
+ - One feature scenario per verdict: a `Park()` on first delivery lands in the parking queue with
1025
+ no retry cycle at all (and completes fast, since it skips five TTL waits — a useful contrast
1026
+ with the full-ladder scenario); a `Retry()` behaves exactly as a bare rejection.
1027
+ - Docs: the verdicts get their own readme section, and the `x-comq-reason` header note in
1028
+ `docs/headers.md` should mention that a `cause` is recorded alongside it.
1029
+
1030
+ ---
1031
+
1032
+ # Context
1033
+
1034
+ ## The six defects
1035
+
1036
+ All introduced by `1df2afe` *refactor: throw exceptions on poison messages*, in
1037
+ `#getAcknowledgingConsumer` ([:370](../source/channel.js#L370)) and `#requeue`
1038
+ ([:393](../source/channel.js#L393)).
1039
+
1040
+ 1. **It acks before it publishes.** [:394](../source/channel.js#L394) acks, then
1041
+ [:399](../source/channel.js#L399) publishes. A crash between the two loses the message.
1042
+ 2. **It republishes to `message.fields.exchange`.** For a queue bound to a fanout exchange that
1043
+ *is* the exchange, so a retry meant for the one subscriber that failed is fanned out to
1044
+ every subscriber again.
1045
+ 3. **It seals the channel** ([:398](../source/channel.js#L398)). `seal()` cancels every consumer tag
1046
+ on the channel; `#sealed` is never cleared (not even by `recover()` at
1047
+ [:213](../source/channel.js#L213)) and `recall.reset` at [:198](../source/channel.js#L198) throws
1048
+ away the callbacks that would let them come back. And it is worse than one subscription:
1049
+ [source/io.js](../source/io.js) routes `consume`, `emit`, `route`, `subscribe`, `process` **and**
1050
+ `enqueue` through a single `#events` channel, so one poison event permanently silences every
1051
+ event consumer *and* every task processor in the process.
1052
+ 4. **It rethrows into amqplib** ([:384](../source/channel.js#L384)), which dispatches deliveries
1053
+ through an EventEmitter listener (`amqplib/lib/channel_model.js:47` →
1054
+ `lib/channel.js:455 return consumer(message)`) and drops the returned promise. Unhandled
1055
+ rejection, and Node ends the process by default. That crash *is* the current retry
1056
+ mechanism: [features/events.poison.feature](../features/events.poison.feature) is entirely
1057
+ `@manual` and instructs a human to "run this scenario five times".
1058
+ 5. **It reads `message.properties.headers[...]` unguarded**
1059
+ ([:379](../source/channel.js#L379), [:396](../source/channel.js#L396)) — see the correction below.
1060
+ [source/shards/channel.js:204](../source/shards/channel.js#L204) already guards the identical
1061
+ pattern with `?.`.
1062
+ 6. **The last attempt destroys the message** — Part 1.
1063
+
1064
+ ## Details worth knowing
1065
+
1066
+ Findings from verifying the above against the code and against amqplib, kept because they
1067
+ correct claims that get made about this area.
1068
+
1069
+ - **The unguarded header read is narrower than it looks.** It is often described as breaking for
1070
+ any task, broadcast or request. It does not, for anything comq published:
1071
+ `amqplib/lib/api_args.js:191` does `Object.create(options.headers || null)` and always assigns
1072
+ `headers` into the publish properties, and `lib/codec.js:65` encodes with
1073
+ `for (const key in val)`, walking the prototype chain. So every comq-published message arrives
1074
+ with a headers table, at minimum `{}`. The `TypeError` is real only for a message published by
1075
+ a **non-comq client** that omits the field table (`defs.js:3438`, `headers: void 0` is the
1076
+ decode default). That is a supported case rather than a hypothetical one — consuming events
1077
+ published by something that is not comq is a thing comq is used for — so the guard is
1078
+ load-bearing, not tidiness. `source/shards/channel.js:204` already guards the identical
1079
+ pattern.
1080
+ - **Publisher confirms cover events only.** `source/topology/request.json` is `confirms: false`
1081
+ and the channel is created with `createChannel`, not `createConfirmChannel`
1082
+ ([channel.js:68](../source/channel.js#L68)) — there is no confirm to await on the request channel.
1083
+ Requests are also `persistent: false`, so a requeued request does not survive a broker restart
1084
+ regardless. This is why Part 2 fixes requests structurally but calls them best-effort.
1085
+ - **`MAX_REDELIVERIES = 5` meant six deliveries, not five.** The counter was absent on the first
1086
+ delivery, so it read `0` and the message was retried; `5 >= 5` first held on the sixth, while
1087
+ [readme.md:466](../readme.md#L466) said "five times in a row". Fixed by counting deliveries
1088
+ rather than retries, which is what the setting's name claims.
1089
+ - **Test coverage for this path collapsed in `1df2afe`.** That commit deleted the requeue unit
1090
+ test and the one automated scenario in `features/rpc.feature`, replaced them with two
1091
+ `@manual` scenarios that assert nothing, and left the `the message is discarded` step in
1092
+ `features/steps/poison.js` referenced by no feature file at all.
1093
+
1094
+ ## Decisions already taken
1095
+
1096
+ - **Retry delay: broker-side delay queue (TTL + DLX).** Survives a process restart and does not
1097
+ hold an unacked delivery against prefetch.
1098
+ - **`seal()` on poison is replaced by a diagnostics event only.** The channel keeps consuming.
1099
+ No circuit breaker.
1100
+ - **Requests get the same structural fixes, best-effort.** `request.json` is not changed; the
1101
+ limitation is documented.
1102
+ - **Parking, not broker dead-lettering** (Part 1). Decided on retention, with the migration cost
1103
+ ruled acceptable and therefore not a criterion: dead-lettering on classic queues can still
1104
+ lose the message, and the point of keeping it is that it is kept.
1105
+
1106
+ ## Still open
1107
+
1108
+ Nothing blocking. The three questions this document opened are resolved:
1109
+
1110
+ - **The noun** — `Park` / `comq.parked.<queue>`. See Part 1.
1111
+ - **`Park` on an `io.reply` producer** — rejected as a category error, treated at runtime as a
1112
+ bare rejection plus a diagnostic. See Part 3.
1113
+ - **Whether Part 3 is built** — built. Parts 1 and 2 shipped first as the emergency, and a
1114
+ consumer of comq got retry-then-park by upgrading and writing no code; the verdicts followed
1115
+ as an additive minor.
1116
+
1117
+ One thing the design did not settle, found while building it: it placed the `Park`-from-a-Producer
1118
+ rejection in `#failed`, which cannot implement it — `Channel` takes a topology but not its type
1119
+ ([channel.js:64](../source/channel.js#L64)), so it cannot tell the request channel from the event
1120
+ one. It lives in `#getRequestConsumer` ([io.js:297](../source/io.js#L297)) instead, which knows by
1121
+ construction that it is wrapping a `Producer`. And rather than emit a diagnostic nobody may be
1122
+ subscribed to, it rethrows with an explanation, which reaches the parked message as its
1123
+ `x-comq-reason` and both existing diagnostics as their exception — louder, and no new event name
1124
+ to document.