RabbitMQ dead-letter queues: poison-message handling
Every RabbitMQ consumer job in Workers now dead-letters a message it can't process after retries exhaust, instead of the old ack-and-drop. Shipped 2026-08-25, replacing the earlier "no DLX, by design" stance once the real data-loss risk became clear — see Workers Resilience for the retry policy that runs before a message reaches this point.
Topology
RabbitMqDeadLetterTopology.DeclareAsync(channel, queueName, querySuffix, ct)
declares one shared dead-letter exchange per deployment (eos.dlx<QueueSuffix>,
direct-typed) plus a per-consumer dead-letter queue (<queueName>.dlq) bound
to it with a routing key equal to the queue's own name, and returns the
x-dead-letter-exchange/x-dead-letter-routing-key arguments the caller
passes into its own main-queue declare. All declare/bind calls are idempotent
— whichever consumer job starts first wins.
Every consumer's ReceivedAsync handler tracks a local succeeded flag: ack
on success (unchanged), BasicNackAsync(deliveryTag, false, requeue: false, ct)
on final failure. RabbitMQ only routes a nacked/rejected message to the DLX
when the queue itself carries the dead-letter arguments above. A message that
fails to deserialize at all (bad JSON) is nacked/dead-lettered the same way; a
successfully-deserialized-but-null message still acks, matching the prior
silent-no-op behavior.
Admin surface
IDeadLetterQueueAdmin/RabbitMqDeadLetterQueueAdmin (registered
scoped — a fresh connection per call, since this is an infrequent admin
action, not a hot path):
ListAsyncreports each known consumer's DLQ name/source queue/current message count.RequeueAsync(dlqName, maxMessages, ct)pulls up tomaxMessagesoff a DLQ and republishes each one to its original source queue via the default exchange, then acks it off the DLQ.dlqNamemust be one ofIDeadLetterQueueAdmin.KnownConsumerQueueNames— anything else throws before touching the broker. This is a fixed diagnostic tool over 4 known queues, not a general-purpose queue browser.
DeadLetterQueueController (Api/Controllers/Operations/Messaging/):
GET api/v1/operations/deadletterqueues (list) / POST api/v1/operations/deadletterqueues/{dlqName}/requeue?maxMessages=N (requeue,
capped at 500 per call).
:::note Gated differently from most Operations controllers
This is gated by a manual CallerContext.FromClaims(User).IsSuperAdmin check
inside the controller, not IAccessControlService/AccessPolicyRegistry — it's
an ops/diagnostic tool over broker state with no MAS_MENU row, not a
persisted resourceType/resourceId entity. Classified as a
MechanismThreeOnlyController in AccessPolicyRegistryCoverageTests.
:::
Redelivery safety
A requeued-and-redelivered message is safe to reprocess because every
consumer already has its own idempotency guard (see
Workers Resilience) —
AutomationActionExecutorJob/OutboundEmailConsumerJob via
IProcessedDomainEventGuard, AuditLogConsumerJob via
EOS_AUDIT_LOG.EventId's own UNIQUE constraint.
AutomationTriggerConsumerJob stays deliberately un-deduped — same
accepted-narrow-blast-radius reasoning as before.
:::caution Not built
No automatic requeue/alerting on a growing DLQ — an operator has to check
GET .../deadletterqueues (or a future alert on RabbitMQ's own queue-depth
metric) and requeue manually today.
:::
Reference files
RabbitMqDeadLetterTopology.cs, IDeadLetterQueueAdmin.cs,
RabbitMqDeadLetterQueueAdmin.cs, DeadLetterQueueController.cs, each
consumer job's own doc comment.