gobridge

Runbook: Outbox Backlog / Stuck Drain

Applies to: routes using delivery_mode: shared_outbox (durable outbox). Audience: on-call operators. Risk: medium — a stuck drain grows a durable backlog and delays delivery; a wrong purge loses messages.

Symptom

Diagnosis

Each metric names a distinct cause; read them before acting (monitoring.md#key-metrics).

  1. OutboxDepth is the true PENDING backlog gauge. A steadily rising value with normal drain latency means ingress outpaces the drainer — scale drain throughput. On a store without depth reporting, the depth falls back to the claimed count and saturates at the batch size, so confirm against OutboxClaimBatchSize (a liveness signal, not backlog).

  2. OutboxDrainStalled is the single signal that a sender is wedged — in-flight sends did not return within a grace past the batch deadline, the signature of a sender that ignores context cancellation. The runtime does not kill the wedged goroutines; this is a diagnostic, not a self-recovery, signal. If it is non-zero, the partition is stuck on the target transport, not the store.

  3. DrainSkippedNoLease counts drain cycles skipped because the drainer holds no lease. A short burst on a standby is normal. A continuously-rising value on a route that should drain means a misconfigured lease — commonly a shared_outbox route bound to a non-exclusive session that never acquires a lease.

  4. OutboxDeferred rising under load flags a drain budget too small for the batch size — records are claimed but the batch deadline expires before the sends complete.

  5. OutboxStranded (non-zero) means an explicitly authorized destructive reload left observable pending records with no drainer. Non-forced orphaning reloads are refused before swap. Cross-check Cluster reconfiguration.

  6. Confirm the drainer is actually running: GET /api/v1/monitor/topology (authenticated) shows running and the route list (http-api.md). If the target transport is down, expect CONNECTION_LOST / UNAVAILABLE in logs (troubleshooting.md).

Action

When NOT to purge

Do not purge or truncate the outbox to clear a backlog. Pending records are messages the source already acknowledged after outbox persistence (ack_after: outbox_persist); deleting them is silent, unrecoverable loss. Because a manual purge bypasses the runtime, it emits no MessagesDropped (or any other) metric to record what was lost — the deletion is invisible to observability, which is exactly what makes it dangerous. Purge is only defensible for records that are provably undeliverable AND already accounted for downstream — never as a way to make a depth alarm go quiet.