Persistence¶
The optional Postgres-backed state store: what it persists, its schema, retention, and current trade-offs.
Overview¶
By default (--state-backend=memory) all state lives only in process memory: the Collector keeps its per-queryid samples in an in-memory map, and the Ingester keeps deploy events in an in-memory slice. That means a pod restart loses all history, and running multiple replicas gives each one an inconsistent, independent view of the world. Setting --state-backend=postgres additionally persists both to Postgres, so history survives restarts and can be inspected/shared across replicas.
Configuring it¶
operator --dsn "host:5432/monitored_db?sslmode=disable" --state-backend postgres
# --state-dsn defaults to --dsn above: state lives in the SAME Postgres
# cluster being monitored. To isolate it, point at a separate instance:
# --state-dsn "host:5432/pgrr_state?sslmode=disable"
- Same Postgres as the monitored cluster (default,
--state-dsnempty): simplest to operate — one connection string, one cluster. Trade-off: the tool's own write traffic shows up in the verypg_stat_statementsit watches, and an outage of the monitored cluster also takes down the history. - Separate Postgres instance (
--state-dsnset): isolates storage load and availability from the cluster under observation, at the cost of running a second Postgres.
Schema¶
Everything lives in its own schema so it never collides with application tables and can be dropped cleanly:
CREATE SCHEMA IF NOT EXISTS regression_radar;
CREATE TABLE regression_radar.query_samples (
id BIGSERIAL PRIMARY KEY,
query_id BIGINT NOT NULL,
query_text TEXT NOT NULL,
calls BIGINT NOT NULL,
total_exec_time_ms DOUBLE PRECISION NOT NULL,
mean_exec_time_ms DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_query_samples_queryid_recorded_at ON regression_radar.query_samples (query_id, recorded_at);
CREATE INDEX idx_query_samples_recorded_at ON regression_radar.query_samples (recorded_at);
CREATE TABLE regression_radar.deploy_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT '',
app TEXT NOT NULL DEFAULT '',
cluster TEXT NOT NULL DEFAULT '',
namespace TEXT NOT NULL DEFAULT '',
revision TEXT NOT NULL DEFAULT '',
image_tag TEXT NOT NULL DEFAULT '',
event_timestamp TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_deploy_events_event_timestamp ON regression_radar.deploy_events (event_timestamp);
Why not pg_regression_radar?
PostgreSQL reserves the literal pg_ prefix for its own system schemas — CREATE SCHEMA pg_regression_radar is rejected outright with "unacceptable schema name". The schema is named regression_radar for exactly this reason.
The schema is applied automatically on startup (internal/storage/postgres) using hand-rolled, idempotent DDL (CREATE ... IF NOT EXISTS) rather than a migration framework like golang-migrate/goose — both tables are append-mostly, derived/observational data that evolves purely additively, so the extra machinery of a versioned migration runner isn't earning its keep yet. If the schema ever needs a genuinely breaking change (rename, backfill, tightened constraint), that's the point to introduce one.
Retention¶
Old rows are pruned periodically (--state-prune-interval, default 15 min) using a DELETE ... WHERE recorded_at < now() - retention sweep, where retention defaults to 7 days (--state-retention).
Trade-offs and limitations¶
- Not a replacement for leader election. Pointing multiple operator replicas at the same state backend makes the data consistent and durable, but each replica still independently scrapes
pg_stat_statementsand runs the correlation engine — so you'd get duplicate scrapes and duplicate alerts. Preventing that requires only one replica being active at a time (leader election), whichcmd/managerprovides via controller-runtime's built-in leader election, not this package. Don't run N unattendedcmd/operatorreplicas against a shared backend. - Extra write load. Every scrape/webhook now also does a write to Postgres. The connection pool used for this is intentionally small (see
internal/storage/postgres.Open) to keep the footprint low, especially when reusing the monitored cluster's own Postgres.
Backfill on startup¶
When --state-backend=postgres, RunOperator loads recent history from the store and seeds the live in-memory Collector/Ingester with it before the webhook/metrics servers or the deploy-event poll loop start:
collector.Collector.Backfillseeds the per-queryidsample history (covering the last--retention-minutes), so the correlation engine's view isn't empty for the first--window-minutesafter a restart. It does not touch the live Prometheus gauges (pg_regression_radar_query_mean_exec_time_ms/..._calls_total) — those reflect the most recent live scrape, and overwriting them with historical values would misrepresent current server state to anything scraping/metricsbefore the first real scrape completes.ingester.Store.Backfillseeds deploy-event history the same way, and returns the resulting event count, which becomes the poll loop's initialDrainSincecursor. This matters for correctness, not just completeness:DrainSincetreats anything past its cursor as newly-arrived work to analyse and potentially alert on. Backfilling events without also advancing the cursor past them would make every historical deploy event look brand new on restart, re-running correlation and potentially re-sending duplicate Slack alerts for regressions already reported in a previous process lifetime.
Both backfills are best-effort: a failure to load either kind of history is logged at Warn and the process starts with a cold in-memory view for that piece, exactly like before this existed, rather than failing startup outright.
See also¶
- Configuration Reference — the full
--state-*flag list. - Collector Internals — the in-memory samples this store durably copies.
- Roadmap — other known gaps.