Recover dormant inbox rows for durable local queues (GH-3856) - #3857
Merged
Conversation
…H-3856) PartitionedMessageTopology forces ListenerScope.Exclusive onto every slot it builds, local queues included. The GH-3590 carve-out then handed inbox recovery for those queues to a ListenerInboxRecoveryLoop that is never constructed for a local queue: LocalQueue.BuildListenerAsync() throws, StartListenersAsync() filters local queues out, and ExclusiveListeners() excludes them too, so a local queue never gets a ListeningAgent at all. The durability agent skipped them and nothing else claimed them, leaving envelopes at status='Incoming', owner_id=0 indefinitely -- across rolling deploys, since the replacement node had no recovery path either. There were two independent guards implementing that hand-off, both added by 629d8b6, and each re-derived the predicate on its own: 1. EndpointCollection.IsSingleNodeListener(Uri) 2. RecoverIncomingMessagesCommand.DeterminePageSize(), on raw ListenerScope Fixing only the first is not enough -- it converts the silent stall into an endless loop that issues a recovery command every polling pass and recovers nothing. Both now ask a single Endpoint.IsSingleNodeListener predicate, which LocalQueue overrides to false: a local queue exists on every node, so the per-database durability agent is a perfectly good owner for its inbox recovery. Reported by @erdtsieck, who diagnosed the mechanism precisely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JKfy5EzLX1i149gjUb3Tfg
This was referenced Aug 7, 2026
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.
Fixes #3856. Reported and diagnosed by @erdtsieck, who traced the exact mechanism — this PR follows their direction, applied one level up so both halves of the guard stay in agreement.
The bug
PartitionedMessageTopologyforcesListenerScope.Exclusiveonto every slot it builds, local queues included:The GH-3590 carve-out then hands inbox recovery for those endpoints to a
ListenerInboxRecoveryLoopowned by the listening node — but a local queue never gets aListeningAgentat all, for three independent reasons:LocalQueue.BuildListenerAsync()throwsNotSupportedExceptionStartListenersAsync()filtersx is not LocalQueueExclusiveListeners()filtersand not LocalQueueSo the durability agent declined and nothing else picked them up. Envelopes sat at
status='Incoming',owner_id=0indefinitely, surviving rolling deploys because the replacement node had no recovery path either.Why the obvious one-line fix isn't enough
There are two independent guards implementing the GH-3590 hand-off, both added by 629d8b6, and each re-derived the predicate on its own:
EndpointCollection.IsSingleNodeListener(Uri)RecoverIncomingMessagesCommand.DeterminePageSize()— on the rawListenerScopeFixing only the first is worse than the current bug. Measured against a real Postgres host, the durability agent then issues a recovery command every single polling pass and recovers nothing:
…on an endless loop. The silent stall becomes log spam, and the messages still never move.
The fix
One predicate instead of three re-derivations —
Endpoint.IsSingleNodeListener, overridden byLocalQueuetofalse, consumed by both guards and byListeningAgent.startInboxRecoveryIfNecessary().A local queue exists on every node, so the per-database durability agent is a perfectly good owner for its inbox recovery — which is exactly the asymmetry the GH-3590 comment relies on for external listeners, and exactly why it does not apply here.
FindListenerCircuit()already falls back to the durable local queue forlocal://addresses.Two guards that must agree should never each re-derive the rule; that is what caused this.
Verification
owner_id=0rows)dotnet build wolverine.slnx -c Release -f net9.0The one CoreTests failure (
remembered_application_assembly_reuse_warning.a_normal_single_assembly_host_does_not_warn) is pre-existing on cleanmain— baselined by stashing this branch and re-running.The existing
single_node_listener_recovery_exclusionsuite usednew LocalQueue(...)as the stand-in endpoint for arabbitmq://exclusive listener; the fix correctly breaks that, so it now usesSingleNodeListenerEndpoint.Note for a follow-up
ListenWithStrictOrdering(),ExclusiveNodeWithParallelism(), andExclusiveNodeWithSessionOrdering()all throwNotSupportedExceptionfor aLocalQueue, yetPartitionedMessageTopologyassignsListenerScope.Exclusivedirectly — bypassing that guard, and the only route into this state. This PR makes the assignment harmless rather than removing it, so the fix also covers any future path that sets the scope. Removing it from the topology may still be worth doing as a cleanup.🤖 Generated with Claude Code
https://claude.ai/code/session_01JKfy5EzLX1i149gjUb3Tfg