Files
beacon-server/db/pools.go
T
MrAlders0n 03f66d1ae4 Database pools by workload, plan router, observe-only guards, metrics (rest of #237) (#238)
* feat(db): five pgx pools with fixed session settings and Prometheus tracing

* feat(guards): per-family observe/enforce guards with client slots, deadlines and a shadow budget

* fix(guards): attribute only guard deadlines and classify like the handlers

* feat(db): route API statements by sqlc name; class C always unnamed

* feat(admin): statement statistics by sqlc name; create pg_stat_statements where allowed

* fix(db): skip pg_stat_statements when no schema is creatable

* feat(background): index health against post-rebuild baselines, Postgres activity, job and ingest metrics

* feat: separate API, ingest and maintenance stores; guards on the public API; metrics listener

* fix(db): keep ingest writes waiting, size the ingest pool to the host, tidy

* test(db): give the statements probe a query id of its own
2026-10-19 07:52:51 -04:00

177 lines
5.3 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"fmt"
"log/slog"
"runtime"
"strings"
"github.com/MeshCore-Beacon/beacon-server/db/planpolicy"
"github.com/jackc/pgx/v5/pgxpool"
)
// Pool names. Each is also the pool's metric label and, prefixed with "beacon-", its application_name.
const (
PoolAPILegacy = "api_legacy"
PoolAPICustom = "api_custom"
PoolAPICached = "api_cached"
PoolIngest = "ingest"
PoolMaint = "maint"
)
// Pools holds one pgx pool per workload. A pool is nil when the layout does not open it.
type Pools struct {
APILegacy, APICustom, APICached, Ingest, Maint *pgxpool.Pool
}
type poolSpec struct {
name string
size map[planpolicy.Layout]int32 // a layout absent here does not open the pool
params map[string]string
}
// poolSpecs is the fixed size and session settings of each pool. api_legacy keeps today's
// session settings: only its application_name differs.
var poolSpecs = []poolSpec{
{
name: PoolAPILegacy,
size: map[planpolicy.Layout]int32{planpolicy.CustomAll: 8, planpolicy.Split: 8, planpolicy.Single: 8},
params: map[string]string{
"application_name": "beacon-api_legacy",
},
},
{
name: PoolAPICustom,
size: map[planpolicy.Layout]int32{planpolicy.CustomAll: 8, planpolicy.Split: 7},
params: map[string]string{
"application_name": "beacon-api_custom",
"plan_cache_mode": "force_custom_plan",
"jit": "off",
"max_parallel_workers_per_gather": "0",
"statement_timeout": "35s",
"idle_in_transaction_session_timeout": "30s",
},
},
{
name: PoolAPICached,
size: map[planpolicy.Layout]int32{planpolicy.Split: 5},
params: map[string]string{
"application_name": "beacon-api_cached",
"plan_cache_mode": "force_generic_plan",
"jit": "off",
"max_parallel_workers_per_gather": "0",
"statement_timeout": "35s",
"idle_in_transaction_session_timeout": "30s",
},
},
{
name: PoolIngest,
size: map[planpolicy.Layout]int32{planpolicy.CustomAll: ingestPoolSize(), planpolicy.Split: ingestPoolSize(), planpolicy.Single: ingestPoolSize()},
// No statement_timeout: a failed ingest write drops its packet, so a write waits out a lock instead.
params: map[string]string{
"application_name": "beacon-ingest",
"plan_cache_mode": "auto",
"jit": "off",
},
},
{
name: PoolMaint,
size: map[planpolicy.Layout]int32{planpolicy.CustomAll: 4, planpolicy.Split: 4, planpolicy.Single: 4},
params: map[string]string{
"application_name": "beacon-maint",
"plan_cache_mode": "force_custom_plan",
"jit": "off",
"max_parallel_workers_per_gather": "0",
},
},
}
// ingestPoolSize is at least 6 and at least one connection per CPU, so ingest never has fewer
// connections than the single shared pool gave it before the pools split.
func ingestPoolSize() int32 {
return int32(max(6, runtime.NumCPU()))
}
func (p *Pools) slot(name string) **pgxpool.Pool {
switch name {
case PoolAPILegacy:
return &p.APILegacy
case PoolAPICustom:
return &p.APICustom
case PoolAPICached:
return &p.APICached
case PoolIngest:
return &p.Ingest
case PoolMaint:
return &p.Maint
}
panic("db: unknown pool " + name)
}
// OpenPools opens the pools the layout uses, each with its fixed size and session settings
// merged over the DSN's own parameters. A pool_max_conns in the DSN is overridden by the layout's
// sizes. The settings are sent as startup parameters, so behind PgBouncer its
// ignore_startup_parameters must list them. Each tune func may adjust a pool's config before it
// opens. On error it closes the pools it already opened. Errors name the pool but never include
// the DSN, since parse errors can embed its password.
func OpenPools(ctx context.Context, dsn string, layout planpolicy.Layout, tune ...func(pool string, cfg *pgxpool.Config)) (*Pools, error) {
if _, ok := poolSpecs[0].size[layout]; !ok {
return nil, fmt.Errorf("unknown API plan layout %q", layout)
}
if strings.Contains(dsn, "pool_max_conns") {
slog.Info("POSTGRES_DSN sets pool_max_conns; the database pool sizes override it", "component", "db", "layout", layout)
}
p := &Pools{}
for _, spec := range poolSpecs {
size, ok := spec.size[layout]
if !ok {
continue
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
p.Close()
return nil, fmt.Errorf("pool %s: invalid PostgreSQL connection configuration", spec.name)
}
cfg.MaxConns = size
for k, v := range spec.params {
cfg.ConnConfig.RuntimeParams[k] = v
}
cfg.ConnConfig.Tracer = newTracer(spec.name)
for _, f := range tune {
f(spec.name, cfg)
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
p.Close()
return nil, fmt.Errorf("pool %s: %w", spec.name, err)
}
*p.slot(spec.name) = pool
}
return p, nil
}
// Close closes every opened pool.
func (p *Pools) Close() {
for _, spec := range poolSpecs {
if s := p.slot(spec.name); *s != nil {
(*s).Close()
*s = nil
}
}
}
// Total is the sum of MaxConns over the opened pools.
func (p *Pools) Total() int32 {
var n int32
for _, spec := range poolSpecs {
if pool := *p.slot(spec.name); pool != nil {
n += pool.Config().MaxConns
}
}
return n
}