Skip to content

Fix silent message loss when envelope mapper throws (RabbitMQ + MQTT)#2511

Merged
jeremydmiller merged 1 commit into
JasperFx:mainfrom
BlackChepo:fix/rabbitmq-mapper-silent-ack
Apr 14, 2026
Merged

Fix silent message loss when envelope mapper throws (RabbitMQ + MQTT)#2511
jeremydmiller merged 1 commit into
JasperFx:mainfrom
BlackChepo:fix/rabbitmq-mapper-silent-ack

Conversation

@BlackChepo

Copy link
Copy Markdown
Contributor

Closes #2510

Summary

When the incoming envelope mapper threw, both RabbitMQ's WorkerQueueMessageConsumer and MQTT's MqttListener acked the broker delivery and dropped the message. No broker
DLQ, no entry in wolverine_dead_letters, no redelivery — the message was simply lost. Triggered in the wild by foreign producers (e.g. MassTransit) whose headers the
mapper can't decode, custom mappers that throw, or header decoding on hosts with a non-UTF8 system codepage.

Both listeners now:

  1. Synthesize envelope.Id (white lie — the mapper failed before populating it, but MoveToErrorsAsync keys by Id).
  2. Attempt ISupportDeadLetterQueue.MoveToErrorsAsync on the receiver so the message lands in wolverine_dead_letters when the durable inbox is configured.
  3. Transport-specific fallback:
    • RabbitMQ: BasicNack(requeue: false) so the broker's DLX captures the message even without durable inbox.
    • MQTT: always PUBACK after the dead-letter attempt — MQTT has no broker DLQ, so leaving the message unacked would cause a poison-redelivery loop.
  4. Wrap every outbound call in try/catch so a failure inside the fix path cannot propagate out of the consumer callback.

MqttEnvelope now sets Destination = topic.Uri in its constructor, matching how Data and MessageType are already populated. Previously Destination was set by
DurableReceiver.MarkReceived, which is bypassed when the mapper throws — causing MessageDatabase.MoveToDeadLetterStorageAsync to NRE on
envelope.Destination!.ToString().

Transport behavior comparison (before → after)

Transport Before After
RabbitMQ BasicAck → silent loss MoveToErrorsAsync → broker DLX + DB when configured
MQTT _complete.PostAsync → silent loss MoveToErrorsAsync → DB (when durable inbox) + PUBACK
Azure SB / GCP Pub/Sub / Kafka / SQS / Redis / NATS / Pulsar already correct unchanged

Test plan

  • New integration test Bug_mapper_exception_routes_to_dlq under Wolverine.RabbitMQ.Tests.Bugs — publishes a raw AMQP message to a queue with a throwing
    IRabbitMqEnvelopeMapper and asserts the broker DLQ receives it.
  • New integration test Bug_mapper_exception_routes_to_dlq under Wolverine.MQTT.Tests.Bugs — durable inbox on SQL Server + throwing IMqttEnvelopeMapper; asserts an
    entry appears in wolverine_dead_letters with ExceptionType containing InvalidOperationException.
  • Both tests fail on main with a "(silent loss)" exception and pass on this branch.
  • Close regression sweep over RabbitMQ DLQ / interop / unknown-message suites: no new failures.
  • Full MQTT test suite: 89/89 pass (1 pre-existing skip).

Files changed

  • src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/WorkerQueueMessageConsumer.cs
  • src/Transports/MQTT/Wolverine.MQTT/Internals/MqttListener.cs
  • src/Transports/MQTT/Wolverine.MQTT/MqttEnvelope.cs (1 line — set Destination in constructor)
  • src/Transports/MQTT/Wolverine.MQTT.Tests/Wolverine.MQTT.Tests.csproj (added Wolverine.SqlServer project reference)
  • New: src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/Bugs/Bug_mapper_exception_routes_to_dlq.cs
  • New: src/Transports/MQTT/Wolverine.MQTT.Tests/Bugs/Bug_mapper_exception_routes_to_dlq.cs

…JasperFx#2510)

  When the incoming envelope mapper throws, both WorkerQueueMessageConsumer
  (RabbitMQ) and MqttListener (MQTT) previously logged the error and acked
  the broker delivery, silently dropping the message. No broker DLQ, no
  entry in wolverine_dead_letters, no redelivery — the message was simply
  lost. This surfaces in real deployments when a queue is shared with a
  foreign producer (e.g. MassTransit) whose headers the mapper cannot
  read, when a custom mapper throws, or when header decoding hits an
  unexpected AMQP type.

  Both listeners now attempt ISupportDeadLetterQueue.MoveToErrorsAsync on
  their IReceiver, synthesizing an envelope Id first so the dead-letter
  store has a primary key. RabbitMQ falls back to BasicNack(requeue:false)
  so the broker's DLX captures the message when the receiver path isn't
  available. MQTT always acks after attempting the dead-letter move to
  avoid a poison-redelivery loop (MQTT has no broker DLQ). Both sites
  contain every outbound call in try/catch so failures inside the fix
  path cannot propagate out of the consumer callback.

  MqttEnvelope now sets Destination in its constructor, mirroring how
  Data and MessageType are populated. Previously Destination was set by
  DurableReceiver during MarkReceived, which is bypassed in the mapper-
  exception path and caused MessageDatabase.MoveToDeadLetterStorageAsync
  to NullReferenceException when formatting the destination URI.

  Integration tests cover both transports: the RabbitMQ test publishes a
  raw AMQP message to a queue with a throwing mapper and asserts the
  broker DLQ receives it; the MQTT test uses a SQL Server durable inbox
  and asserts an entry appears in wolverine_dead_letters with the
  original exception type.

  Closes JasperFx#2510
@jeremydmiller

Copy link
Copy Markdown
Member

@BlackChepo You've been on fire! But also wish you didn't have quite so much to go fix lately:/

I'll deal w/ the CI problems

@jeremydmiller jeremydmiller merged commit f737bb1 into JasperFx:main Apr 14, 2026
15 of 19 checks passed
@BlackChepo

Copy link
Copy Markdown
Contributor Author

Thanks Jeremy! The good news is we're finding them before users do. 😄 Happy to keep contributing fixes as I run into things
Wolverine is a joy to work with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RabbitMQ listener silently acks and drops messages when the envelope mapper throws

2 participants