Skip to content

GH-4186: report a NativeAck listener's real queue depth and last receipt - #4187

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4186-native-ack-queue-depth
Aug 29, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-4186-native-ack-queue-depth

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4186.

What was wrong

ListeningAgent read the receiver's depth through ILocalQueue:

public int QueueCount => (_receiver is ILocalQueue q ? q.QueueCount : 0) + ...

NativeAckReceiver and InlineReceiver are deliberately not local queues — a delivery in either mode settles against the listener that brought it, not against a queue — so both contributed a constant 0 to EndpointHealthSnapshot, despite each already maintaining a real depth over a real block. The issue's inference was correct: a saturated NativeAck listener was indistinguishable from an idle one.

LastQueueActivityAt was the same root cause. Its change-detection heuristic has exactly one writer, UpdateQueueCountObservation(), whose only caller is BackPressureAgent — which correctly does not run for these two modes (Endpoint.ShouldEnforceBackPressure, GH-3708). So the timestamp stayed at the DateTimeOffset.UtcNow it was initialised with at construction, for the life of the process.

No back-pressure behaviour was changed for NativeAck or Inline. They still have no BackPressureAgent, as designed.

The shape

Shape 2 from the issue, and the test pins why rather than just the outcome. A new narrow IHasQueueDepth that ILocalQueue extends:

  • BufferedReceiver / DurableReceiver satisfy it for free, unchanged.
  • NativeAckReceiver / InlineReceiver implement it directly — reporting a depth without claiming they can be enqueued into.

Shape 1 (NativeAckReceiver : ILocalQueue) was the smaller diff and the wrong one: EnqueueDirectlyAsync type-switches on ILocalQueue before it reaches the NativeAck branch GH-4011 added, so a NativeAck receiver claiming to be a local queue would silently take the wrong path on every DLQ replay. a_native_ack_receiver_reports_a_depth_without_claiming_to_be_a_local_queue guards that.

LastReceivedAt is a default-null member on the same interface, stamped on receipt by the two receivers that need it. ListeningAgent prefers it when it is the more recent of the two, so every other receiver keeps the existing heuristic byte-for-byte.

One thing beyond the issue, called out deliberately

The two pass-through wrappers — ReceiverWithRules and GlobalPartitionedInterceptor — were also swallowing the depth. Delegating through them is required for the NativeAck fix to hold when incoming envelope rules or a global-partitioned topology are in play.

For ReceiverWithRules that is reporting only. For GlobalPartitionedInterceptor wrapping a Buffered or Durable receiver it is not: QueueCount read 0, so BackPressureAgent could never fire on a global-partitioned endpoint and the buffer could grow unbounded. That is a real fix, but it is a behaviour change on a path the issue did not name, so flagging it rather than burying it.

Evidence

native_ack_queue_depth_4186 asserts on CollectEndpointHealth() end to end, exactly as the issue asked for — listener started through IEndpointCollection, handlers parked on a gate, snapshot read from the real collection.

Verified red first: with ListeningAgent reverted to the ILocalQueue read, depth_and_receipt_activity_reach_the_endpoint_health_snapshot times out on QueueCount > 0 and inline_receiver_depth_reaches_the_snapshot_too gets 0 where it wants 1.

Green after: 3/3 here, and CoreTests 2674/2674 with 2 pre-existing skips. dotnet build wolverine.slnx -c Release -f net9.0 clean.

🤖 Generated with Claude Code

ListeningAgent read the receiver's depth through ILocalQueue. NativeAckReceiver
and InlineReceiver are deliberately not local queues -- a delivery in either mode
settles against the listener that brought it, not against a queue -- so both
contributed a constant 0 to EndpointHealthSnapshot despite each maintaining a real
depth over a real block. A saturated NativeAck listener was indistinguishable from
an idle one, and rendered downstream as a green zero rather than as "unknown".

Read the depth through a new IHasQueueDepth instead, which ILocalQueue extends.
Implementing ILocalQueue on NativeAckReceiver would have been the smaller diff and
the wrong one: EnqueueDirectlyAsync type-switches on ILocalQueue *before* reaching
the NativeAck branch GH-4011 added, so claiming to be a local queue would silently
take the wrong path on every DLQ replay. A test pins that shape.

LastQueueActivityAt is the same root cause: its change-detection heuristic has
exactly one writer, BackPressureAgent, which correctly does not run for either mode
(Endpoint.ShouldEnforceBackPressure), so the timestamp stayed frozen at listener
construction for the life of the process. Both receivers now stamp receipt directly
and ListeningAgent prefers that stamp when it is the more recent of the two; every
other receiver keeps the existing heuristic unchanged.

Also passes depth through GlobalPartitionedInterceptor and ReceiverWithRules, the
two pass-through wrappers that were swallowing it. For a wrapped Buffered or
Durable receiver that had a second effect beyond reporting: QueueCount read 0, so
BackPressureAgent could never fire on a global-partitioned endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

NativeAck listeners always report QueueCount 0 and a LastQueueActivityAt frozen at construction

1 participant