Problem
DurabilityAgent's recovery timer needs the active node numbers to spot messages orphaned by a departed node:
var nodes = await _runtime.Storage.Nodes.LoadAllNodesAsync(_runtime.Cancellation);
activeNodeNumbers = nodes.Select(n => n.AssignedNodeNumber).ToList();
That is a per-node fact, but LoadAllNodesAsync also selects the entire assignment table so it can populate WolverineNode.ActiveAgents, which this caller never reads:
// PostgresqlNodePersistence.LoadAllNodesAsync
$"select {NodeColumns} from {_nodeTable};select {Id}, {NodeId}, {Started} from {_assignmentTable};"
And there is one durability agent per message database. So on a sharded deployment the query count scales with the database count, and the row count scales with the agent count.
Measured
A production cluster with 512 shard databases, five nodes, ~10,000 assigned agents, default ScheduledJobPollingTime of five seconds. All figures from the main message store:
|
|
| calls of the assignments select |
76 / second |
| rows returned from that one table |
772,000 / second |
| calls since the stats were reset (128 days) |
245,000,000 |
Client:ClientWrite |
164 of 170 average active sessions |
Lock:transactionid |
0.04 AAS |
pg_stat_statements shows the node select and the assignment select with identical call counts, which is the two-statement command above. So this is volume, not contention: the server spends its time writing those result sets back to the clients.
It also has a second-order effect. Each of those calls holds a pooled connection for its duration, which empties the pool (~4,000 pool has been exhausted an hour at a cap of 50). The heartbeat writes then time out at the 15s Npgsql timeout, the leader declares healthy nodes stale, and reassigning their agents churns the very table being read 76 times a second — 3,842 node records in ten minutes during one such episode.
Note that SqlServerNodePersistence already filters the equivalent read with where node_id = @id; the unfiltered pair is on the "load everything" path that this caller happens to use.
Suggested fix
The value is identical for every database on the node, so fetch it once per node per polling interval instead of once per database — the same reasoning as PersistenceMetricsSweeper for the metrics polling in GH-3375, whose doc comment describes this exact failure mode ("at high database counts the in-phase per-agent pollers each pinned a pooled connection").
The caller already tolerates data one interval old, since that is how often it looks, so behaviour is unchanged and the query count drops by the database count.
PR follows.
Problem
DurabilityAgent's recovery timer needs the active node numbers to spot messages orphaned by a departed node:That is a per-node fact, but
LoadAllNodesAsyncalso selects the entire assignment table so it can populateWolverineNode.ActiveAgents, which this caller never reads:And there is one durability agent per message database. So on a sharded deployment the query count scales with the database count, and the row count scales with the agent count.
Measured
A production cluster with 512 shard databases, five nodes, ~10,000 assigned agents, default
ScheduledJobPollingTimeof five seconds. All figures from the main message store:Client:ClientWriteLock:transactionidpg_stat_statementsshows the node select and the assignment select with identical call counts, which is the two-statement command above. So this is volume, not contention: the server spends its time writing those result sets back to the clients.It also has a second-order effect. Each of those calls holds a pooled connection for its duration, which empties the pool (~4,000
pool has been exhaustedan hour at a cap of 50). The heartbeat writes then time out at the 15s Npgsql timeout, the leader declares healthy nodes stale, and reassigning their agents churns the very table being read 76 times a second — 3,842 node records in ten minutes during one such episode.Note that
SqlServerNodePersistencealready filters the equivalent read withwhere node_id = @id; the unfiltered pair is on the "load everything" path that this caller happens to use.Suggested fix
The value is identical for every database on the node, so fetch it once per node per polling interval instead of once per database — the same reasoning as
PersistenceMetricsSweeperfor the metrics polling in GH-3375, whose doc comment describes this exact failure mode ("at high database counts the in-phase per-agent pollers each pinned a pooled connection").The caller already tolerates data one interval old, since that is how often it looks, so behaviour is unchanged and the query count drops by the database count.
PR follows.