Context
We run Wolverine-managed event subscription distribution
(UseWolverineManagedEventSubscriptionDistribution = true) over a Marten store with 512 shard
databases, ~854 tenants and Events.UseTenantPartitionedEvents = true. That works out to roughly:
- ~5 100 projection agents (tenants × 6 async projections), and
- 512 durability agents (one per shard database — each shard has its own
wolverine_incoming_envelopes),
for about 5 650 distributed agents. Every API pod is a Wolverine node and carries a share of them,
assigned with database affinity. Background from our side that you have already helped with:
#3698,
#3701,
#3733.
What we are trying to do
Marten writes each projection version to its own tables. When we bump projection.Version, the new
version's tables start empty and the daemon builds them from event 0, per tenant, per shard. At our
size that takes hours, and the code that queries them has no knowledge of the previous version's
tables — so the moment a pod running the new version serves traffic, it serves empty read models.
So we want to run a separate set of pods on the new image, off the load balancer, let the daemon
build the new projection version there, and only roll the serving fleet once those read models have
caught up. The old pods keep serving the old version's tables throughout, which works naturally
because the projection version is part of the agent identity
(provided-cares:V23:All:{tenant}) — old and new pods advertise disjoint projection agents.
Those warming pods must be full Wolverine nodes; that is the only way the leader assigns them the new
version's projection agents. But being a node also brings message handling, which is exactly what they
must not do while their read models are still cold:
|
Can we switch it off? |
| External listeners (AWS SQS here) |
Yes — IEndpointCollection.StartListenerAsync / StopListenerAsync |
| Local durable queue listeners |
Yes, same API |
| Projection side effects |
Yes — Marten's GateSideEffectsBehindPriorVersion |
| Durability agents |
This is our question |
Durability agents are assigned per shard database and are version-independent, so the leader hands
them to warming pods too. They recover and execute persisted incoming envelopes, which means a warming
pod runs real handlers against half-built read models. A durability agent is not a listener, so
StopListenerAsync does nothing about it.
The actual question
DurabilitySettings.DurabilityAgentEnabled looks like it might be exactly the lever, but its doc
comment only says "Should the message durability agent be enabled during execution. The default is
true." We cannot tell from that whether, under Wolverine-managed distribution, it also:
- keeps the node from advertising durability agents, so the leader never assigns it any; or
- only disables the local durability loop while the node still gets assigned durability agents that
then sit inert — which would be worse than not setting it, because those shard databases would have
no working durability agent at all.
If it does the first, that is our answer and this issue is just a documentation request. If it
does the second, we would like a supported way to express "this node takes event:// agents but not
durability ones" — a node-level role, or a capability predicate over agent URI schemes evaluated per
node. We note IAgentFamily.SupportedAgentsAsync() is already documented as "All supported agent
uris by this node instance", so the mechanism seems to exist; we just cannot find the knob.
What we ruled out
NodeAgentController.DisableAgentsAsync / WolverineRuntime.DisableAgentsAsync — documented
"STRICTLY FOR TESTING".
DisableAllWolverineMessagePersistence — disables persistence bootstrapping entirely, built for
OpenAPI generation; we still need the message store for the distribution itself.
DurabilityMode.Solo / Serverless / MediatorOnly — Balanced is what enables the leader
election and control queue the distribution needs, and the others disable persistence or start no
agents at all.
- Pointing the warming pods at a separate Wolverine main database. This is attractive for a
different reason — it gives the warming pods their own node registry, leader and control queue, so
warming pods joining and leaving no longer trigger a full rebalance of the serving cluster's ~5 650
agents. But it does not answer this question: both clusters are built over the same sharded Marten
store, so both would assign durability agents against the same 512 per-shard
wolverine_incoming_envelopes tables. That looks like it turns one problem into two coordinators
competing over the same durability targets. If that reading is wrong we would be glad to hear it.
Why this might be generally useful
Any system with a large read-model rebuild has this shape: you want nodes that build projections
without taking on message handling, then hand the caught-up state to the serving fleet. It is also a
different scaling axis — sometimes you want more projection capacity without more message-handling
capacity.
Environment
- Wolverine 6.24.2, Marten 9.22.0, JasperFx 2.36.3, .NET 10
UseWolverineManagedEventSubscriptionDistribution = true, DurabilityMode.Balanced
- Marten sharded tenancy: 512 databases,
Events.TenancyStyle = Conjoined,
Events.UseTenantPartitionedEvents = true
- Control queue on the database (AWS SQS provides no per-node control queue)
Context
We run Wolverine-managed event subscription distribution
(
UseWolverineManagedEventSubscriptionDistribution = true) over a Marten store with 512 sharddatabases, ~854 tenants and
Events.UseTenantPartitionedEvents = true. That works out to roughly:wolverine_incoming_envelopes),for about 5 650 distributed agents. Every API pod is a Wolverine node and carries a share of them,
assigned with database affinity. Background from our side that you have already helped with:
#3698,
#3701,
#3733.
What we are trying to do
Marten writes each projection version to its own tables. When we bump
projection.Version, the newversion's tables start empty and the daemon builds them from event 0, per tenant, per shard. At our
size that takes hours, and the code that queries them has no knowledge of the previous version's
tables — so the moment a pod running the new version serves traffic, it serves empty read models.
So we want to run a separate set of pods on the new image, off the load balancer, let the daemon
build the new projection version there, and only roll the serving fleet once those read models have
caught up. The old pods keep serving the old version's tables throughout, which works naturally
because the projection version is part of the agent identity
(
provided-cares:V23:All:{tenant}) — old and new pods advertise disjoint projection agents.Those warming pods must be full Wolverine nodes; that is the only way the leader assigns them the new
version's projection agents. But being a node also brings message handling, which is exactly what they
must not do while their read models are still cold:
IEndpointCollection.StartListenerAsync / StopListenerAsyncGateSideEffectsBehindPriorVersionDurability agents are assigned per shard database and are version-independent, so the leader hands
them to warming pods too. They recover and execute persisted incoming envelopes, which means a warming
pod runs real handlers against half-built read models. A durability agent is not a listener, so
StopListenerAsyncdoes nothing about it.The actual question
DurabilitySettings.DurabilityAgentEnabledlooks like it might be exactly the lever, but its doccomment only says "Should the message durability agent be enabled during execution. The default is
true." We cannot tell from that whether, under Wolverine-managed distribution, it also:
then sit inert — which would be worse than not setting it, because those shard databases would have
no working durability agent at all.
If it does the first, that is our answer and this issue is just a documentation request. If it
does the second, we would like a supported way to express "this node takes
event://agents but notdurability ones" — a node-level role, or a capability predicate over agent URI schemes evaluated per
node. We note
IAgentFamily.SupportedAgentsAsync()is already documented as "All supported agenturis by this node instance", so the mechanism seems to exist; we just cannot find the knob.
What we ruled out
NodeAgentController.DisableAgentsAsync/WolverineRuntime.DisableAgentsAsync— documented"STRICTLY FOR TESTING".
DisableAllWolverineMessagePersistence— disables persistence bootstrapping entirely, built forOpenAPI generation; we still need the message store for the distribution itself.
DurabilityMode.Solo/Serverless/MediatorOnly—Balancedis what enables the leaderelection and control queue the distribution needs, and the others disable persistence or start no
agents at all.
different reason — it gives the warming pods their own node registry, leader and control queue, so
warming pods joining and leaving no longer trigger a full rebalance of the serving cluster's ~5 650
agents. But it does not answer this question: both clusters are built over the same sharded Marten
store, so both would assign durability agents against the same 512 per-shard
wolverine_incoming_envelopestables. That looks like it turns one problem into two coordinatorscompeting over the same durability targets. If that reading is wrong we would be glad to hear it.
Why this might be generally useful
Any system with a large read-model rebuild has this shape: you want nodes that build projections
without taking on message handling, then hand the caught-up state to the serving fleet. It is also a
different scaling axis — sometimes you want more projection capacity without more message-handling
capacity.
Environment
UseWolverineManagedEventSubscriptionDistribution = true,DurabilityMode.BalancedEvents.TenancyStyle = Conjoined,Events.UseTenantPartitionedEvents = true