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_dlq is 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::Number semantics: enabling the arbitrary_precision feature 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-compatible reason="transient" with last_error = "handler panicked: …", counted by dlq_handler_panics_total{path}. Disclosure: a DETERMINISTICALLY panicking handler is re-invoked once per parking_ttl cycle until the park cap quarantines it as park_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 inside subscribe_dlq (third supervised arm; own connection; a fresh channel per cycle issues passive declares — asserting existence, never creating — and reads declare-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 validated DlqRetryConfig.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 after Confirmed. 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).

Edit this page · latest