You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Fix the unsettled path. Make RabbitMqInteropFriendlyCallback.MoveToErrorsAsync settle the original delivery the same way RabbitMqChannelCallback.moveToErrorQueueAsync does: set RabbitMqEnvelope.Acknowledged and Envelope.HasBeenAckedbefore 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).
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.
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
No settle path leaves a delivery permanently unacked.
All acks are multiple: false.
Full RabbitMQ test suite green locally (expect ~10 min); no message leaked into quorum queues.
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 withConsumerDispatchConcurrency > 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 siblingRabbitMqChannelCallback.moveToErrorQueueAsync(Internal/RabbitMqChannelCallback.cs:112-137) does this correctly: it marksAcknowledged/HasBeenAckedand then nacks without requeue.Flipping
multiple: true → falsein isolation was already attempted during the GH-3492 perf wave and DEFERRED: it measured throughput-neutral but leaked 1 message intoquorum1in 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.ackis a fire-and-forget frame, not an RPC; ledger row 216).Execution plan
RabbitMqInteropFriendlyCallback.MoveToErrorsAsyncsettle the original delivery the same wayRabbitMqChannelCallback.moveToErrorQueueAsyncdoes: setRabbitMqEnvelope.AcknowledgedandEnvelope.HasBeenAckedbefore issuingBasicNackAsync(tag, multiple: false, requeue: false)againstenvelope.DeliveredOn, guarded byRabbitMqListener.CanSettle(RabbitMqListener.cs:309-338). Audit for any other path that produces a delivery which is never settled (search all uses ofRabbitMqEnvelopewhere neither ack nor nack fires).RabbitMqListener.CompleteAsync(RabbitMqEnvelope)toBasicAckAsync(envelope.DeliveryTag, multiple: false, …). Update/remove the large explanatory comment atRabbitMqListener.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.5f6b3bbc7) already routed all settles throughenvelope.DeliveredOn+CanSettle; re-readCanSettle's doc comment — its stale-tag rationale explicitly assumedmultiple: truemade replay catastrophic. The guard stays (settling on a replaced channel is still wrong), but the comment should be updated.Tests
ConsumerDispatchConcurrency > 1or hand-driven consumer) proving that withmultiple: 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.RabbitMqInteropFriendlyCallback.MoveToErrorsAsyncleaves zero unacked deliveries on the channel (broker queue depth / channel unacked count via management API or a fresh consumer).Wolverine.RabbitMQ.Testssuite locally including the quorum-queue tests that caught the leak last time (docker compose up -d rabbitmqfirst). The previous attempt leaked exactly 1 message intoquorum1— watch for that specific failure.src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/Bugs/Bug_3687_settling_a_delivery_from_a_dead_channel.cs.Documentation
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
multiple: false.Dependencies