Skip to content

Dormant inbox rows for a durable LocalQueue with ListenerScope.Exclusive are never recovered: the durability agent skips it and it never gets a ListeningAgent #3856

Description

@erdtsieck

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions