Skip to content

DurabilityAgent reads the whole assignment table once per database per poll: 76 calls and 772k rows a second at 512 databases #3846

Description

@erdtsieck

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.

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