Skip to content

Login path saturates at ~550-700 connects/s per instance regardless of cores (production report) #1469

Description

@ryderhwang

Summary

In a large production deployment we've measured PgDog's client login path saturating at ~550–700 new client connections/s per instance, while the instance uses only ~0.5 of its 4 cores (workers = 4). Past that rate, TCP accepts succeed but startup/auth lags; clients hit their own connect_timeout mid-startup and PgDog logs and reports nothing (no errors, no warnings), because from its perspective the connection simply went away before login completed. Adding instances fixes it linearly; adding cores to an instance does not — which suggests a serialization point in the login path rather than CPU exhaustion.

Environment

  • pgdog v0.1.52, pooler_mode = "transaction", passthrough_auth = "enabled_plain_allow_change", no client TLS
  • 4 vCPU / workers = 4 per instance, Kubernetes (arm64), listen backlog raised via sysctls (accept-queue overflow was measured and ruled out as the main cause — ~373 overflow drops vs ~130k client-side connect timeouts during the worst event)
  • Client fleet: Django/psycopg2, ~180 app instances, a fresh connection per request (no client-side pooling), so a rolling deploy of the app fleet produces reconnect waves of ~7k connects/s fleet-wide

What we observed

  • At 8 instances (~600/s/pod capacity each), a fleet-wide reconnect wave (~7k/s) drove client-side connect p99 to a pinned 5s (their libpq connect_timeout), with tens of thousands of failures over ~30 min — while pgdog.errors stayed 0, CPU stayed ~12% of the pod, and logs were silent.
  • Scaling to 32 and then 80 instances made the same waves invisible (~175/s/pod → connect p99 ~20ms flat).
  • Separate load testing measured the per-instance wall at ~700 connects/s against a warm instance and ~550/s under sustained load, roughly independent of core count.

Reading the code, candidate suspects

(From frontend/listener.rs / frontend/client/mod.rs / backend/databases.rs on main — happy to be corrected.)

  1. With passthrough auth, every login calls databases::add(user). The password-matches path looks cheap (read + constant_time_eq), but the password-differs / first-seen path takes the global config lock, clones the entire config, calls set() and reload_from_existing() — per login. During a credential rotation with allow_change, a reconnect wave would hit this path on every login. We don't believe this is our steady-state wall (our password is stable), but it looks pathological under rotation + storm.
  2. The login exchange is a sequential multi-await conversation (extra ClearTextPassword round trip under passthrough), so logins-in-flight can pile up — but that alone shouldn't cap throughput at ~600/s on an idle CPU.
  3. Everything spawned from a single accept task via comms.tracker().spawn() — accept itself seems unlikely to be the wall at these rates.

Ask

Related: we'll file a separate issue about making login-path saturation observable (today it's invisible in both logs and metrics).

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