Skip to content

perf: authed data-API throughput is pinned to the knex default pool (~10/replica) with no OS_* knob — a 3-replica cluster saturates at ~25 rps while Postgres sits at ~21/200 connections #14176

Description

@baozhoutao

Summary

On a multi-replica deployment the authed, DB-touching data API saturates at a low, replica-count-bound throughput because the SQL driver never sets a connection pool size and no OS_* env exposes one for the primary datasource — so every replica runs at knex's default pool (max: 10), and the pool, not the database, is the ceiling.

Measured (live 3-replica EE cluster, Traefik LB, Postgres 16 max_connections=200)

Load ramp, 8s/step, throughput = completed req/s across the whole cluster:

path conc=10 conc=50 conc=100 conc=150
/api/v1/health (no DB) 1031 rps 1051 rps (1376 @400)
GET /data/note (authed, org-scoped) 19 rps, p50 513ms 24 rps, p50 1988ms 25 rps, p50 3738ms 77.8% 503, p99 12.8s
POST /data/note 21 rps 23 rps 25 rps (@80)
  • Authed data throughput plateaus at ~25 rps and latency climbs linearly with concurrency (closed-system saturation: throughput flat ⇒ latency = concurrency ÷ throughput). The static front door does ~1,000–1,400 rps, so Traefik/Node/HTTP is not the constraint.
  • A 45s sustained run at conc=50 held 27 rps / 0 errors / p50 1746ms with no drift — stable, just capped.
  • Throughout, a 5s sampler showed Postgres holding ~9–21 of its 200 connections; at steady state the app accounts for ~9 total across 3 replicas. The DB has ~10× unused headroom. The bottleneck is the client pool, not the server.

Root cause

SqlDriver.withConnectBound (packages/drivers/driver-sql/src/sql-driver.ts:4468) injects only pool.createTimeoutMillis; it sets no pool.min / pool.max, so knex's default (min 2, max 10) applies per driver instance. A repo-wide grep finds no env read for pool sizing (OS_DB_POOL* / POOL_MAX / process.env.*POOL → 0 hits). A per-datasource pool field does exist and pg honors it (#5714), but that is a datasource-config knob — there is no operator-facing env to size the primary datasource's pool (the one behind OS_DATABASE_URL) on a deployment.

Net: 3 replicas × ~3–10 pooled connections ÷ ~350ms per org-scoped query ≈ the ~25 rps observed. Adding replicas raises the ceiling linearly; raising the per-replica pool would too, but the operator has no supported way to do the latter.

Recommendation — expose a pool knob

Add an OS_DB_POOL_MAX (and OS_DB_POOL_MIN) env, read where the primary datasource's knex config is built, and thread it into knexConfig.pool alongside the existing createTimeoutMillis:

pool: { min: envInt('OS_DB_POOL_MIN', 2), max: envInt('OS_DB_POOL_MAX', 10), createTimeoutMillis: DEFAULT_CREATE_TIMEOUT_MS }

Then document it next to max_connections guidance in the self-hosting / cluster docs (the deploy compose already tells operators to tune Postgres max_connections=200 for "N replicas × pool" — but there is currently no way to set the pool half of that product). Sizing rule of thumb: replicas × OS_DB_POOL_MAX < max_connections, leaving headroom for migrations/admin.

Secondary observation (resilience, relates to #13408)

At conc≥150 the data path sheds to 503 rather than queueing; with Traefik readiness-draining, a sustained concurrency spike can drain the whole fleet (the same failure shape as the stuck-datasource readiness case, #13408). A right-sized pool plus a bounded acquire-queue would let the cluster degrade in latency rather than availability.

Found during a cluster load/stress pass; full numbers in the QA record #13404. Lineage: #5714 (datasource pool honored by pg but sqlite drops it), #3769 (pool-exhaustion symptom).

Activity

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions