Skip to content

Database affinity is per agent family, not per database: 73% of shard databases have durability and projections on different nodes #3785

Description

@erdtsieck

Rewritten 2026-08-03 after measuring this on production for a full hour. The original report claimed that
nodes joining a running cluster get only the durability family and never take projection work. That is
wrong and I am retracting it — see "Retracted: the imbalance does converge" below. What survives, and what
this issue is now about, is that database affinity is applied per agent family rather than per database, so
two nodes end up connecting to the same shard database. That part reproduced on production and held steady
over an hour.

Agent assignment honours database affinity within the wolverinedb:// durability family and within the
event-subscriptions:// family, but not between them. The result is that for most shard databases one node
owns the durability agent while a different node owns that database's projection agents.

What we measured

Production, 5 pods, one Marten store over 512 tenant shard databases with Events.UseTenantPartitionedEvents,
~8,700 agents (6 async projections × ~1,385 tenants, plus ~460 durability agents). Measured on a converged
cluster, then again every five minutes for an hour:

databases
durability agent on the same node as that database's projections 71
on a different node 375
only one family assigned 65

That is 73% split, and it did not move over the hour (it was 397/81 an hour earlier, so it drifts a little with
rebalancing but the ratio holds). The pool count derived from it stayed flat between 902 and 945.

What it costs, measured rather than estimated

Npgsql keeps one connection pool per (connection string, database) per process, so a split database means two
pools against one shard instead of one. Joining pg_stat_activity on the shard server to the assignment table:

connections held by count
the projection owner (the node doing the work) 1,483
the durability owner 434
nodes owning neither family (ordinary API traffic for that tenant) 1,025

The split costs 425 connections — the connections a durability owner holds against databases it would
otherwise never open. That is 14% of the total, not a doubling: a durability pool holds ~1.1 connections on
average, against 3 for a busy projection pool. I want to be precise about that because "two pools instead of
one" sounds like twice the connections and it is not.

14% still mattered. Earlier the same day this server sat at 2,393 of its 2,400 max_connections for half an
hour during a projection version bump, throwing 7,500+ 53300 remaining connection slots are reserved… in
fifteen minutes, briefly taking two of five pods out of the load balancer, and dropping assigned agents from
8,008 to 5,554 because an agent that lost its slot could not get a connection to restart. 425 connections was
almost exactly the margin we did not have. (We have since raised the limit to 3,600, so this is no longer
urgent for us — but the waste is structural and the next 512-shard cluster will meet the same wall.)

Worth adding for calibration: only 140 of ~3,000 connections are active at any instant. This is not a
concurrency problem, it is a slot-scarcity problem — which is why halving the pool count is the useful lever
and lowering the per-pool cap is not (that would throttle exactly the 660 pools that are doing the replay).

What we would expect

That a shard database's durability agent is assigned to whichever node owns that database's projection agents,
so one node holds one pool against it. Concretely: make affinity a property of the database across families
rather than a property of each family independently.

Retracted: the imbalance does converge

The original version of this issue said the new nodes stayed at ~107 agents thirty-five minutes after a
scale-out and that the split "had not moved". Measured over a longer window on production, that is a transient
of the catch-up, not a steady state. The last pod to join a rolling restart:

07:37       6 agents
08:12     167
08:39     936
08:40   1,230
08:55   1,893

Eighty minutes to go from 6 to above its fair share. Over the following hour the lowest node stayed between 88%
and 96% of an even share, and the final distribution over 8,716 agents was:

node agents % of even share databases
507 1,665 96% 179
508 1,732 99% 181
509 1,787 103% 154
510 1,749 100% 188
511 (last to join) 1,783 102% 200

The node that started with six agents ended up holding the most databases of the five. So placement is not
starving new nodes; it is slow to confirm them.

The slowness has a known cause and it is not in Wolverine: while a bumped projection's shards are warming up,
the replay runs inside the agent start path, so an agent only counts as assigned once its replay finishes
(JasperFx/jasperfx#610). During that window the assignment table advances in bursts and looks exactly like a
stuck cluster. That is what I mistook for permanent starvation.

The one observation from the original report I still find odd, offered without a conclusion: over a six-minute
window during the ramp we counted ~45,000 ReassignAgent decisions in the AssignmentChanged descriptions,
essentially all aimed at the nodes that already held everything and about twenty at each new node. Given that
the outcome converges, this may simply be the leader re-deciding against a fleet whose starts have not
confirmed yet — in which case it is a symptom of #610 too, and only worth looking at if it is cheap to make
the evaluation aware of pending starts.

Environment

Wolverine 6.24.4 / Marten 9.22.2 / JasperFx 2.37.2, .NET 10, 5 pods, DurabilityMode.Balanced with
UseWolverineManagedEventSubscriptionDistribution, database control queue, one Marten store over 512 tenant
databases with Events.UseTenantPartitionedEvents. Production, and a canary on a restored copy of it, so we
can reproduce at full scale — happy to run whatever measurement would be useful.

Related: JasperFx/jasperfx#610 (warm-up inside the start path), #3753 (the outcome-level
report).

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