On 6.24.6, so both GH-3590 (#3598) and GH-3680 (#3695) are in. 1,350 envelopes have been sitting at status = 'Incoming', owner_id = 0, attempts = 0 for hours and nothing picks them up.
The gap
PartitionedMessageTopology forces every partitioned endpoint to ListenerScope.Exclusive, local queues included:
// PartitionedMessageTopology, ctor
var endpoint = buildEndpoint(options, name);
endpoint.UsedInShardedTopology = true;
endpoint.ListenerScope = ListenerScope.Exclusive;
So a PublishToPartitionedLocalMessaging("activiteiten", 16, …) topology produces sixteen durable local://activiteitenN/ endpoints with ListenerScope.Exclusive.
Now both recovery paths decline:
1. The durability agent skips it. CheckRecoverableIncomingMessagesOperation.PostProcessingCommands():
// GH-3590: … Recovery for those endpoints is owned by the listening node itself (ListenerInboxRecovery).
if (_endpoints.IsSingleNodeListener(incoming.Destination))
{
continue;
}
and
bool IsSingleNodeListener(Uri address)
=> EndpointFor(address) is { ListenerScope: not ListenerScope.CompetingConsumers };
No LocalQueue carve-out, so this returns true and recovery is skipped.
2. But the listening node never owns it either, because a local queue never gets a ListeningAgent. startInboxRecoveryIfNecessary() is called at the end of ListeningAgent.StartAsync(), and the only loop that creates those agents excludes local queues:
public async Task StartListenersAsync()
{
…
var listeningEndpoints = _options.Transports.SelectMany(x => x.Endpoints())
.Where(x => x is not LocalQueue)
.Where(x => x.ShouldAutoStartAsListener(_options.Durability));
ExclusiveListeners() excludes them too (… and not LocalQueue), which is consistent with what we see in wolverine_node_assignments: of 10,878 assignments, zero have a local:// URI — only event-subscriptions://, wolverinedb:// and the two leader agents.
So the GH-3590 carve-out hands ownership to a ListenerInboxRecoveryLoop that, for a local queue, is never constructed.
Observed
A sharded deployment (512 tenant message databases, 5 nodes, Wolverine 6.24.6):
|
|
| dormant rows in one tenant database |
1,350 |
| endpoints |
all sixteen local://activiteiten1..16/ |
| state |
status='Incoming', owner_id=0, attempts=0 |
| unchanged for |
hours, across several node replacements |
recovered … log lines in 20 minutes |
0, while the same window has 1,237 lines of ordinary traffic on those queues |
They get into that state the normal ways: envelopes marked replayable out of the dead letter table, and in-flight rows released to owner_id = 0 when a node is replaced. A rolling deploy does not clear them, because the node that comes back has no recovery path for these endpoints either.
Suggested fix
The GH-3590 reasoning is that an exclusive external listener runs on exactly one node while the durability agent is assigned per database and may be elsewhere. A local queue does not have that problem: it exists on every node, so the durability agent is a perfectly good owner for its inbox recovery. The carve-out over-reaches.
bool IsSingleNodeListener(Uri address)
=> EndpointFor(address) is { ListenerScope: not ListenerScope.CompetingConsumers } and not LocalQueue;
That puts local partitioned queues back under the durability agent, which already has FindListenerCircuit() fall back to the durable local queue — the comment above the skip even mentions that fallback as the reason the check has to come first.
Happy to send that as a PR with a regression test if you agree with the direction. Reproduces continuously here.
On 6.24.6, so both GH-3590 (#3598) and GH-3680 (#3695) are in. 1,350 envelopes have been sitting at
status = 'Incoming',owner_id = 0,attempts = 0for hours and nothing picks them up.The gap
PartitionedMessageTopologyforces every partitioned endpoint toListenerScope.Exclusive, local queues included:So a
PublishToPartitionedLocalMessaging("activiteiten", 16, …)topology produces sixteen durablelocal://activiteitenN/endpoints withListenerScope.Exclusive.Now both recovery paths decline:
1. The durability agent skips it.
CheckRecoverableIncomingMessagesOperation.PostProcessingCommands():and
No
LocalQueuecarve-out, so this returns true and recovery is skipped.2. But the listening node never owns it either, because a local queue never gets a
ListeningAgent.startInboxRecoveryIfNecessary()is called at the end ofListeningAgent.StartAsync(), and the only loop that creates those agents excludes local queues:ExclusiveListeners()excludes them too (… and not LocalQueue), which is consistent with what we see inwolverine_node_assignments: of 10,878 assignments, zero have alocal://URI — onlyevent-subscriptions://,wolverinedb://and the two leader agents.So the GH-3590 carve-out hands ownership to a
ListenerInboxRecoveryLoopthat, for a local queue, is never constructed.Observed
A sharded deployment (512 tenant message databases, 5 nodes, Wolverine 6.24.6):
local://activiteiten1..16/status='Incoming',owner_id=0,attempts=0recovered …log lines in 20 minutesThey get into that state the normal ways: envelopes marked replayable out of the dead letter table, and in-flight rows released to
owner_id = 0when a node is replaced. A rolling deploy does not clear them, because the node that comes back has no recovery path for these endpoints either.Suggested fix
The GH-3590 reasoning is that an exclusive external listener runs on exactly one node while the durability agent is assigned per database and may be elsewhere. A local queue does not have that problem: it exists on every node, so the durability agent is a perfectly good owner for its inbox recovery. The carve-out over-reaches.
That puts local partitioned queues back under the durability agent, which already has
FindListenerCircuit()fall back to the durable local queue — the comment above the skip even mentions that fallback as the reason the check has to come first.Happy to send that as a PR with a regression test if you agree with the direction. Reproduces continuously here.