Skip to content

perf(datastore): fill connection pools before startup finishes - #3339

Draft
vroldanbet wants to merge 2 commits into
mainfrom
perf/datastore-pool-prepopulation
Draft

vroldanbet wants to merge 2 commits into
mainfrom
perf/datastore-pool-prepopulation

Conversation

@vroldanbet

@vroldanbet vroldanbet commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

What

SpiceDB now waits for its Postgres and CockroachDB connection pools to fill to the configured minimum number of connections before it finishes starting up. If a pool cannot be filled, the process fails to start with an error saying which pool fell short and by how much.

Why

When Kubernetes rolls out new SpiceDB pods, it marks a pod Ready as soon as its health check passes, and the load balancer starts sending it traffic. Until now that could happen while the pod's connection pools were still empty. The pgx pool constructor does not actually connect to anything — it kicks off a background goroutine to open the connections and returns immediately — so SpiceDB could finish starting and report itself healthy with nothing in its pools.

The new tests measure this against a database container on loopback, the most favourable case there is: Postgres returned from its constructor with 4 of 20 read connections open, CockroachDB with 8 of 10. On a real network that gap scales with the round trip.

The result is that the first burst of traffic onto a brand-new pod has to open every connection it uses, one per request, at once. That is a thundering herd against the database at exactly the moment a rollout is shifting traffic onto that pod, and it shows up to users as a latency spike or as errors. Rolling pods is supposed to be routine.

The minimum connection count is a statement about how much capacity an instance needs before it should take traffic. So an instance that cannot reach it now fails to start, rather than starting cold and finding out under load.

One trade-off to be explicit about, because the failure mode is new even though the misconfiguration behind it is not: during a rolling deploy maxSurge means old and new pods coexist, so a database sized for exactly N pods cannot satisfy N+1 sets of warm pools, and the rollout will now stall on a pod that fails to start instead of on a pod that starts and then returns connection errors under load. That is an under-provisioned connection limit surfacing at deploy time rather than at peak, and the pod does not survive either way.

How

Both datastore constructors now open the minimum number of connections and hand them straight back to the pool before returning. There is no API in pgx to ask for this (LazyConnect was removed on purpose and a hook for it was declined upstream), so it is a short loop in our code, shared between the two engines.

Details worth a reviewer's attention:

  • The target is min(MinConns, MaxConns). A minimum above the maximum is a configuration SpiceDB accepts today with only a warning, and the maximum is a hard limit, so warming to the raw minimum would hang until the timeout and then refuse to start a server that was perfectly able to serve traffic.
  • Warm-up has its own 30-second budget, separate from the rest of start-up, so that "the pool could not be filled" does not depend on how long the preceding checks happened to take.
  • The read and write pools are warmed at the same time, not one after the other. This matters most for CockroachDB, which rate-limits new connections.
  • The failure message names the pool and the counts, e.g. read connection pool did not reach its configured minimum of 20 connections (established 10); lower the minimum connection count for this pool, or raise the connection limit on the database: ... FATAL: sorry, too many clients already.
  • A datastore built with no pool options now keeps one connection, not a core count's worth. ConfigurePgx used to default the minimum to the maximum unconditionally. Every path that serves real traffic sets both bounds from the --datastore-conn-pool-* flags, so production is unchanged — but a caller that passes no pool options (the test harness, and embedders using SpiceDB as a library) inherited pgx's own max(4, NumCPU) maximum as a minimum, which with this PR would mean 16 read plus 16 write connections established synchronously per datastore on a 16-vCPU machine. Those callers need a pool that works, not a pool that is full, so the default is now one connection with the rest filling on demand. This keeps the test suites off the warm-up path while still exercising it once per datastore.
  • Also fixes a data race alongside this: the Postgres credentials-provider hook wrote its result into the constructor's own error variable, and pgx runs that hook on one goroutine per connection. It was always wrong; warming the pools makes those goroutines overlap the constructor rather than run after it.

What it costs

Constructor wall time with the shipped defaults (20 read, 10 write connections). Latency injected with a TCP proxy that delays every forwarded chunk.

before after
Postgres, loopback 11.5ms 16.6ms
Postgres, 10ms RTT 52.2ms 52.6ms
Postgres, 50ms RTT 219.5ms 219.8ms
Postgres, 200ms RTT 828.6ms 821.0ms
Postgres, 16 serial read replicas, loopback 191ms 251ms
Postgres, 16 serial read replicas, 50ms RTT 3.72s 3.65s
CockroachDB, loopback 206ms 2.01s

Past loopback the Postgres cost vanishes, because the connections were already being opened in parallel in the background and the checks the constructor already runs cover the time.

CockroachDB is the outlier, and it is not connection setup: it rate-limits new connections to one per --datastore-connect-rate (100ms by default), so twenty connections take 1.9 seconds whoever opens them. That time was always being spent — previously after start-up, slowing down the traffic it overlapped. Warming the two pools concurrently is what keeps this at 2.0s instead of 3.0s.

The test suites pay almost none of this, which matters because the CockroachDB suite is the CI critical path. Constructing a CockroachDB datastore through the test harness, on a 12-core machine (medians of 5 runs):

median
before this PR 261ms
with warm-up, minimum defaulted to the maximum 1.44s
with warm-up, minimum defaulted to one (shipped) 431ms

End to end, go test -tags datastore ./internal/datastore/crdb/... on the same machine took 942s with the minimum defaulted to the maximum and 747s as shipped — back in line with the 758s that #3337 brought the CockroachDB job down to, so this does not give back that win.

Not changed here

MySQL and Spanner have the same shape of problem and are left alone in this PR. MySQL uses database/sql, which has no minimum-connections concept at all (the --datastore-conn-pool-*-min-open flag is silently ignored for it) and opens connections purely on demand; only the one connection its start-up queries use is warm. Spanner's client library has removed its session pool entirely in favour of a single multiplexed session, created on a background goroutine the constructor does not wait for — but its readiness check runs a real query, which forces that session to exist, and it is one session rather than N connections.

References

@github-actions github-actions Bot added area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) labels Sep 21, 2026
@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.02128% with 31 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/datastore/postgres/postgres.go 40.63% 18 Missing and 1 partial ⚠️
internal/datastore/crdb/crdb.go 20.00% 11 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@vroldanbet
vroldanbet force-pushed the perf/datastore-pool-prepopulation branch from 3f66c1a to 5f7f944 Compare September 21, 2026 20:07
@vroldanbet
vroldanbet force-pushed the perf/parallel-consistency-fixtures branch from cc5df29 to c8820fe Compare September 21, 2026 20:11
@vroldanbet
vroldanbet force-pushed the perf/datastore-pool-prepopulation branch from 5f7f944 to ffd1af1 Compare September 21, 2026 20:30
@vroldanbet
vroldanbet force-pushed the perf/parallel-consistency-fixtures branch 3 times, most recently from c6edd0d to 97d73a3 Compare September 22, 2026 05:49
Base automatically changed from perf/parallel-consistency-fixtures to main September 22, 2026 06:04
@vroldanbet
vroldanbet force-pushed the perf/datastore-pool-prepopulation branch from ffd1af1 to 242cdd3 Compare September 22, 2026 08:24
@vroldanbet
vroldanbet force-pushed the perf/datastore-pool-prepopulation branch 2 times, most recently from ec17b8a to 8fe51ed Compare September 22, 2026 10:18

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 2.

Benchmark suite Current: 8fe51ed Previous: b4a0719 Ratio
BenchmarkSerializeUnion/subs=128 (github.com/authzed/spicedb/pkg/query) - MB/s 156.26 MB/s 38.98 MB/s 4.01
BenchmarkDeserializeRealistic (github.com/authzed/spicedb/pkg/query) - MB/s 83.74 MB/s 21.98 MB/s 3.81

This comment was automatically generated by workflow using github-action-benchmark.

pgxpool.NewWithConfig does not connect to anything. Its last statement starts a
goroutine that opens MinConns connections, and it then returns to the caller.
Both the Postgres and the CockroachDB datastore constructors inherited that, so
SpiceDB finished starting, passed its health check, and was marked Ready by
Kubernetes with pools that were still filling. The new tests measure it against
a container on loopback, which is the most favourable case there is: Postgres
returned with 4 of 20 read connections open, CockroachDB with 8 of 10. On a real
network that gap scales with the round trip.

The first burst of traffic onto a freshly rolled pod then has to open every
connection it uses, inline, which is a thundering herd against the database at
exactly the moment a rollout is shifting traffic onto that pod.

Both constructors now acquire min(MinConns, MaxConns) connections and release
them before returning. pgx has no synchronous warm-up API for this: LazyConnect
was removed deliberately and an AfterInit hook was declined in jackc/pgx#1660,
with the maintainer recommending exactly this loop in user code.

The clamp to MaxConns is required, not defensive: ConfigurePgx permits MinConns
above MaxConns and only logs a warning, and MaxConns is the hard size limit of
the underlying resource pool, so warming to the raw minimum would block until
the deadline and then refuse to start a server that could serve fine.

Not reaching the minimum is now a startup failure. The minimum is a statement
about how much capacity an instance needs before it should take traffic, so an
instance that cannot get there should not report itself ready. The error names
the pool and how many connections it managed, so an operator can tell whether to
lower the minimum or raise the database's connection limit:

  read connection pool did not reach its configured minimum of 20 connections
  (established 10); lower the minimum connection count for this pool, or raise
  the connection limit on the database: failed to connect to
  `user=postgres database=postgres`: ... FATAL: sorry, too many clients already

Warm-up gets its own 30s budget rather than sharing the datastore's other
start-up deadline, so that "the pool could not be filled" does not depend on how
long the preceding verification queries happened to take.

Measured cost, constructor wall time with the shipped defaults of 20 read and 10
write connections, 10 runs on loopback and 5 per latency step through a TCP
proxy that delays every forwarded chunk:

  Postgres    before                after
  loopback    median 11.5ms         median 16.6ms
  10ms RTT    median 52.2ms         median 52.6ms
  50ms RTT    median 219.5ms        median 219.8ms
  200ms RTT   median 828.6ms        median 821.0ms

Past loopback the cost disappears: the background goroutine is opening the
connections in parallel anyway, and the verification query the constructor
already runs covers the time. Sixteen read replicas, constructed serially, went
from 191ms to 251ms on loopback and were unchanged at 50ms RTT (3.72s to 3.65s).

CockroachDB is the exception, at 206ms to 2.01s on loopback. That is its
connect-rate limiter: AfterConnect waits on one connection per
--datastore-connect-rate, 100ms by default, so twenty connections cost 1.9s no
matter who opens them. This change moves that time from after start-up, where it
was slowing down the traffic it overlapped, to before it. Each pool has its own
limiter, so the read and write pools are warmed concurrently rather than one
after the other, which is the difference between 2.01s and 3.02s.

Also fixes a data race next door: the Postgres credentials-provider hook
assigned the token to the constructor's own err variable, and pgx calls
BeforeConnect from one goroutine per connection. That was always wrong, but
warming the pools means those goroutines now run while the constructor is still
using that variable.

Claude-Session: https://claude.ai/code/session_017DF2mdm5e2RGbjPWtmYetd
… count

ConfigurePgx defaulted the pool minimum to the pool maximum unconditionally.
Every path that serves real traffic sets both bounds -- the Postgres and
CockroachDB engine builders pass ReadConnsMinOpen/ReadConnsMaxOpen and the
write and replica equivalents from the --datastore-conn-pool-* flags on every
construction -- so for a running SpiceDB that default was never reached and
production behaviour is unchanged.

It was reached by callers that build a datastore directly with no pool options
at all: the test harness, and embedders using SpiceDB as a library. For them,
"maxed out" meant inheriting pgx's own MaxConns default, max(4, NumCPU), as a
*minimum*. That was free while the pool filled in the background. It stopped
being free in the previous commit, which establishes the minimum before the
constructor returns: on a 16-vCPU runner every datastore built by a test now
wanted 16 read plus 16 write connections first, and CockroachDB rate-limits new
connections to one per 100ms, so each construction paid about 1.5s.

The CockroachDB suite builds a datastore per subtest across 111+ subtests and is
the CI critical path, so that lands directly on the result #3337 just achieved
by parallelising those subtests. Measured on a 12-core machine, constructing a
CockroachDB datastore through the test harness (medians of 5):

  before the warm-up change              261ms
  warm-up, minimum defaulted to maximum  1.44s
  warm-up, minimum defaulted to one       431ms

and end to end, go test -tags datastore ./internal/datastore/crdb/... went from
942s to 747s, in line with the 758s #3337 brought the job down to.

A caller that configures no bounds wants a pool that works, not a pool that is
full, so the minimum for that case is now one connection and the rest fill on
demand. One is deliberate rather than zero: it keeps the warm-up path exercised
by every datastore the suites build, instead of only by the two tests written
for it.

Claude-Session: https://claude.ai/code/session_017DF2mdm5e2RGbjPWtmYetd
@vroldanbet
vroldanbet force-pushed the perf/datastore-pool-prepopulation branch from 8fe51ed to 3ead613 Compare September 22, 2026 14:13

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant