ADR-059: Bounded DLQ Retry — Error Taxonomy, Parking Replayer, Quarantine, Settlement Teardown
On this page
Status
Accepted (2026-07-29), implemented as designed by the #1197 MR (epic &75 C3) — units U1
(taxonomy + settlement teardown), U2 (parking replayer + quarantine + the _park capture),
U3 (the security SQLSTATE classification). As-built deltas from this document: none
substantive; two hardening details landed beyond the letter of the design — the replayer’s
rebuild redeclares BOTH queues it publishes to (a deleted quarantine queue self-heals without
a main-session rebuild), and identity fields mirrored onto the outer capture envelope are
clamped to 256 bytes (a degenerate producer cannot push the outer envelope past the broker
limit the §D6 fit reserves margin for). Plan:
DLQ Forensic Durability & Idempotency § C3 design.
Amends (named): ADR-022 (§D3’s DLQ
consumer loses its log-and-ack posture — handler failures now park or quarantine, never
ack-and-lose; the #1181 amendment’s "structural unification is #1197’s single-authoritative-path
scope" promise is re-routed to #1053, which owns the producer-side retry-ladder redesign) and
ADR-003 (the topology gains two per-DLQ-queue durable
classic queues — {queue}.parking and {queue}.quarantine — declared through the same
craig-mq choke-point the #1198 amendment pinned; the deployment guide’s policy MUST-NOTs are
extended).
Context
The DLQ audit consumer (run_dlq_session) acked unconditionally after a handler error — a DB
outage permanently discarded the forensic record — and acked-and-dropped malformed envelopes.
Every ack/nack failure in both consume sessions was warn-and-continue, silently leaking
prefetch credit (16) until the consumer stalled with nothing louder than a warn!. C2
(ADR-022 §#1181) gave dead-letter occurrences durable identity; C4 (ADR-003 §#1198) pinned the
classic-queue substrate. This ADR adds the disposition machinery between them.
A count-cap-then-ack recreates the loss; unbounded requeue head-of-line blocks. The reviewed v1 of this design used a broker TTL+DLX return hop and was rejected: classic-queue dead-lettering is broker-managed best effort (at-least-once dead-lettering is a quorum-queue feature, explicitly reserved by ADR-003 §#1198’s quorum gate), so the expiry hop could lose the only remaining copy after the original was acked.
Decision
D1. Safety invariant (with its honest bounds)
A delivery is acked ONLY after a committed audit row or a client-confirmed durable publish.
Every hop in this design is a client publish with publisher confirms + mandatory
persistent delivery mode, acked only on Confirmation::Ack with no returned message — there
is no broker-managed dead-letter hop anywhere in the retry path.
Bounds, named: dedup is exactly-once per occurrence token while its canonical row is in the
audit hot window (post-archive redelivery re-records — the C2 R9 bound); the dual producer
paths can still mint two tokens for one terminal failure (the C2 R1b bound — producer-side
unification is #1053’s scope); tokenless (raw/foreign) deliveries have no automatic dedup and
gain more re-execution paths under teardown redelivery (disclosed, rate-bounded by D5’s
cross-cycle backoff); operator policies (message-ttl, expires, max-length*, destructive
overflow) on craig* queues can silently destroy any queue-resident copy — an operator
convention the deployment guide prohibits, not runtime-enforceable with AMQP-only credentials.
D2. Error taxonomy
craig_mq::Disposition { TransientRetry, TransientPark, Permanent }
DlqError<E> { disposition, source } (the InboxError<E> generic precedent — no type
erasure). subscribe_dlq handlers return Result<(), DlqError<E>>; the HANDLER — which sees
the concrete error — chooses the disposition; craig-mq never downcasts or string-matches the
source. The consumer’s classification (craig-security): Permanent ONLY for provably
message-specific failures (SQLSTATE class 22 data exceptions, class 23 constraint violations,
serializing the message’s own envelope); class 40 → TransientRetry (retry-helps);
pool/connection/io/TLS/protocol + classes 08/57/53 → TransientPark (the outage
short-circuit — in-session retries buy nothing during a hard outage); class 42 + unknown
SQLSTATEs + decode/schema errors → TransientPark (systemic drift must never quarantine a
whole backlog); the missing-canonical-row invariant bail → TransientRetry (a real race with
the ADR-058 archive pruner — a retry records the occurrence). Fail-safe default: TransientPark.
Divergent-content token collisions stay HANDLER-internal (the C2 R7 tokenless DB-quarantine row) — the quarantine queue of this ADR and the tokenless DB-quarantine row of ADR-022 are different mechanisms with different purposes.
D3. Dispositions in the DLQ session
Ok → ack. TransientRetry → up to handler_attempts TOTAL invocations in-session
(retry_delay(n) = 100 ms · n + jitter(0..=100 ms), checked arithmetic, sleeps racing
shutdown) → exhausted → PARK. TransientPark → PARK immediately. Permanent → QUARANTINE.
Malformed body (undeserializable) → QUARANTINE with reason malformed and the raw bytes
base64-embedded — never silent-dropped (this closes the C6 gap; run_session’s
undeserializable branch is unchanged — the broker dead-letters it here). A delivery whose
routing key lacks the canonical `dlq. prefix is never parked (its republish would be
unroutable — the DLQ binds dlq.#): QUARANTINE, reason noncanonical_route. A carried
park_count >= park_cap → QUARANTINE, reason park_cap_exhausted. Any publish failure,
confirm outcome other than a clean Ack, or mandatory return → NO ack →
SessionEnd::SettlementFailed.
D4. Parking tier — the client-driven replayer
{queue}.parking: durable, classic, NO TTL, NO DLX arguments — a plain holding queue
(published to via the default exchange; the return routing key lives in the capture wrapper,
not the transport). subscribe_dlq spawns a SECOND supervised consumer on it — own
session/connection, prefetch 1, sharing a per-subscription CHILD of the shutdown token
(#1232: service shutdown propagates parent→child; the consumer arm cancels the child on its
abnormal end so the joined task’s death is visible to handle supervision): receive a parked
message →
due = parked_at + parking_ttl → sleep until due (racing shutdown; FIFO + a uniform TTL means
due times are monotonic, so head-blocking IS the delay mechanism) → republish to craig.dlx
under the ORIGINAL dlq.* routing key (confirmed + mandatory + persistent) → ack the parked
delivery only on a clean confirm. A broker crash mid-sleep redelivers the parked message and
the due time is recomputed — safe. The client republish carries no x-death header (the
broker interplay for client-carried history is undocumented — never relied on), so the return
leg derives identity purely from the capture wrapper.
D5. Settlement-failure teardown
lapin settlement resolves Result<bool>: Ok(false) — nothing was actually sent (killed or
already-settled acker) — is a settlement FAILURE; success is Ok(true) only. ALL ack/nack
failures in BOTH run_session and run_dlq_session end the session with
SessionEnd::SettlementFailed; the supervisor DROPS the old session before rebuilding (a
healthy-connection settlement failure must not leave the old consumer holding unacked
deliveries while a new one builds), and the broker requeues the unacked prefetch window.
Backoff carries across consecutive SettlementFailed ends and resets ONLY on a non-settlement
end — a settle-one-fail-one flapper still doubles to the 30 s cap. Teardown logs at error!.
Disclosures: teardown redeliveries arrive redelivered = true and consume the 2-strike ladder’s
first strike (a DLX-rate inflation after settlement incidents — not loss); a persistent
settlement failure is an acceptable-loud bounded loop (acks fail by channel state, not message
content). After a confirmed publish, the corresponding ack runs un-raced against shutdown but
bounded (5 s) — a hang tears down; the unacked original redelivers.
D6. The capture wrapper (one schema for park AND quarantine)
JSON body, the _dlx precedent — no AMQP-header novelty. Outer EventEnvelope mirrors the
original’s id/source_service/event_type (so a fall-through recording keeps threshold
semantics; the malformed variant uses a fresh v7 id, source_service = the DLQ queue name,
event_type = "dlq.malformed"); payload:
{"_park": {"v": 1, "capture_id": "<uuid v7>", "park_count": 0, "parked_at": "<rfc3339>",
"reason": "transient|park_cap_exhausted|permanent|malformed|noncanonical_route",
"last_error": "<truncated 2 KiB + sha256>", "occurrence_token": "<verbatim|null>",
"occurred_at": "<rfc3339|null>", "original_queue": "…", "original_routing_key": "dlq.…",
"original_envelope": { … } }}
(the malformed variant carries original_body_b64 instead of original_envelope). The
ORIGINAL occurrence token travels verbatim — no new grammar prefix; dedup identity survives
the park hop, and a parked return that was already recorded absorbs as an exact C2 R7 phantom.
Size safety: last_error truncated to 2 KiB (+ sha256 of the full text); if the serialized
capture would exceed MAX_CAPTURE_BYTES (12 MiB, under RabbitMQ 4.x’s 16 MiB default
max_message_size), the embedded body is truncated to a prefix + sha256 + truncated: true —
a capture can never become unpublishable and wedge the settlement loop. Unwrap happens in
build_dead_letter_delivery BEFORE the handler; validation mirrors the C2 R6/R2 rules
(v == 1, original_queue agreement, token grammar when present). park_count parses as any
valid u32 — the cap applies as DISPOSITION POLICY, never as wrapper validity (a cap decrease
or mixed-replica caps must not turn legitimate parked messages into forged fall-throughs). An
invalid wrapper falls through to normal derivation and records as the wrapper envelope —
forensically intact, bounding forgery amplification.
D7. Quarantine tier — governance
{queue}.quarantine: durable, classic, no TTL, no DLX, no consumer. It holds child-welfare
forensic content OUTSIDE the ADR-058 retention machinery — named, not silent: ownership is the
operator’s (drained per the deployment guide’s manual-ack replay procedure — consume, validate
the capture, republish to craig.dlx confirmed+mandatory, ONLY THEN ack; malformed captures
are excluded from replay and drain to file); broker disk alarms are the backstop; every
quarantine publish logs at error! (envelope id, reason, queue) alongside the #1199 metrics —
the log line carries the envelope id the counter cannot. The parking/quarantine depth gauges
this section named as a launch requirement of C5 (#1199) landed there (dlq_queue_depth,
see the Amendment below and the
DLQ triage runbook). Duplicate
copies (ack-fail after a confirmed publish → redelivery → second copy) are a designed outcome;
tokenless/malformed captures have NO reliable automatic dedup — each capture carries a fresh
capture_id for operator correlation.
D8. Configuration
DlqRetryConfig { parking_ttl, park_cap, handler_attempts, depth_sample_interval } —
named-field + Default (production: 10 min / 12 / 3 total invocations / 30 s sampling →
~2 h outage ride-through before quarantine; while parked an occurrence has no DB row — since
#1199 the dlq_queue_depth{queue="….parking"} gauge is its visibility). Validated at
subscribe_dlq (handler_attempts ∈ 1..=10, parking_ttl ∈ 1 s..=24 h,
park_cap ∈ 1..=100, depth_sample_interval ∈ 1 s..=1 h) with a typed DlqConfigError;
the subscribe error becomes SubscribeDlqError { Config, Amqp }.
D9. Threshold interplay
Parked returns carry the ORIGINAL occurred_at: returns within the 1-hour window (short
outages — the common case at the 10-minute default TTL) still count toward the #1156 threshold
and can fire the alert; only returns delayed past the window miss permanently — R5 working as
designed (a stale backlog must not masquerade as a current burst). The #1199 dlq_queue_depth
gauges are the compensating control for outage-masked bursts (landed — see the Amendment).
Consequences
-
subscribe_dlqis a pre-1.0 breaking change (typed handler error + config parameter); all call sites updated in the same MR, no shims. -
The R7 equivalence across the park round-trip depends on f64
serde_json::Numbersemantics: enabling thearbitrary_precisionfeature anywhere in the workspace (feature unification) would flip Number equality text-wise and mint false DIVERGENT quarantines — guarded by a canary unit test that fails under that feature. -
Two new durable queues per DLQ subscription appear on the broker (one production DLQ →
craig-security.dlq.parking/.quarantine); the #1202 permission-separation work must scope the DLQ service’s credentials to cover them. -
Handler panics compose with this machinery as designed (#1203, landed 2026-07-30): the per-delivery task boundary converts an unwind panic to
TransientPark— parked IMMEDIATELY, never re-invoked in-session, captured under the wire-compatiblereason="transient"withlast_error = "handler panicked: …", counted bydlq_handler_panics_total{path}. Disclosure: a DETERMINISTICALLY panicking handler is re-invoked once perparking_ttlcycle until the park cap quarantines it aspark_cap_exhausted(~2 h at defaults) — bounded by design, and the panic counter fires on every cycle so the defect is visible long before the cap.
Alternatives considered
-
Broker TTL+DLX return hop (v1): rejected — best-effort dead-lettering on classic queues is a loss window after the original is acked, and the TTL declare-arg makes every future TTL change a redeclare-406 boot hazard.
-
Per-message expiration: rejected — expiry applies only at the queue head; a long-TTL head stalls shorter ones.
-
TTL via operator policy: rejected — services hold AMQP credentials only, and the argument/policy minimum rule would let an operator silently shorten the retry cadence.
-
Count-cap-then-ack / unbounded requeue: rejected in the epic restructure — the former recreates the loss, the latter head-of-line blocks without bound.
Amendment — #1199 (2026-07-29): the C5 observability surface landed (epic &75 C5)
The launch-requirement gauges + the disposition counters shipped, riding the existing
OTel → Prometheus pipeline (instruments are live when OTEL_EXPORTER_OTLP_ENDPOINT is set;
GET /metrics serves them — empty 200 otherwise):
-
dlq_queue_depth{queue}/dlq_depth_last_sample_timestamp{queue}— sync gauges fed by a read-only depth sampler insidesubscribe_dlq(third supervised arm; own connection; a fresh channel per cycle issues passive declares — asserting existence, never creating — and readsdeclare-ok.message-count). Semantics: READY messages only — quarantine exact (no consumer), parking undercounts by ≤ 1 (the prefetch-1 replayer holds the head unacked through its TTL sleep), the DLQ itself by ≤ 16 (prefetch). The timestamp gauge is the staleness tell: a frozen depth with an advancing timestamp is a real plateau; frozen both means the sampler is down. Cadence: the validatedDlqRetryConfig.depth_sample_interval(§D8). -
dlq_captures_total{tier, reason}— confirmed capture publishes at ALL three capture sites (the disposition path, the malformed-envelope quarantine, the replayer’s unroutable branch), incremented only afterConfirmed. Counts publishes, not distinct messages — the §D7 duplicate-copy mode double-counts by design; the depth gauge is the stock truth. -
dlq_outcomes_total{outcome}— the audit handler’s decision partition (recorded/phantom_absorbed/divergent_quarantined), each incremented after its durable act.
Operator surface: the DLQ triage runbook (triage flows,
capacity guidance, the pre-C2 false-alert disclosure carried jointly with the ADR-022 #1199
amendment). Routed out: operator-visible threshold-alert routing — #1205 (landed
2026-08-09: shipped Prometheus alerting rules over this amendment’s instruments —
ADR-022 §Amendment #1205, deployment guide §DLQ alerting recommendations); drain/replay
tooling (declined optional scope) — #1206 (landed 2026-08-09: cargo xtask
quarantine-drain, the §D7 manual procedure automated at the same JSON level — §D1
ack-discipline preserved, malformed captures file-only, unclean confirms stop the drain
with the capture in place; the manual procedure stays the documented fallback).