perf(datastore): fill connection pools before startup finishes - #3339
Draft
vroldanbet wants to merge 2 commits into
Draft
vroldanbet wants to merge 2 commits into
vroldanbet wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
vroldanbet
force-pushed
the
perf/datastore-pool-prepopulation
branch
from
September 21, 2026 20:07
3f66c1a to
5f7f944
Compare
vroldanbet
force-pushed
the
perf/parallel-consistency-fixtures
branch
from
September 21, 2026 20:11
cc5df29 to
c8820fe
Compare
vroldanbet
force-pushed
the
perf/datastore-pool-prepopulation
branch
from
September 21, 2026 20:30
5f7f944 to
ffd1af1
Compare
vroldanbet
force-pushed
the
perf/parallel-consistency-fixtures
branch
3 times, most recently
from
September 22, 2026 05:49
c6edd0d to
97d73a3
Compare
vroldanbet
force-pushed
the
perf/datastore-pool-prepopulation
branch
from
September 22, 2026 08:24
ffd1af1 to
242cdd3
Compare
vroldanbet
force-pushed
the
perf/datastore-pool-prepopulation
branch
2 times, most recently
from
September 22, 2026 10:18
ec17b8a to
8fe51ed
Compare
There was a problem hiding this comment.
⚠️ 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
force-pushed
the
perf/datastore-pool-prepopulation
branch
from
September 22, 2026 14:13
8fe51ed to
3ead613
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
maxSurgemeans 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 (
LazyConnectwas 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:
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.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.ConfigurePgxused 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 ownmax(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.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.
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):
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-openflag 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
pgxpool.NewWithConfigreturns after startinggo func() { p.createIdleResources(...) }— pgx v5.9.2,pgxpool/pool.go:334-340