Files
turnstone/docs/pgbouncer.md
Patrick Buckley 81a3eaecce chore: relicense BUSL-1.1 → Apache 2.0 for 1.6.0 (#651)
* chore: relicense BUSL-1.1 -> Apache 2.0 for 1.6.0

Flips every license artifact in the tree; 1.5.x and earlier remain
BUSL-1.1 per their release-time LICENSE files. Contributor consent
record: #548 (rationale: #546).

- LICENSE: canonical Apache 2.0 text
- NOTICE: new; copyright line + pointer to THIRD-PARTY-NOTICES
- pyproject.toml: SPDX expression + explicit license-files trio
- Dockerfile: COPY the license trio (hatchling needs them at build)
- THIRD-PARTY-NOTICES: BUSL line reworded; bundled-version drift
  fixed (KaTeX 0.17.0, Mermaid 11.15.0, hls.js 1.6.16)
- README badge + License section, CONTRIBUTING inbound-license line,
  TS SDK package(+lock), example pyproject
- docs/pgbouncer.md: drop stray ':' introduced in #353

* docs: add CONTRIBUTORS.md

* chore: drop LICENSE leading blank line

The apache.org LICENSE-2.0.txt begins with a newline; the SPDX
canonical text and GitHub license templates do not. Use the
conventional form — detection is whitespace-normalized either way.
2026-06-10 21:03:16 -07:00

8.3 KiB
Raw Permalink Blame History

PgBouncer Connection Pooling

Turnstone cluster deployments share a single PostgreSQL instance across all server nodes and the console. Each process maintains a small connection pool (2 base + 3 overflow = 5 max). At scale this adds up — a 100-node cluster opens up to 500 connections, and a 1000-node cluster up to 5,000.

PostgreSQL's default max_connections is 100, and each real connection allocates ~510 MB of backend memory. PgBouncer sits between turnstone and PostgreSQL, multiplexing thousands of lightweight client connections down to a small number of real database connections.


Why PgBouncer works well with turnstone

All turnstone database operations are short-burst queries: acquire a connection, execute 13 statements, commit, release. No operation holds a connection for more than a few milliseconds. This makes transaction pooling mode ideal — PgBouncer assigns a real connection only for the duration of each transaction, then returns it to the pool.

Cluster size Client connections (max) PgBouncer server connections needed
10 nodes 50 1020
100 nodes 500 2040
500 nodes 2,500 3060
1,000 nodes 5,000 4080

The server connection count stays low because most client connections are idle at any given moment.


Docker Compose

Add PgBouncer between turnstone services and PostgreSQL:

services:
  pgbouncer:
    image: edoburu/pgbouncer:latest
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_NAME: ${POSTGRES_DB:-turnstone}
      DB_USER: ${POSTGRES_USER:-turnstone}
      DB_PASSWORD: ${POSTGRES_PASSWORD:?}
      LISTEN_PORT: "6432"
      AUTH_TYPE: ${POSTGRES_AUTH_TYPE:-scram-sha-256}
      POOL_MODE: transaction
      DEFAULT_POOL_SIZE: "40"
      MAX_CLIENT_CONN: "5000"
      MAX_DB_CONNECTIONS: "80"
      SERVER_IDLE_TIMEOUT: "300"
    ports:
      - "6432:6432"
    networks:
      - turnstone-net
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "6432"]
      interval: 5s
      timeout: 3s
      retries: 5

Then point turnstone services at PgBouncer instead of PostgreSQL directly by changing TURNSTONE_DB_URL:

# Before (direct)
TURNSTONE_DB_URL=postgresql://turnstone:secret@postgres:5432/turnstone

# After (via PgBouncer)
TURNSTONE_DB_URL=postgresql://turnstone:secret@pgbouncer:6432/turnstone

Helm / Kubernetes

Add a PgBouncer deployment or use a Helm chart like edoburu/pgbouncer.

In values.yaml, point the database at PgBouncer:

database:
  backend: postgresql
  external:
    host: pgbouncer
    port: 6432
    database: turnstone
    username: turnstone
    existingSecret: turnstone-db-secret

PgBouncer configuration:

pgbouncer:
  poolMode: transaction
  defaultPoolSize: 40
  maxClientConn: 5000
  maxDbConnections: 80

Configuration reference

PgBouncer setting Recommended Notes
pool_mode transaction Required — turnstone uses short-burst queries with no session state
default_pool_size 40 Real PostgreSQL connections per database. Start here, increase if you see no more connections allowed
max_client_conn 5000 Upper bound on client connections. Set to cluster_nodes × 5
max_db_connections 80 Hard cap on real connections to PostgreSQL. Keep below PG max_connections minus headroom for admin/monitoring
server_idle_timeout 300 Close idle server connections after 5 minutes
server_lifetime 3600 Recycle server connections after 1 hour

On the PostgreSQL side:

PostgreSQL setting Recommended Notes
max_connections 100 Default is fine — PgBouncer is the only client. Set higher than max_db_connections to leave room for admin connections
shared_buffers 25% of RAM Standard PostgreSQL tuning

Turnstone pool settings

Each turnstone process maintains its own SQLAlchemy connection pool to PgBouncer (which then multiplexes to PostgreSQL):

Environment variable Default Description
TURNSTONE_DB_POOL_SIZE 2 Base pool size per process
TURNSTONE_DB_BACKEND sqlite Set to postgresql for cluster deployments
TURNSTONE_DB_URL Connection URL (point at PgBouncer, not PostgreSQL directly)

The default pool of 2 + 3 overflow = 5 connections per process is intentionally small to support large clusters. You should not need to increase this — turnstone's database operations are all short-burst context-managed queries that hold connections for milliseconds.

SQLAlchemy pool_pre_ping is enabled, so stale connections (e.g. after PgBouncer restarts) are automatically detected and replaced.


Monitoring

PgBouncer exposes stats via its admin console (connect to PgBouncer port with user pgbouncer):

-- Active and waiting clients
SHOW POOLS;

-- Per-database stats
SHOW STATS;

-- Current client connections
SHOW CLIENTS;

Key metrics to watch:

  • cl_active — clients with a server connection assigned. Should be well below max_db_connections.
  • cl_waiting — clients waiting for a server connection. Sustained non-zero values mean you need more default_pool_size.
  • sv_active — active server (PostgreSQL) connections. Should stay below PostgreSQL max_connections.

Troubleshooting

"no more connections allowed (max_client_conn)" — PgBouncer is rejecting new client connections. Increase max_client_conn to match your cluster size × 5.

"no more connections allowed (max_db_connections)" — PgBouncer cannot open more connections to PostgreSQL. Increase max_db_connections and ensure PostgreSQL max_connections is higher.

Connections timing out on startup — If all nodes start simultaneously, the burst of initial connections (migrations, health checks) can temporarily exceed the pool. PgBouncer queues excess clients by default — this resolves itself within seconds.

Prepared statements not supported — PgBouncer in transaction mode does not support prepared statements. Turnstone's SQLAlchemy layer does not use server-side prepared statements by default, so this is not an issue.

LISTEN / NOTIFY not supported in transaction mode — PgBouncer's transaction pooling assigns a real server connection only for the duration of each transaction, then returns it to the pool. PostgreSQL LISTEN is session state — a transaction-pooled client can't hold the multi-statement session a long-lived LISTEN needs. The console's NotifyDispatcher (reactive node discovery via the services channel) therefore opens a dedicated, direct-to-Postgres connection that bypasses PgBouncer.

Configure via config.toml [database] listen_url (preferred — co-located with the main url) or the TURNSTONE_DB_LISTEN_URL env var (config.toml wins when both are set). Defaults to the main DB URL when unset.

Setting Behaviour
unset Listener uses TURNSTONE_DB_URL as-is. Fine when PgBouncer is in session mode, or when there's no pooler in front of Postgres. With transaction-mode PgBouncer the listener's LISTEN will fail and the dispatcher retries with exponential backoff (1 s → 30 s cap) without ever succeeding. Reactive NOTIFY-driven node discovery is silently lost; the cluster collector's 60 s _discovery_loop is the only remaining backstop.
set to direct-to-PG URL (e.g. postgresql://…/turnstone) Listener bypasses PgBouncer for its one dedicated connection. Reactive discovery latency drops from up-to-60 s to ~500 ms. The rest of the storage layer continues to go through PgBouncer in transaction mode.

Set this whenever PgBouncer is in transaction mode (the recommended setting per this doc). The override only adds one long-lived PG connection per console process — sized into the cluster's max_connections budget alongside the pool.

See also: Docker deployment · Security