Files
beacon-server/db/pools_integration_test.go
T
MrAlders0n 94b4b40f9b planguard: plan and result regression checks, fixture tier in CI, read-only mode (#240)
* feat(planguard): read sqlc statement text and load a strict plan corpus

* fix(planguard): fail on unextracted statements, keep fragments mode-neutral

* feat(planguard): plan assertions, scoped sort exemptions and narrow retired-index verdicts

* fix(planguard): an errored run always fails

* fix(planguard): keep subplan conds off their parent scan; fail on unanalyzed budgets

* feat(planguard): read-only plan runner, reports and the run command

* fix(planguard): validate identifiers in emitted scripts; prove read-only runs

* feat(planguard): end-state corpus, planted regressions and the generated cached-plan policy

* fix(planguard): derive observer cases from data; mark legacy generic plans

* fix(planguard): bound the dense-region route resolvers; test policy refusals

* fix(planguard): re-measure the dense-region route cases; cover every policy refusal

* feat(planguard): synthetic fixture built through the migrations, checked in CI

* ci(planguard): build the fixture once; guard fixture data for identifiers

* fix(planguard): hash every fixture generator input; harden the fixture guard

* feat(planguard): snapshot diff over every column, named result classes and cursor walks

* fix(planguard): bound the relaxed diff classes; derive the cursor tie from data

* fix(planguard): refuse multi-statement text in the corpus runner

* feat: read-only server mode and an HTTP page diff between two builds

* fix(planguard): refuse escape strings and psql meta-commands in corpus text

* fix: storediff follows each build's own cursor; prove read-only gates via the log

* fix(planguard): match the PostgreSQL lexer on high-bit bytes; check every emitted line

* feat(admin): daily read-only plan check on the maintenance pool, its metric and report

* feat(planguard): daily prod check behind planguard.daily_check

* fix(planguard): cancel fresh runs at shutdown; expose a never-ran check

* ci(planguard): pin the fixture server and collation; refresh baselines

* fix(planguard): calibrate route budgets on one dataset; skip entries whose index is absent
2026-10-19 13:45:39 -04:00

219 lines
7.0 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"crypto/rand"
"errors"
"io"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/db/planpolicy"
"github.com/MeshCore-Beacon/beacon-server/internal/metrics"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Each opened pool has exactly the table's settings and size; pools the layout does not use are nil.
func TestOpenPoolsSettingsPostgres(t *testing.T) {
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set BEACON_TEST_POSTGRES_DSN")
}
// A parameter in the DSN must survive the per-pool settings merge.
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "search_path=pools_test_path"
const dflt = "-" // not asserted: the server default
// application_name, plan_cache_mode, jit, max_parallel_workers_per_gather, statement_timeout,
// idle_in_transaction_session_timeout
settings := map[string][6]string{
PoolAPILegacy: {"beacon-api_legacy", "auto", dflt, dflt, "0", "0"},
PoolAPICustom: {"beacon-api_custom", "force_custom_plan", "off", "0", "35s", "30s"},
PoolAPICached: {"beacon-api_cached", "force_generic_plan", "off", "0", "35s", "30s"},
PoolIngest: {"beacon-ingest", "auto", "off", dflt, "0", "0"},
PoolMaint: {"beacon-maint", "force_custom_plan", "off", "0", "0", "0"},
}
shows := []string{"application_name", "plan_cache_mode", "jit", "max_parallel_workers_per_gather",
"statement_timeout", "idle_in_transaction_session_timeout"}
for _, tc := range []struct {
layout planpolicy.Layout
want map[string]int32
total int32
}{
{planpolicy.CustomAll, map[string]int32{"api_legacy": 8, "api_custom": 8, "maint": 4}, 0},
{planpolicy.Split, map[string]int32{"api_legacy": 8, "api_custom": 7, "api_cached": 5, "maint": 4}, 0},
{planpolicy.Single, map[string]int32{"api_legacy": 8, "maint": 4}, 0},
} {
// The ingest pool follows the host: at least 6, and one per CPU.
tc.want["ingest"] = int32(max(6, runtime.NumCPU()))
for _, n := range tc.want {
tc.total += n
}
t.Run(string(tc.layout), func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
p, err := OpenPools(ctx, dsn, tc.layout)
if err != nil {
t.Fatal(err)
}
defer p.Close()
for name, pool := range map[string]*pgxpool.Pool{
PoolAPILegacy: p.APILegacy, PoolAPICustom: p.APICustom, PoolAPICached: p.APICached,
PoolIngest: p.Ingest, PoolMaint: p.Maint,
} {
size, opened := tc.want[name]
if (pool != nil) != opened {
t.Errorf("%s: opened = %v, want %v", name, pool != nil, opened)
continue
}
if !opened {
continue
}
if got := pool.Config().MaxConns; got != size {
t.Errorf("%s: MaxConns = %d, want %d", name, got, size)
}
// A failed assertion must release the connection, or the deferred Close waits on it.
func() {
conn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("%s: acquire: %v", name, err)
}
defer conn.Release()
for i, guc := range shows {
want := settings[name][i]
if want == dflt {
continue
}
var got string
if err := conn.QueryRow(ctx, "SHOW "+guc).Scan(&got); err != nil {
t.Fatalf("%s: SHOW %s: %v", name, guc, err)
}
if got != want {
t.Errorf("%s: %s = %q, want %q", name, guc, got, want)
}
}
var path string
if err := conn.QueryRow(ctx, "SHOW search_path").Scan(&path); err != nil {
t.Fatal(err)
}
if path != "pools_test_path" {
t.Errorf("%s: search_path = %q, want the DSN's pools_test_path", name, path)
}
// The tracer labels the statement with its sqlc name, and Query ends at rows.Close.
rows, err := conn.Query(ctx, "-- name: PoolsTracerProbe :many\nSELECT 1")
if err != nil {
t.Fatal(err)
}
rows.Close()
}()
body := scrapeMetrics(t)
for _, want := range []string{
`beacon_db_time_seconds_count{pool="` + name + `",statement="PoolsTracerProbe"}`,
`beacon_db_time_seconds_count{pool="` + name + `",statement="unnamed"}`,
`beacon_pool_acquire_seconds_count{pool="` + name + `"}`,
} {
if !strings.Contains(body, want) {
t.Errorf("metrics missing %s", want)
}
}
}
if got := p.Total(); got != tc.total {
t.Errorf("Total() = %d, want %d", got, tc.total)
}
})
}
}
func scrapeMetrics(t *testing.T) string {
t.Helper()
rec := httptest.NewRecorder()
metrics.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
b, err := io.ReadAll(rec.Body)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// A tune that sets default_transaction_read_only on every pool makes each pool refuse a write
// with SQLSTATE 25006 while reads still work.
func TestReadOnlyPoolsRejectWritesPostgres(t *testing.T) {
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set BEACON_TEST_POSTGRES_DSN")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// Own throwaway database, so the table exists and nothing else sees the attempt.
admin, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close(context.Background())
name := "beacon_read_only_pools_" + strings.ToLower(rand.Text())
ident := pgx.Identifier{name}.Sanitize()
if _, err := admin.Exec(ctx, "CREATE DATABASE "+ident); err != nil {
t.Fatal(err)
}
defer admin.Exec(context.Background(), "DROP DATABASE "+ident+" WITH (FORCE)")
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.Database = name
migrated, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
err = RunMigrations(ctx, migrated)
migrated.Close()
if err != nil {
t.Fatal(err)
}
tuned := map[string]bool{}
tune := func(pool string, c *pgxpool.Config) {
tuned[pool] = true
c.ConnConfig.Database = name
c.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on"
}
p, err := OpenPools(ctx, dsn, planpolicy.CustomAll, tune)
if err != nil {
t.Fatal(err)
}
defer p.Close()
pools := map[string]*pgxpool.Pool{PoolAPILegacy: p.APILegacy, PoolAPICustom: p.APICustom, PoolIngest: p.Ingest, PoolMaint: p.Maint}
for name, pool := range pools {
if !tuned[name] {
t.Errorf("%s: tune not called", name)
}
_, err := pool.Exec(ctx, "INSERT INTO background_state(key, value) VALUES ($1, '{}')", "read-only-"+name)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "25006" {
t.Errorf("%s: INSERT error = %v, want SQLSTATE 25006", name, err)
}
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM background_state").Scan(&n); err != nil {
t.Errorf("%s: SELECT: %v", name, err)
} else if n != 0 {
t.Errorf("%s: background_state has %d rows, want 0", name, n)
}
}
if len(tuned) != len(pools) {
t.Errorf("tune called for %v, want the %d custom_all pools", tuned, len(pools))
}
}