Files
multica/server/cmd/server/dbstats.go
LinYushen 9c9afd4a66 feat(metrics): BusinessSamplerCollector for active users / queued / runtime gauges (MUL-2947) (#3706)
* feat(metrics): scrape-time BusinessSamplerCollector for active users / queued / runtime gauges (MUL-2947)

Adds an opt-in prometheus.Collector that runs a fixed set of read-only
SQL queries on every /metrics scrape and exposes the results as gauges:

  - multica_active_users{window=5m|1h|24h}
  - multica_active_workspaces{window=...}
  - multica_agent_task_queued{source}
  - multica_agent_task_running{source,runtime_mode}
  - multica_agent_task_stuck_total{source}
  - multica_runtime_online{runtime_mode,provider}
  - multica_runtime_heartbeat_age_seconds{runtime_mode} (histogram)
  - multica_workspace_total

Plus a self-introspection histogram
multica_business_sampler_query_seconds{name=...} and a counter
multica_business_sampler_query_errors_total{name=...} so the sampler's
own behaviour is observable on /metrics.

Production-safety contract per the PR4 brief:
  - every query runs in its own BEGIN READ ONLY tx with
    SET LOCAL statement_timeout = '500ms' (configurable)
  - the sampler takes a dedicated *pgxpool.Pool option so operators
    can isolate it from business traffic
  - successful results are cached for 5–10s (default 8s) to absorb
    concurrent scrapes from multiple Prometheus replicas
  - every SQL has a hard LIMIT 100 fallback
  - all label values flow through the existing BusinessMetrics
    NormalizeTaskSource / NormalizeRuntimeMode / NormalizeRuntimeProvider
    whitelists, so a misbehaving runtime cannot inflate cardinality
  - sampler is OPT-IN via RegistryOptions.BusinessSampler — existing
    callers that only pass Pool keep their current behaviour and never
    start hitting the DB on /metrics

Tests cover: emit shape, TTL cache (one DB call per N scrapes),
bounded cardinality under malicious labels, opt-out (no leakage), and
DB-hang isolation (unreachable host -> /metrics returns within 5s,
query_errors_total advances).

Refs MUL-2947 (depends on PR2 / MUL-2948, merged in #3695).

Co-authored-by: multica-agent <github@multica.ai>

* fix(metrics): address PR4 review — wire sampler in main.go, fix LIMIT bug, add live-DB statement_timeout test

Three fixes from 大彪's review on #3706:

1. main.go was building NewRegistry without the BusinessSampler option,
   so the collector was effectively dead code in prod. Now constructs a
   dedicated 2-conn pgxpool (newSamplerDBPool) from the same DATABASE_URL
   when METRICS_ADDR is set, plumbs it into RegistryOptions.BusinessSampler,
   and defers Close() at shutdown. A pool-build failure logs and disables
   the sampler instead of taking down the server.

2. queryActiveUsers / queryActiveWorkspaces previously wrapped the
   distinct-user/workspace subquery in a 'LIMIT 100', then COUNT(*)'d
   the result — capping the active-user gauge at 100 regardless of
   reality. Removed the inner LIMIT; the COUNT scalar is one row anyway,
   and metric cardinality is bounded by the fixed samplerWindows
   allow-list, not by the SQL shape.

3. The previous DB-hang test only exercised the acquire-fails path. Added
   business_sampler_pgsleep_test.go which connects to a live Postgres
   (skips cleanly when DATABASE_URL is not set), runs SELECT pg_sleep(2)
   inside a sampler-style tx with SET LOCAL statement_timeout = '500ms',
   and asserts:
     - the call returns in well under 1.5 s (proving the server-side
       cancellation, not just our caller-side context)
     - query_errors_total{name=pg_sleep_canary} advances
     - the duration histogram records the cancellation
   Verified locally: 550 ms, SQLSTATE 57014 'canceling statement due to
   statement timeout' — exactly the safety net the PR claims.

Refs MUL-2947 / PR #3706.

Co-authored-by: multica-agent <github@multica.ai>

* test(metrics): assert SQLSTATE 57014 on pg_sleep cancellation

The previous assertion only checked that the query was cut off in well
under the sleep duration, which a caller-side context cancellation
would also satisfy. Capturing the inner pgconn.PgError and asserting
Code == "57014" ("query_canceled") nails down that Postgres itself
cancelled the statement because of the SET LOCAL statement_timeout —
so a regression that drops the SET LOCAL line fails this test loudly
instead of silently passing on context cancellation.

Refs MUL-2947 / PR #3706 review nit.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:50:11 +08:00

224 lines
7.8 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
// dbStatsInterval is how often the pool stats are sampled and logged.
// 15s lines up with the daemon heartbeat cadence so it's easy to
// correlate with traffic patterns in the prod logs.
dbStatsInterval = 15 * time.Second
// defaultMaxConns / defaultMinConns are the per-pod pgxpool sizing
// defaults. They replace pgx's built-in default of max(4, NumCPU),
// which is far too small for our daemon-poll traffic pattern (~3800
// acquires/s observed in prod) and was the root cause of the 3s+
// /tasks/claim tail latency.
//
// The numbers follow the conventional "small pool, lots of waiters"
// guidance for Postgres (HikariCP / PG community formula
// `(core_count * 2) + effective_spindle_count`): 25 leaves headroom
// for bursts and the occasional long-running query while staying well
// below typical managed-Postgres `max_connections` ceilings when
// multiplied across pods. MinConns=5 keeps a warm baseline so cold
// pods don't pay handshake cost on first traffic.
//
// Both values are overridable via DATABASE_MAX_CONNS / DATABASE_MIN_CONNS.
defaultMaxConns int32 = 25
defaultMinConns int32 = 5
)
// newDBPool builds a pgxpool with sane production defaults and env overrides.
//
// pgxpool.New(ctx, url) — used previously — silently picks MaxConns =
// max(4, NumCPU). On our prod pods (small CPU request) that resolved to 4,
// which got fully saturated by the daemon claim/heartbeat traffic and showed
// up as ~900ms acquire waits on every query.
//
// Configuration precedence (highest first):
// 1. DATABASE_MAX_CONNS / DATABASE_MIN_CONNS env vars
// 2. pool_max_conns / pool_min_conns query params on DATABASE_URL
// (honored natively by pgxpool.ParseConfig)
// 3. The defaults defined here (defaultMaxConns / defaultMinConns)
//
// pgx's own built-in default (max(4, NumCPU)) is intentionally NOT used as a
// fallback — it is the value that caused the prod incident.
func newDBPool(ctx context.Context, dbURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dbURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
urlParams := poolParamsFromURL(dbURL)
// Compute the non-env fallback first: honor URL pool_* params if the
// operator set them, otherwise use our code default. This fallback is
// also what an *invalid* env value falls back to — never pgx's built-in
// default of 4/0, which is the value that caused the prod incident.
maxFallback := defaultMaxConns
if urlParams["pool_max_conns"] {
maxFallback = cfg.MaxConns
}
cfg.MaxConns = envInt32("DATABASE_MAX_CONNS", maxFallback)
minFallback := defaultMinConns
if urlParams["pool_min_conns"] {
minFallback = cfg.MinConns
}
cfg.MinConns = envInt32("DATABASE_MIN_CONNS", minFallback)
if cfg.MinConns > cfg.MaxConns {
cfg.MinConns = cfg.MaxConns
}
return pgxpool.NewWithConfig(ctx, cfg)
}
// poolParamsFromURL returns the set of pool_* query params present on the
// database URL. Used to detect whether the operator already tuned the pool
// via the connection string, so env-less upgrades don't silently override
// existing configuration.
func poolParamsFromURL(dbURL string) map[string]bool {
out := map[string]bool{}
u, err := url.Parse(dbURL)
if err != nil {
return out
}
for k := range u.Query() {
out[k] = true
}
return out
}
// envInt32 reads an int32 from the named env var. Empty / invalid values fall
// back to def and emit a warn so misconfiguration is visible in startup logs.
func envInt32(name string, def int32) int32 {
raw := os.Getenv(name)
if raw == "" {
return def
}
v, err := strconv.ParseInt(raw, 10, 32)
if err != nil || v <= 0 {
slog.Warn("invalid env var, using default",
"name", name, "value", raw, "default", def, "error", err)
return def
}
return int32(v)
}
// logPoolConfig prints the effective pgxpool configuration once at startup.
// Surfacing this is critical because pgxpool defaults are surprisingly small
// (MaxConns = max(4, NumCPU)) — without seeing the value in the log it's
// easy to mistake pool exhaustion for "the database is slow".
func logPoolConfig(pool *pgxpool.Pool) {
cfg := pool.Config()
slog.Info("db pool config",
"max_conns", cfg.MaxConns,
"min_conns", cfg.MinConns,
"max_conn_lifetime", cfg.MaxConnLifetime.String(),
"max_conn_idle_time", cfg.MaxConnIdleTime.String(),
"health_check_period", cfg.HealthCheckPeriod.String(),
)
}
// runDBStatsLogger samples pool.Stat() periodically. It always emits an INFO
// line so operators can see baseline pressure, and emits a WARN whenever the
// EmptyAcquireCount delta is positive — that's the direct symptom of pool
// exhaustion (a request had to wait because no idle conn was available) and
// the smoking gun we're looking for to confirm the slow /tasks/claim
// hypothesis.
func runDBStatsLogger(ctx context.Context, pool *pgxpool.Pool) {
ticker := time.NewTicker(dbStatsInterval)
defer ticker.Stop()
var (
lastEmpty int64
lastAcquire int64
lastAcquireDur time.Duration
lastCanceled int64
)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
s := pool.Stat()
emptyDelta := s.EmptyAcquireCount() - lastEmpty
acquireDelta := s.AcquireCount() - lastAcquire
acquireDurDelta := s.AcquireDuration() - lastAcquireDur
canceledDelta := s.CanceledAcquireCount() - lastCanceled
// Average wait per acquire over the last sampling window. Useful
// because cumulative AcquireDuration alone hides whether the
// situation is improving or worsening.
var avgAcquireMs int64
if acquireDelta > 0 {
avgAcquireMs = (acquireDurDelta).Milliseconds() / acquireDelta
}
fields := []any{
"max_conns", s.MaxConns(),
"total_conns", s.TotalConns(),
"acquired_conns", s.AcquiredConns(),
"idle_conns", s.IdleConns(),
"constructing_conns", s.ConstructingConns(),
"acquire_count_delta", acquireDelta,
"empty_acquire_delta", emptyDelta,
"canceled_acquire_delta", canceledDelta,
"avg_acquire_ms", avgAcquireMs,
}
if emptyDelta > 0 || canceledDelta > 0 {
slog.Warn("db pool pressure", fields...)
} else {
slog.Info("db pool stats", fields...)
}
lastEmpty = s.EmptyAcquireCount()
lastAcquire = s.AcquireCount()
lastAcquireDur = s.AcquireDuration()
lastCanceled = s.CanceledAcquireCount()
}
}
// samplerMaxConns is the cap for the dedicated /metrics sampler pool. The
// sampler issues at most one acquire per scrape and Prometheus typically
// scrapes once per 15s — two connections is plenty for two replicas
// scraping in parallel without ever competing with the main pool.
const samplerMaxConns int32 = 2
// newSamplerDBPool builds a tiny pgxpool aimed exclusively at the
// BusinessSamplerCollector. Keeping it isolated from the main pool means a
// stalled sampler scrape can never starve business traffic — the worst
// case is the next /metrics returning stale numbers, which is exactly the
// safety contract documented on BusinessSamplerOptions.
//
// The pool is built from the same DATABASE_URL as the main pool so it
// hits the same database; the sizing knobs (DATABASE_MAX_CONNS et al.) on
// the main pool are intentionally NOT honored here. A sampler that grew
// to 25 connections per replica during an incident would defeat the
// purpose of running it on a separate pool in the first place.
func newSamplerDBPool(ctx context.Context, dbURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dbURL)
if err != nil {
return nil, fmt.Errorf("parse database url for sampler: %w", err)
}
cfg.MaxConns = samplerMaxConns
cfg.MinConns = 0
// A dedicated short-lived idle window — the sampler runs every
// scrape (~15s) so connections shouldn't sit warm forever.
cfg.MaxConnIdleTime = 5 * time.Minute
return pgxpool.NewWithConfig(ctx, cfg)
}