GH-4186: report a NativeAck listener's real queue depth and last receipt - #4187
Merged
Merged
Conversation
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>
This was referenced Aug 30, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4186.
What was wrong
ListeningAgentread the receiver's depth throughILocalQueue:NativeAckReceiverandInlineReceiverare deliberately not local queues — a delivery in either mode settles against the listener that brought it, not against a queue — so both contributed a constant0toEndpointHealthSnapshot, 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.LastQueueActivityAtwas the same root cause. Its change-detection heuristic has exactly one writer,UpdateQueueCountObservation(), whose only caller isBackPressureAgent— which correctly does not run for these two modes (Endpoint.ShouldEnforceBackPressure, GH-3708). So the timestamp stayed at theDateTimeOffset.UtcNowit was initialised with at construction, for the life of the process.No back-pressure behaviour was changed for
NativeAckorInline. They still have noBackPressureAgent, as designed.The shape
Shape 2 from the issue, and the test pins why rather than just the outcome. A new narrow
IHasQueueDepththatILocalQueueextends:BufferedReceiver/DurableReceiversatisfy it for free, unchanged.NativeAckReceiver/InlineReceiverimplement it directly — reporting a depth without claiming they can be enqueued into.Shape 1 (
NativeAckReceiver : ILocalQueue) was the smaller diff and the wrong one:EnqueueDirectlyAsynctype-switches onILocalQueuebefore 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_queueguards that.LastReceivedAtis a default-nullmember on the same interface, stamped on receipt by the two receivers that need it.ListeningAgentprefers 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 —
ReceiverWithRulesandGlobalPartitionedInterceptor— 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
ReceiverWithRulesthat is reporting only. ForGlobalPartitionedInterceptorwrapping a Buffered or Durable receiver it is not:QueueCountread0, soBackPressureAgentcould 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_4186asserts onCollectEndpointHealth()end to end, exactly as the issue asked for — listener started throughIEndpointCollection, handlers parked on a gate, snapshot read from the real collection.Verified red first: with
ListeningAgentreverted to theILocalQueueread,depth_and_receipt_activity_reach_the_endpoint_health_snapshottimes out onQueueCount > 0andinline_receiver_depth_reaches_the_snapshot_toogets0where it wants1.Green after: 3/3 here, and CoreTests 2674/2674 with 2 pre-existing skips.
dotnet build wolverine.slnx -c Release -f net9.0clean.🤖 Generated with Claude Code