Skip to content

RabbitMQ: settle interop-DLQ deliveries and move to per-message acks (multiple: false) #3706

Description

@jeremydmiller

Context

Every RabbitMQ ack currently goes out as BasicAckAsync(tag, multiple: true) (src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqListener.cs:350-368). Cumulative acks are only correct when completions happen in delivery order. Under out-of-order completion — which already exists today with ConsumerDispatchConcurrency > 1, and which becomes the normal case for the planned native-ack parallel endpoint mode (see the dependent issue) — acking tag N silently sweeps up every lower unacked tag on the channel, including deliveries whose handlers are still running. A crash at that moment is silent message loss.

The code comment above the ack says exactly this ("wrong under a concurrent listener … but it is currently load-bearing"). It is load-bearing because one settle path never settles at all: RabbitMqInteropFriendlyCallback.MoveToErrorsAsync (RabbitMqListener.cs:40-44) posts a copy to the DLQ and never acks or nacks the original delivery. The cumulative sweep from later acks is what currently reclaims those orphaned deliveries. Its sibling RabbitMqChannelCallback.moveToErrorQueueAsync (Internal/RabbitMqChannelCallback.cs:112-137) does this correctly: it marks Acknowledged/HasBeenAcked and then nacks without requeue.

Flipping multiple: true → false in isolation was already attempted during the GH-3492 perf wave and DEFERRED: it measured throughput-neutral but leaked 1 message into quorum1 in the full suite — precisely because of the unsettled interop path (RABBITMQ-PERF-DEEP-DIVE-PLAN.md:183-198, ledger row 217).

This issue is the prerequisite for the native-ack parallel endpoint mode. Do NOT resurrect ack coalescing/batching — that was prototyped and rejected at −10% throughput (basic.ack is a fire-and-forget frame, not an RPC; ledger row 216).

Execution plan

  1. Fix the unsettled path. Make RabbitMqInteropFriendlyCallback.MoveToErrorsAsync settle the original delivery the same way RabbitMqChannelCallback.moveToErrorQueueAsync does: set RabbitMqEnvelope.Acknowledged and Envelope.HasBeenAcked before issuing BasicNackAsync(tag, multiple: false, requeue: false) against envelope.DeliveredOn, guarded by RabbitMqListener.CanSettle (RabbitMqListener.cs:309-338). Audit for any other path that produces a delivery which is never settled (search all uses of RabbitMqEnvelope where neither ack nor nack fires).
  2. Flip the ack to per-message. Change RabbitMqListener.CompleteAsync(RabbitMqEnvelope) to BasicAckAsync(envelope.DeliveryTag, multiple: false, …). Update/remove the large explanatory comment at RabbitMqListener.cs:357-366 — replace it with a comment stating that per-message acks are required for out-of-order completion and pointing at this issue.
  3. Verify no other settle path relies on the cumulative sweep. The NullReferenceException acking a RabbitMQ delivery on a torn-down channel #3687 work (5f6b3bbc7) already routed all settles through envelope.DeliveredOn + CanSettle; re-read CanSettle's doc comment — its stale-tag rationale explicitly assumed multiple: true made replay catastrophic. The guard stays (settling on a replaced channel is still wrong), but the comment should be updated.

Tests

  • Repro-first: a test with a listener processing deliveries out of order (e.g. ConsumerDispatchConcurrency > 1 or hand-driven consumer) proving that with multiple: true, completing a later delivery acks an earlier in-flight one (assert via redelivery absence after channel close). This is the red baseline; flip and watch it go green.
  • A test that a message routed through RabbitMqInteropFriendlyCallback.MoveToErrorsAsync leaves zero unacked deliveries on the channel (broker queue depth / channel unacked count via management API or a fresh consumer).
  • Run the full Wolverine.RabbitMQ.Tests suite locally including the quorum-queue tests that caught the leak last time (docker compose up -d rabbitmq first). The previous attempt leaked exactly 1 message into quorum1 — watch for that specific failure.
  • Existing regression coverage to keep green: src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/Bugs/Bug_3687_settling_a_delivery_from_a_dead_channel.cs.

Documentation

  • Update docs/guide/messaging/transports/rabbitmq/ listener docs where ack behavior is described: acks are per-message; note the fixed DLQ-interop settle behavior.

Acceptance criteria

Dependencies

  • Blocks the native-ack parallel endpoint mode issue (filed separately).
  • Independent of everything else; can start immediately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrabbit

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions