Files
HiveGrid-Wireframes/hivegrid-cloud/docs/ops/pgbouncer.md
T

187 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PgBouncer Operations Runbook
PgBouncer multiplexes Prisma's per-process connection pools onto a small set
of real Postgres backends. The dev compose stack and the staging Coolify
stack both terminate application traffic at `pgbouncer:6432`; Prisma Migrate
keeps a separate path through `postgres:5432` (`DIRECT_URL`).
Related task: [`docs/improvement-plan/03-scaling-infrastructure/pgbouncer.md`](../improvement-plan/03-scaling-infrastructure/pgbouncer.md).
## Topology
```
+--------------+ +-------------+ +----------+
| apps (web, | 6432 | pgbouncer | 5432 | postgres |
| api, worker) +--------->+ transaction +-------->+ 16-alp. |
| Prisma client| | pool, 25/db | | |
+------+-------+ +-------------+ +-----^----+
| |
| DIRECT_URL (5432) — only for prisma migrate ---+
v
prisma migrate / db push / db pull
```
- `DATABASE_URL = postgres://...@pgbouncer:6432/<db>?pgbouncer=true&connection_limit=10`
- `pgbouncer=true` tells Prisma to skip prepared-statement caching.
- `connection_limit=10` caps Prisma's internal pool — PgBouncer pools again
on top, doubling the budget without this would defeat the purpose.
- `DIRECT_URL = postgres://...@postgres:5432/<db>` — read by
`prisma.config.ts` for `migrate`, `db push`, `db pull`, `db seed`.
## Local development
```bash
# Boot Postgres + PgBouncer side by side. The compose service is intentionally
# Docker-network-only — no host port mapping for 6432.
docker compose up postgres pgbouncer
# In another shell:
DIRECT_URL="postgres://user:pass@localhost:5432/boilerplate" \
DATABASE_URL="postgres://user:pass@localhost:6432/boilerplate?pgbouncer=true&connection_limit=10" \
pnpm dev
```
If you do not have a Postgres service in `docker-compose.yml` yet, point both
URLs at your existing local Postgres on port 5432 (skip PgBouncer entirely).
`prisma.config.ts` falls back to `DATABASE_URL` when `DIRECT_URL` is unset.
## Coolify (staging) deploy procedure
The pooler must run on the same Docker network as Postgres. The current
operator-owned deployment source is the Coolify service/network config; the
archived [`STAGING-ACCESS.md`](../improvement-plan/archive/STAGING-ACCESS.md)
is historical inventory only and must not override live Coolify state.
1. **Generate `userlist.txt`.** Postgres on staging is initialised with
`password_encryption=scram-sha-256`, so the SCRAM hash already lives in
`pg_authid.rolpassword`. From the Postgres container:
```bash
psql -U postgres -tAc \
"SELECT '\"'||rolname||'\" \"'||rolpassword||'\"'
FROM pg_authid
WHERE rolname IN ('boilerplate','pgb_admin');" > userlist.txt
chmod 600 userlist.txt
```
Drop the file into a Coolify volume mount that the pgbouncer container
will see at `/etc/pgbouncer/userlist.txt`. **Never commit a real hash.**
2. **Create the Coolify service.**
- Image: `edoburu/pgbouncer:latest`
- Network: `coolify` (must match Postgres)
- Mounts:
- `infra/pgbouncer/pgbouncer.ini` → `/etc/pgbouncer/pgbouncer.ini:ro`
- `userlist.txt` (from step 1) → `/etc/pgbouncer/userlist.txt:ro`
- No public port mapping — Traefik should not see 6432.
- Healthcheck:
`pg_isready -h 127.0.0.1 -p 6432 -U pgb_admin`
3. **Wait for the pooler to be `healthy`.** Verify from inside the network:
```bash
docker exec <pgbouncer-id> psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncer -c "SHOW STATS;"
```
4. **Switch app env vars.** In Coolify, on `web`, `api`, `worker`:
- `DATABASE_URL=postgres://<database-user>:${PG_PASS}@<pgbouncer-service>:6432/<database>?pgbouncer=true&connection_limit=10`
- `DIRECT_URL=postgres://<database-user>:${PG_PASS}@<postgres-service>:5432/<database>`
Use service and database names from the current Coolify project state; do
not copy archived staging inventory into a live cutover.
5. **Restart apps last.** Apps must boot only _after_ the pooler reports
healthy; otherwise the first Prisma query fails and the container loops.
## Migration runbook (production cutover)
1. Provision PgBouncer in Coolify but leave app env vars unchanged.
2. Verify `SHOW STATS` and `SHOW POOLS` respond.
3. Smoke-test from a one-off container:
```bash
docker run --rm --network coolify \
postgres:16-alpine psql \
"postgres://<database-user>:${PG_PASS}@<pgbouncer-service>:6432/<database>" \
-c "SELECT 1;"
```
4. Roll out `DIRECT_URL` to all apps first (no behaviour change because
`prisma.config.ts` falls back to `DATABASE_URL`).
5. Roll out the new `DATABASE_URL` (with `pgbouncer=true&connection_limit=10`).
Coolify will recreate containers — they must reconnect to the pooler.
6. Watch `pgbouncer` logs for `WARNING|ERROR|FATAL`.
7. Confirm `prisma migrate status` from a CI container still passes (uses
`DIRECT_URL`).
## Tuning knobs
| Knob | Default | When to change |
| --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pool_mode` | `transaction` | Stay on `transaction` for web traffic. Long-lived advisory locks or `LISTEN/NOTIFY` need `session` mode on a separate logical database. |
| `default_pool_size` | `25` | Rule of thumb: `postgres.max_connections / number_of_app_pools / 1.5`. Hetzner Postgres is provisioned with `max_connections=200`, so 25 per logical DB × app stack still leaves headroom. |
| `max_client_conn` | `1000` | Raises the upper bound of _client_-side connections PgBouncer will accept. Bumping past 1000 has memory cost (~12 KB/conn). |
| `reserve_pool_size` | `5` | Burst buffer when the main pool is exhausted. Increase only if `SHOW POOLS` shows recurring `cl_waiting`. |
| `server_idle_timeout` | `600` | Lower if Postgres is rapidly recycled (cloud-managed). Higher = fewer reconnects. |
| `query_timeout` | `0` | Leave at `0`. Per-query timeouts live in Prisma / API layer; PgBouncer killing a long report query is hard to debug. |
| `server_reset_query` | `DISCARD ALL` | Required for transaction pooling — prevents tenant state from leaking between transactions on the same backend. Do not weaken. |
Grafana dashboard lives under
`infra/grafana/dashboards/` (provisioned by Phase 02 `#35`).
Panels of interest: pool wait time p95, server connection count vs.
`default_pool_size`, total clients vs. `max_client_conn`.
## Troubleshooting
### `FATAL: prepared statement "s0" already exists`
PgBouncer in transaction pool mode reused a server-side connection that
still has the prepared statement cached. Confirm `DATABASE_URL` has
`?pgbouncer=true` and Prisma 5+ is in use. Without the flag, Prisma keeps
local prepared statements and the next transaction lands on a different
backend.
### `error: too many clients already`
Postgres ran out of connections. Either:
- `default_pool_size` × number-of-app-pools > `max_connections` — lower
pool size or raise `max_connections` on Postgres.
- Prisma's `connection_limit` is unset, so each app process opens its own
default pool (CPU + 1) against PgBouncer. Set `connection_limit=10` (or
lower) in the query string of `DATABASE_URL`.
### `cl_waiting > 0` persistently in `SHOW POOLS`
Application sustained pool exhaustion. Options:
- raise `default_pool_size` (and double-check `max_connections` headroom),
- introduce a read replica (see `03-scaling-infrastructure/read-write-split.md`),
- inspect slow queries — long-running transactions hold backends.
### Migrations fail with "prepared statement" or DDL errors
`DIRECT_URL` is wrong or missing. `prisma migrate` must hit Postgres
directly. Verify `prisma.config.ts` reads `DIRECT_URL`:
```bash
DATABASE_URL=postgres://...@pgbouncer:6432/db?pgbouncer=true \
DIRECT_URL=postgres://...@postgres:5432/db \
pnpm exec prisma migrate status
```
### Healthcheck flapping
`pg_isready` returns non-zero whenever PgBouncer cannot reach Postgres.
Check Postgres health first; PgBouncer surfaces an empty server pool as
unhealthy.
## Rollback
To bypass PgBouncer without code changes:
1. In Coolify, change `DATABASE_URL` to point at Postgres directly
(port 5432, drop `?pgbouncer=true&connection_limit=10`).
2. Restart `web`, `api`, `worker`.
3. Stop the `pgbouncer` Coolify service.
`DIRECT_URL` stays valid — it never depended on the pooler.