Repository navigation
GH-3590's reasoning applies to competing-consumer listeners too: inbox recovery is silently skipped when the database's durability agent lands on a node that does not listen to the envelope's queue #4469
Replies: 5 comments
|
@leonardomaza-prisma Not yet, and I hate the AI generated issues sometimes. Why are you hitting this? I'm not buying that this is the solution. Why do some nodes not listen to the queue in question? Are you trying to set up some nodes with a different configuration, but trying to convince Wolverine they're the same logical service? |
|
@jeremydmiller thanks for the quick reply, and I'm sorry about the issue. You're right. I did use AI for the analysis — I tried to do it as carefully as I could, reading your code and our own topology before writing anything, but what came out reads like AI slop and that's on me. Why we're hitting it: we run several pods against one message store. The API doesn't listen to the background queues, and our worker pods are split into pools that listen to different queues. What we see is that messages for a queue only come back when the durability agent for that database happens to sit on a pod that listens to it. Otherwise they stay there. Your description is accurate, it just wasn't deliberate. We never set ServiceName, and all of them share one message store (Postgres, database per tenant). Is there a way to make them separate logical services while sharing that store? That's the part I couldn't work out. How would you configure Wolverine for this? |
|
My consistent advice is to make them different logical apps with a different ServiceName and don't share the message store schema. |
|
I'm moving this to a discussion |
|
We're hitting a very similar issue. We run wolverine on our API solely for publishing events to queues. We have competing consumers that pick these events up and process them. The API has no listeners or subscribers and we don't want it to pick up any events. Is there a way to set an app as a publisher, not a consumer, that I'm missing? The API is currently picking up events and not being able to process them, which is problematic. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Versions
PersistMessagesWithPostgresql(...).RegisterTenants(...), database-per-tenant).CheckRecoverableIncomingMessagesOperation,EndpointCollection.FindListenerCircuitandRecoverIncomingMessagesCommand.DeterminePageSizebetweenV6.32.0andV6.38.0and they areidentical for the branches below.
Setup
A Kubernetes deployment with queue-segregated worker pools. Each pool listens to a different
subset of the PostgreSQL queues, and two more nodes publish but never listen:
pool-aqueue-1,queue-2,queue-3pool-bqueue-4pool-cpublisher×2All queues are
ListenerScope.CompetingConsumers. Message storage is multi-tenanted by database(five tenant databases plus the main store).
Everything below is about
queue-1, which onlypool-alistens to — the shape that matters isthat a queue has a listener on some nodes and not others.
What happens
Every node publishes the same single capability,
wolverine://durability-agents-enabled/, so theleader considers all five nodes equally eligible and distributes the per-database durability agents
by load. Nothing in that decision knows which queues a node actually listens to.
When a database's durability agent lands on a node that has no listener for a queue, every orphaned
Incomingenvelope addressed to that queue becomes permanently unrecoverable, and nothing says so:The comment assumes the null is transient. In a queue-segregated deployment it is the steady
state — and it is the only branch of that loop that produces no log at all. The three neighbouring
outcomes all log:
"Issuing a command to recover {Count} incoming messages…"(Information)"…but the listener is latched"(Information)DeterminePageSizereturns 0"Unable to recover inbox messages to destination…"(Information)Meanwhile the node that does listen to that queue sits idle next to the backlog, and every
queue-depth dashboard reads zero, because the envelopes already left the transport table.
Evidence
Measured in production on 2026-09-15, five tenant databases, same instant, same message types
(all addressed to
postgresql://queue-1/), with agent placement as the only variable:queue-1?tenant-apool-atenant-bpool-atenant-cpool-atenant-dpool-atenant-apublishernodeConfirmed in both directions: the same database's backlog is frozen while its agent is on a
non-listening node and drains within seconds of the agent moving to the listening one. Raising
WolverinetoInformationon the listening pod shows"Issuing a command to recover…"appearing exactly when a drain starts, and no log of any kind for the frozen case — which is
what identifies the
listener == nullbranch.Why the existing escape hatches do not apply
Durability.DurabilityAgentEnabledis the only capability the leader consults(
MessageStoreCollection.NodeCanRunDurabilityAgents→node.Capabilities.Contains(DurabilityCapabilityUri)).It is binary: a node either runs database agents or it does not. Turning it off on the
non-listening pods narrows the lottery but cannot express which queues a node can serve — and in
a deployment where every pool listens to a different subset, no single node is capable of all of them.
Pinning an agent (
AgentRestrictions.PinAgent) does not work for this family. The pin isapplied through
AssignmentGrid.Node.TryAssign, which requires the node to declare the agent'sown URI among its capabilities:
The durability family only ever publishes the generic
wolverine://durability-agents-enabled/, soTryAssignalways returns false and the pin is discarded — silently, leavingagent.IsPinnedfalse. Verified by writing a pin row and observing no reassignment, with theagent's own URI absent from
wolverine_nodes.capabilities.You already wrote this argument — for the other half
ListeningAgent.startInboxRecoveryIfNecessary():That reasoning — "the agent is assigned per database and routinely lands on a different node" —
is not specific to single-node listeners. It holds for any listener that is not present on every
node.
The implicit assumption for
CompetingConsumersseems to be that every node listens to every queue,so whichever node holds the database agent can serve it. That is true of a homogeneous cluster. It is
false as soon as worker pools are segregated by queue — and then a competing-consumer listener is
exactly as absent from the agent's node as an exclusive one, with the same dormant-inbox outcome.
The difference is that the exclusive case is handled, and this one fails silently.
We are aware the gate is probably not free to relax: with N nodes hosting the same durable listener,
N recovery loops would sweep every database. That trade-off is yours to make — we are reporting the
hole, not prescribing the shape.
What we would hope for
In rough order of how much we would value it:
listener == nullbranch naming which destination itwalked past would have turned four days of silence into one grep. Cheapest change, and on its own
it would have been enough for us to self-diagnose on day one.
IsSingleNodeListenergate to «this node hosts a durable listener for this endpoint»,so a competing-consumer listener owns its inbox recovery on the nodes where it actually runs — the
same remedy Durable exclusive listeners: inbox messages with owner_id = 0 are never recovered when the DurabilityAgent runs on a different node #3590 already applies, for the same stated reason.
agent: a per-queue capability, or
EvaluateAssignmentsAsyncpreferring nodes that can serve thedatabase's pending destinations.
Repro
A failing test against your own suite, in
PostgresqlTests/MultiTenancynext toexclusive_listener_recovery_across_tenant_databases:competing_consumer_inbox_recovery_across_nodes.Two
[Fact]s that differ in exactly one thing — whether the node that owns the tenant database'sdurability agent is also the node listening to the queue. The topology is expressed entirely through
public configuration (
Durability.DurabilityAgentEnabledper host), no test-only hooks:Same envelope, same bare queue address, same tenant database, same live and accepting listener. The
test asserts the listening agent really is registered before seeding, so a red result cannot be a
mis-built host.
Happy to open it as a PR — with or without the one-line logging change — whichever you prefer.
All reactions