Files
warmbly/internal/infrastructure/db/client.go
T
Matthew Meszaros f9c02bba6e fix(db): plug 4 tx leaks + bump pool from 4 → 25 — root cause of 10-min logout
Root cause for the 10-min auto-logout (confirmed via pg_stat_activity):
the postgres pool MaxConns was 4, and four repository functions opened
a tx without committing or rolling back. After four calls each leaked
a connection in "idle in transaction" state. Once all four were gone
the pool was permanently exhausted — every new request that needed a
connection blocked until the client gave up. The 10-min trigger is
because that's when the first /auth/refresh fires; refresh tries to
acquire a connection, hangs, eventually the browser aborts the request,
the frontend treats the failure as session expiry, kicks the user.

The four leaking sites:
  - emailRepository.Search        (drove the leak — Accounts page)
  - campaignRepository.Search
  - sequenceRepository.Create
  - contactRepository.BulkUpdate

Each now has `defer tx.Rollback(ctx)` immediately after Begin, matching
the pattern used in the non-leaky sites in the same files. Rollback is
a no-op after Commit, so this is safe for both read-only tx (Search)
and read-write tx (Create / BulkUpdate).

Additional hardening so a future leak can't silently brick the backend:
  - MaxConns 4 → 25. 4 was reckless even without leaks; one bursty
    admin page would saturate. 25 is still well under postgres'
    default max_connections=100.
  - MinConns 0 → 2. Keep a couple of warm connections at idle so the
    first request after a quiet period doesn't pay the connect cost.
  - idle_in_transaction_session_timeout=300000 (5 min) as a session
    RuntimeParam. If a code path forgets the defer, postgres aborts
    the leaked tx after 5 min and reclaims the connection.
  - statement_timeout=60000 (60 s) as a session RuntimeParam.
    Statement runaway can't pin a connection forever.

Verified after backend restart:
  SELECT count(*) FROM pg_stat_activity
    WHERE datname='warmbly_dev' AND state='idle in transaction';
  → 0
2026-05-23 04:41:40 +00:00

69 lines
2.2 KiB
Go

package db
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
*pgxpool.Pool
}
const (
// MaxConns sizing rationale: 4 was catastrophically low — a single
// leaked tx in a request handler is enough to deadlock the entire
// backend (e.g. /auth/refresh blocks waiting for a connection, the
// frontend treats the resulting timeout as session expiry, user is
// kicked at the 10-minute refresh boundary). 25 leaves headroom even
// under bursty admin pages while staying well under postgres'
// default max_connections=100.
defaultMaxConns = int32(25)
defaultMinConns = int32(2)
defaultMaxConnLifetime = time.Hour
defaultMaxConnIdleTime = time.Minute * 30
defaultHealthCheckPeriod = time.Minute
defaultConnectTimeout = time.Second * 5
// Postgres idle-in-transaction safety net. If a code path forgets
// `defer tx.Rollback(ctx)`, the server will abort the leaked tx
// after this many milliseconds (5 min) instead of holding the
// connection forever. Belt-and-suspenders against the bug class
// that caused the 10-minute logout.
idleInTxnTimeoutMs = "300000"
// Statement-level safety net for query runaway. 60s should be more
// than enough for any user-facing query; admin reports that need
// longer can override with SET LOCAL statement_timeout.
statementTimeoutMs = "60000"
)
func New(ctx context.Context, endpoint string) (*DB, error) {
dbConfig, err := pgxpool.ParseConfig(endpoint)
if err != nil {
return nil, err
}
dbConfig.MaxConns = defaultMaxConns
dbConfig.MinConns = defaultMinConns
dbConfig.MaxConnLifetime = defaultMaxConnLifetime
dbConfig.MaxConnIdleTime = defaultMaxConnIdleTime
dbConfig.HealthCheckPeriod = defaultHealthCheckPeriod
dbConfig.ConnConfig.ConnectTimeout = defaultConnectTimeout
if dbConfig.ConnConfig.RuntimeParams == nil {
dbConfig.ConnConfig.RuntimeParams = map[string]string{}
}
dbConfig.ConnConfig.RuntimeParams["idle_in_transaction_session_timeout"] = idleInTxnTimeoutMs
dbConfig.ConnConfig.RuntimeParams["statement_timeout"] = statementTimeoutMs
conn, err := pgxpool.NewWithConfig(ctx, dbConfig)
if err != nil {
return nil, err
}
return &DB{
Pool: conn,
}, nil
}