gobridge

Runbook: Poison Message / DLQ Growth

Applies to: any route with a DLQ store configured. Audience: on-call operators. Risk: medium — redriving or purging DLQ entries changes delivery state; read the entry’s LastError before you act.

Symptom

Diagnosis

  1. Read the DLQ summary and page the entries (http-api.md#admin-api-endpoints):

    curl -s -H "X-API-Key: ${ADMIN_KEY}" \
      "http://<host>:8080/api/v1/admin/dlq"                       # {configured,count,count_capped}
    curl -s -H "X-API-Key: ${ADMIN_KEY}" \
      "http://<host>:8080/api/v1/admin/dlq/messages?route_id=<id>&limit=20"
    
  2. Open a single entry to get the real cause. The LastError field carries the underlying failure — treat that as the thing to fix (troubleshooting.md#poison_message):

    curl -s -H "X-API-Key: ${ADMIN_KEY}" \
      "http://<host>:8080/api/v1/admin/dlq/messages/<id>"         # audited as dlq.read_payload
    
  3. Classify from the error code (troubleshooting.md): POISON_MESSAGE means retries and replay attempts were exhausted; INVALID_PAYLOAD / SCHEMA_VIOLATION mean the message will never parse and must be fixed at the producer.

  4. Separate the counters (monitoring.md#key-metrics): DLQEntries (route_id, category) is the DLQ write count; DLQWriteFailures (no dimension) means the DLQ store itself is rejecting writes or no lease was held; MessagesDropped (route_id, reason) is a terminal drop that wrote no DLQ record — silent loss, alert on it directly; DLQWriteHold (timer, no dimension) is how long each DLQ write held its caller.

  5. Intake stalled during a poison burst? Read DLQWriteHold. The DLQ write is synchronous and confirmed before the source delivery is settled, so the failure evidence is at least as durable as the message it describes. The cost of that guarantee is backpressure: while the DLQ store is unhealthy each DLQ-bound delivery holds its route goroutine — and a global in-flight slot — for up to the write budget. In the shipped runtime wiring that ceiling is 10.5 s (2 attempts × 5 s write timeout + one 500 ms backoff); it is not configurable per route.

    DLQWriteHold Reading
    p99 ≈ 0 Healthy store; holds are noise.
    p99 rising, DLQWriteFailures flat The store is slow but still confirming — intake throughput is already reduced.
    p99 at ~10.5 s with DLQWriteFailures rising Every DLQ write is burning the full budget and failing. Intake for DLQ-bound traffic is effectively stopped.
    No samples at all while a route is visibly stalled The store is ignoring cancellation — a wedge, not a slow write. The 10.5 s ceiling assumes the store honors its write deadline; the timer is only emitted when the write returns.

    Alarm on DLQWriteHold p99 > 5 s for 5 minutes (half the ceiling), paired with DLQWriteFailures > 0. This is by design, not a defect: the alternative to holding is settling a source message whose failure evidence was never written. Messages are not lost during the hold — they stay unsettled and are redelivered.

Action