Skip to content

PMM-15050 Hi-load performance improvements - #5338

Merged
ademidoff merged 33 commits into
mainfrom
PMM-15050-perf-improvements
Jul 14, 2026
Merged

PMM-15050 Hi-load performance improvements#5338
ademidoff merged 33 commits into
mainfrom
PMM-15050-perf-improvements

Conversation

@ademidoff

@ademidoff ademidoff commented May 8, 2026

Copy link
Copy Markdown
Member

PMM-15050

SUBMODULES-4350

Summary

When PMM Server is restarted (for example during a version migration), all PMM Clients lose their long-lived gRPC Connect streams and reconnect at roughly the same instant. With the previous defaults a fleet of 800 agents would issue 800 simultaneous Grafana lookups, exhaust Grafana's request capacity, fan out into hundreds of Postgres backends, and trip Postgres' max_connections — turning the migration into a reconnect storm and a "too many clients" livelock. On top of that, each agent's vmagent holds up to 1 GiB of buffered metrics that flush to VictoriaMetrics in parallel as soon as the connection is re-established, which can swamp VM and re-feed the same loop.

This PR addresses the storm across several layers.

pmm-managed auth cache

  • Longer auth cache TTL (3 s → 60 s) in AuthServer. Once an agent's credentials have been validated, the next reconnect within a minute is a free cache hit instead of another Grafana round-trip. Entries are now also age-checked on read, so a hit never serves credentials older than the TTL — previously a stale entry could survive until the next background sweep (up to ~2× the interval).
  • Longer auth timeout (3 s → 15 s). A temporarily slow Grafana no longer immediately translates to mass 401s in nginx auth_request.
  • Larger HTTP transport pool for the Grafana client: MaxIdleConns 50 → 100 with explicit MaxIdleConnsPerHost 100 (Go default is 2). pmm-managed only ever talks to a single Grafana host, so the per-host cap is the real bound and both are set to the same value — aligned with Grafana's max_open_conn = 100, the ceiling on auth lookups it can process in parallel. Reconnect bursts no longer force a fresh TCP connection for almost every request.

Postgres connection pressure

  • Bound Grafana's Postgres pool in grafana.ini: max_open_conn = 100, max_idle_conn = 25. Grafana 11's default for max_open_conn is 0 (unlimited), so a synchronized burst of token validations can ask Grafana to open hundreds of Postgres backends and exhaust max_connections.
  • Raise pmm-managed's Postgres pool from 5 idle / 10 open to 20 idle / 50 open. The previous limit caused DB-bound auth paths to queue at 10 during the burst.

VictoriaMetrics ingestion

  • Make VictoriaMetrics' ingestion rate limit configurable through the VM_maxIngestionRate env var (following the existing VM_search_* / VM_promscrape_* pattern), wired to VM's --maxIngestionRate flag. The default is 0 — VM's native "no limit" — so out-of-the-box ingestion behavior is unchanged. A hard default cap was deliberately avoided: --maxIngestionRate pauses ingestion when the limit is exceeded rather than shedding load, so a fixed cap would throttle legitimate ingestion on large fleets — penalizing exactly the big deployments this work targets. Operators who need to smooth a reconnect burst can opt in by setting VM_maxIngestionRate to a value suited to their fleet; vmagent tolerates throttling natively by holding data in its 1 GiB disk queue and retrying.
  • Note: this means the PR does not ship an on-by-default mitigation for the VM-swamping scenario described above — it exposes the knob for deployments that need it. An appropriately-sized default (or a soft, load-shedding limit) can be a follow-up.

pmm-agent reconnect

  • Wider reconnect backoff jitter (±25 %±50 %) in the shared agent/utils/backoff package. Spreads the first retry of 800 agents across a 1 s window instead of 0.5 s. The cap (backoffMaxDelay) stays at 15 s so an agent in repeated failure does not hold up to a minute of metrics in its local buffer.

Drive-bys

  • Replaces deprecated errors.Cause with errors.As in auth_server.go.

Notes

  • The delayJitter constant lives in the shared agent/utils/backoff package, so wider jitter also applies to the slowlog reader retry and the exporter restart loop. Both are local to a single agent process; wider jitter is strictly more decorrelating there and has no downside.
  • An earlier revision of this PR added singleflight around the cache miss. It was removed: each PMM Client authenticates with its own per-node service token, so cache keys are unique per agent and singleflight provides essentially no deduplication for the migration scenario.
  • The Grafana PostgreSQL datasource (used by dashboards) is a separate connection pool not bounded by this PR. It's typically a steady-state load, not a reconnect-burst contributor; can be revisited if dashboards are seen contributing.
  • No UI surface is affected.

When PMM Server restarts (e.g. during a version migration), all agents
lose their long-lived gRPC Connect streams and reconnect at roughly the
same time. Each reconnect goes through nginx auth_request -> pmm-managed
AuthServer, which on cache miss calls Grafana /api/auth/serviceaccount.
With the previous defaults a fleet of 800 agents would issue 800
simultaneous Grafana lookups, exhaust Grafana, and time out at the
3-second auth deadline.

This change reduces the load on Grafana along three independent axes:

- Singleflight around the cache miss in retrieveRole: concurrent calls
  for the same hashed credentials now collapse into a single Grafana
  request; followers wait for the leader and pick up the cached entry.

- Longer auth cache TTL (3s -> 60s) and longer auth timeout (3s -> 15s),
  so a fresh agent's role no longer needs to be re-fetched every few
  seconds and a temporarily slow Grafana does not immediately translate
  to mass 401s.

- Larger HTTP transport pool for the Grafana client (MaxIdleConns
  50 -> 200, explicit MaxIdleConnsPerHost 100 instead of the Go default
  of 2), so reconnect bursts no longer force a fresh TCP/TLS handshake
  for almost every request.

Drive-by: replaces interface{} with any in the Grafana client to clear
pre-existing lint warnings in the files touched here.
@ademidoff
ademidoff requested a review from a team as a code owner May 8, 2026 05:48
@ademidoff
ademidoff requested review from 4nte and maxkondr and removed request for a team May 8, 2026 05:48
@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.34247% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.16%. Comparing base (31318c7) to head (3dc388f).
⚠️ Report is 27 commits behind head on main.

Files with missing lines Patch % Lines
managed/services/management/node.go 75.32% 13 Missing and 6 partials ⚠️
managed/services/management/service.go 72.58% 13 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5338      +/-   ##
==========================================
+ Coverage   43.59%   44.16%   +0.56%     
==========================================
  Files         415      416       +1     
  Lines       43134    43185      +51     
==========================================
+ Hits        18804    19071     +267     
+ Misses      22454    22231     -223     
- Partials     1876     1883       +7     
Flag Coverage Δ
admin 34.78% <ø> (ø)
agent 50.14% <ø> (+1.09%) ⬆️
managed 43.41% <75.34%> (+0.42%) ⬆️
vmproxy 72.22% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

ademidoff and others added 4 commits May 8, 2026 09:01
errors.Cause from github.com/pkg/errors does not walk standard Unwrap
chains. errors.As is the supported replacement, also used elsewhere in
this file, and removes the need for the //nolint:errorlint suppression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each PMM Client authenticates with its own per-node service token, so
the cache key (a hash of the Authorization/Cookie headers) is unique per
client. Singleflight only coalesces concurrent calls sharing the same
key, so for the migration scenario that motivated this PR — hundreds of
clients reconnecting at the same moment — it provided essentially no
deduplication. The longer cache TTL and bigger HTTP transport pool do
the actual load-shedding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When PMM Server restarts, all clients lose their gRPC streams at the
same instant and start reconnecting against a small randomized window.
With min=1s / max=15s and ±25% jitter, the first retry of a fleet of
800 agents was concentrated in roughly a 0.5-second window — a worst
case of ~1600 reconnects/sec hitting auth_request and Grafana.

Doubling the jitter (±25% -> ±50%) and raising the cap from 15s to 60s
spreads later retries over a wider interval, so a server outage longer
than 15s no longer keeps every agent hammering at the cap simultaneously.

The jitter constant lives in the shared backoff package, so the change
also applies to slowlog and process restart loops; wider jitter is
strictly more decorrelating and has no downside for those local loops.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverting the cap bump from the previous commit. With a 60s cap, an
agent in repeated failure can hold up to a minute of metrics in its
local buffer between reconnect attempts, which is a worse trade-off
than the server-side benefit of spreading retries further apart.

The wider jitter (±50%) introduced in the previous commit is kept;
it still meaningfully spreads later retries within the 15s cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ademidoff ademidoff changed the title PMM-15050 Reduce Grafana auth load when fleets of agents reconnect PMM-15050 Hi-load performance improvements May 10, 2026
ademidoff and others added 4 commits May 10, 2026 12:28
Grafana 11 defaults max_open_conn to 0 (unlimited), so a burst of
concurrent auth lookups - e.g. a fleet of agents reconnecting at the
same time - can ask Grafana to open hundreds of fresh Postgres backends
in parallel and saturate Postgres' max_connections, returning
"too many clients" and tipping the reconnect loop into a livelock.

Cap the pool at 100 open / 25 idle. Token validation is a fast query,
so 100 concurrent backends is plenty even for thousands of agents while
staying well within PMM Server's max_connections=2000 ceiling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The pool was hard-coded to 5 idle / 10 open. Under a reconnect storm
from a fleet of agents, every DB-bound auth path (LBAC role lookup,
settings read, agent-state write) queues at 10, and that queue sits on
the same Postgres backends that Grafana's auth flow is competing for.

Bump to 20 idle / 50 open. Stays well within PMM Server's
max_connections=2000 and gives auth paths enough headroom to drain the
burst instead of stalling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
14400s (4h) is already Grafana's default for conn_max_lifetime, so
setting it explicitly is just clutter. The values that actually change
behaviour are max_open_conn and max_idle_conn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ademidoff
ademidoff marked this pull request as draft May 10, 2026 10:17
ademidoff and others added 2 commits May 31, 2026 13:23
A fleet of agents reconnecting after a server outage flushes their
1 GiB on-disk buffers to VictoriaMetrics in parallel, with no rate
limit anywhere in the path. VM can be overwhelmed, return 503s, and
feed the same reconnect loop that the auth-side fixes in this PR
already address.

Add --maxIngestionRate to vmsingle's command line with a default of
3M samples/sec (~3x steady state of an 800-agent fleet, well within
PMM Server sizing). vmagent tolerates throttling natively by holding
data in its 1 GiB disk queue and retrying, so a brief burst is spread
over time instead of crashing VM.

Tunable via the VM_maxIngestionRate environment variable, following
the same pattern as the existing VM_search_* / VM_promscrape_*
overrides plumbed through marshalConfig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ademidoff
ademidoff marked this pull request as ready for review June 8, 2026 11:19
db.SetMaxOpenConns(10) //nolint:mnd
// Sized to give DB-bound auth/role/settings paths enough headroom during
// a reconnect storm from a fleet of agents, while staying well within
// Postgres max_connections (set to 2000 by PMM Server).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment assumes Postgres max_connections=2000, which is only true for bundled Postgres. HA/external Postgres deployments may have much lower limits, and with Grafana 100 + pmm-managed 50 per node we can exceed them. Should we point this out in our docs?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! Yes, I think we need to make sure our Kubernetes distros configure the same parameter by default, then we can skip mentioning it explicitly in the docs. Otherwise we need to mention.

Comment thread managed/services/grafana/auth_server.go

@maxkondr maxkondr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general: this solution is very questionable. On one hand it increases the number of allowed incoming PG connections. On the other hand it increases the load on PG itself because there will be more requests from pmm-managed that shifts the initial problem described in ticket from pmm-managed+Grafana to Grafana+PG.

Here is the list of issues:

  1. auth cache in grafana/auth_server.go doesn't hold negative responses from Grafana -> wrong credentials or DDOS-attack will always hit Grafana.

1.1. There is one common lock (mutex) on cache that creates contention lock on high load.
Good solution here would be using sharded cache with local shard's lock instead.

  1. there is no protection on pmm-managed side agains "Thundering herd" problem during massive agents registration and receiving massive incoming API requests - it just tries to swallow everything it receives (by increasing the PG connections and Grafana client connections limits) and shifts the problem to backing services (Grafana, PG).

Resilient systems are built using the following approaches.
On pmm-managed side:

  • introduce rate-limiter + circuit breaker + re-tries backoff with increasing jitter in Grafana client. Each retry to Grafana has a local timeout (let's say 5s) and general time for processing request (let's say 15s).
  • the same as above in managed/services/agents/registry.go:register func.
  • cache positive and negative responses from backing services
  • during pmm-agent registration pmm-managed may calculate some jitter/coefficient that client will use later for during re-registration (and other logics like sending QAN packets). This jitter may be based on number of pmm-agents registered in DB.

On pmm-agent side the same approaches:

  • for connection attempts - implement circuit breaker + re-tries backoff increasing with jitter (some sort of that is already implemented)

Regarding DB migration issue on pmm-managed side.
DB migration is part of pmm-managed component startup logic. It shall not accept incoming connections/requests from agents/API until the migration is finished. the finished migration procedure shall be included into "healthy condition" to accept incoming requests.

Comment thread managed/services/grafana/client.go Outdated
Comment thread managed/services/grafana/client.go
Comment thread build/ansible/roles/grafana/files/grafana.ini
@ademidoff
ademidoff force-pushed the PMM-15050-perf-improvements branch from a5cd1ea to dd6e380 Compare June 11, 2026 09:24
@ademidoff
ademidoff requested a review from maxkondr June 29, 2026 06:49
@ademidoff

Copy link
Copy Markdown
Member Author

In general: this solution is very questionable. On one hand it increases the number of allowed incoming PG connections. On the other hand it increases the load on PG itself because there will be more requests from pmm-managed that shifts the initial problem described in ticket from pmm-managed+Grafana to Grafana+PG.

Here is the list of issues:

  1. auth cache in grafana/auth_server.go doesn't hold negative responses from Grafana -> wrong credentials or DDOS-attack will always hit Grafana.

1.1. There is one common lock (mutex) on cache that creates contention lock on high load. Good solution here would be using sharded cache with local shard's lock instead.

  1. there is no protection on pmm-managed side agains "Thundering herd" problem during massive agents registration and receiving massive incoming API requests - it just tries to swallow everything it receives (by increasing the PG connections and Grafana client connections limits) and shifts the problem to backing services (Grafana, PG).

Resilient systems are built using the following approaches. On pmm-managed side:

  • introduce rate-limiter + circuit breaker + re-tries backoff with increasing jitter in Grafana client. Each retry to Grafana has a local timeout (let's say 5s) and general time for processing request (let's say 15s).
  • the same as above in managed/services/agents/registry.go:register func.
  • cache positive and negative responses from backing services
  • during pmm-agent registration pmm-managed may calculate some jitter/coefficient that client will use later for during re-registration (and other logics like sending QAN packets). This jitter may be based on number of pmm-agents registered in DB.

On pmm-agent side the same approaches:

  • for connection attempts - implement circuit breaker + re-tries backoff increasing with jitter (some sort of that is already implemented)

Regarding DB migration issue on pmm-managed side. DB migration is part of pmm-managed component startup logic. It shall not accept incoming connections/requests from agents/API until the migration is finished. the finished migration procedure shall be included into "healthy condition" to accept incoming requests.

I'm not sure we can go ahead with such massive refactoring given that it's very difficult to reliably test the changes. I think we should consider the refactoring in a series of separate tickets.

ademidoff and others added 12 commits July 7, 2026 10:53
After a fleet-wide reconnect, vmagents replay their disk queues oldest-first,
so VM can have no fresh *_up samples for many minutes while data is flowing
and services are healthy. The Inventory Services page showed Unknown for
~20 minutes after a reconnect storm of 600 agents.

ListServices now falls back to the most recent sample within a 30-minute
lookback, gated on the service's pmm-agent being currently connected, so a
dead client still reports Unknown on the same timeline as before. The stale
query only runs when a supported service lacks a fresh sample.

Also fix the supported-service check that read the zero-value metric's
service type, which made the intended UNKNOWN branch unreachable and
reported UNSPECIFIED for every service without metrics.
A 30m lookback only covers short outages: after PMM Server was down for
an hour, the newest samples in VM were ~60 minutes old, last_over_time
found nothing, and services showed Unknown again until the vmagent
replay caught up.

The window must cover the outage duration plus the replay backlog, so
widen it to 24h. The connected-pmm-agent gate still keeps dead clients
at Unknown, and fresh samples always take precedence, so the wider
window only affects the lag period.

Basing the fallback on agent status instead was considered and rejected:
exporters serve *_up=0 and keep running indefinitely while their
database is unreachable (verified with mongodb_exporter, mysqld_exporter
and postgres_exporter), so agents.status=RUNNING cannot distinguish an
up database from a down one and would report Up for a down database.
The Services page recovered after the stale-status fallback, but the
Nodes page kept showing Unknown during post-outage ingestion lag:
ListNodes and GetNode derive node status from a fresh up{job=~".*_hr$"}
sample and had no fallback.

Apply the same logic as ListServices: when a node has no fresh sample,
use the most recent one within staleStatusWindow, gated on a pmm-agent
providing that node's metrics being currently connected. Dead clients
keep reporting Unknown.
@ademidoff
ademidoff merged commit b83afc6 into main Jul 14, 2026
29 checks passed
@ademidoff
ademidoff deleted the PMM-15050-perf-improvements branch July 14, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants