Files
multica/server/internal/metrics/business_sampler_pgsleep_test.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

117 lines
4.3 KiB
Go

package metrics
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// TestBusinessSamplerStatementTimeoutCutsHungQuery is the integration test
// that proves the safety net is real. It connects to a live Postgres,
// asks it to `pg_sleep(2)` inside a sampler-style transaction with
// SET LOCAL statement_timeout = '500ms', and asserts:
//
// 1. The query returns in well under the sleep duration (cancelled by
// the server, not by our caller-side context — the SET LOCAL is
// doing the work).
// 2. The Postgres error we caught carries SQLSTATE 57014
// ("query_canceled"). This is the canonical proof of statement_timeout
// firing, and it's the assertion that catches the regression where
// someone deletes the SET LOCAL line and the test would otherwise
// pass on a context-cancellation timeout instead.
// 3. The error counter for that named query advances.
// 4. The duration histogram records the cancellation latency, so
// dashboards can see it happen.
//
// Skips cleanly when no DATABASE_URL is set, mirroring the integration
// test pattern already used in cmd/server. Operators running CI without a
// reachable Postgres see "SKIP", not a failure.
func TestBusinessSamplerStatementTimeoutCutsHungQuery(t *testing.T) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
t.Skip("DATABASE_URL not set; skipping live-Postgres statement_timeout test")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Skipf("could not connect to %s: %v", dbURL, err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
t.Skipf("database not reachable at %s: %v", dbURL, err)
}
c := NewBusinessSamplerCollector(&BusinessSamplerOptions{
Pool: pool,
CacheTTL: time.Second,
QueryTimeout: 500 * time.Millisecond,
})
if c == nil {
t.Fatal("NewBusinessSamplerCollector returned nil for live pool")
}
conn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("acquire conn for hung-query test: %v", err)
}
defer conn.Release()
const queryName = "pg_sleep_canary"
var capturedErr error
start := time.Now()
c.runQuery(ctx, conn, queryName, func(ctx context.Context, tx pgx.Tx) error {
// 2 s is comfortably longer than the 500 ms statement_timeout
// AND the 550 ms outer context deadline, so whichever layer
// fires first we still observe a cancelled query.
_, err := tx.Exec(ctx, "SELECT pg_sleep(2)")
capturedErr = err
return err
})
elapsed := time.Since(start)
// We give a generous upper bound (1.5 s) to absorb local-Postgres
// scheduler jitter and pgx round-trip overhead. The lower bound
// (>250 ms) confirms we *did* hit the timeout rather than the query
// returning instantly because pg_sleep was elided somewhere.
if elapsed >= 1500*time.Millisecond {
t.Fatalf("statement_timeout did not cut the hung query: elapsed %s", elapsed)
}
if elapsed <= 250*time.Millisecond {
t.Fatalf("query returned suspiciously fast (%s); SET LOCAL statement_timeout may not be in force", elapsed)
}
// SQLSTATE 57014 ("query_canceled") is the canonical proof that
// Postgres itself terminated the query because of statement_timeout.
// If a future refactor accidentally drops the SET LOCAL line, the
// query would still get cancelled — but by our caller-side context,
// not by Postgres, and this assertion would catch it.
if capturedErr == nil {
t.Fatal("expected pg_sleep to return an error; got nil")
}
var pgErr *pgconn.PgError
if !errors.As(capturedErr, &pgErr) {
t.Fatalf("expected *pgconn.PgError from pg_sleep cancellation; got %T: %v", capturedErr, capturedErr)
}
if pgErr.Code != "57014" {
t.Fatalf("expected SQLSTATE 57014 (query_canceled); got %q (%s)", pgErr.Code, pgErr.Message)
}
// One labelled error must have been recorded against the named query.
if got := testutil.ToFloat64(c.queryErrors.WithLabelValues(queryName)); got < 1 {
t.Fatalf("query_errors_total{name=%q} = %v, want >= 1", queryName, got)
}
// And one observation must have landed on the duration histogram.
if got := testutil.CollectAndCount(c.queryDuration); got < 1 {
t.Fatalf("query_seconds histogram saw 0 observations after pg_sleep cancellation")
}
}