diff --git a/Cargo.lock b/Cargo.lock index 31ebfaa47..9ae7b1d17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1900,7 +1900,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pg_doorman" -version = "3.9.1" +version = "3.10.0" dependencies = [ "ahash", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index efe811b98..d7b92d138 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pg_doorman" -version = "3.9.1" +version = "3.10.0" edition = "2021" rust-version = "1.87.0" license = "MIT" diff --git a/documentation/en/src/changelog.md b/documentation/en/src/changelog.md index 3e38c5eb1..55a867dca 100644 --- a/documentation/en/src/changelog.md +++ b/documentation/en/src/changelog.md @@ -60,6 +60,64 @@ Unsafe values that the cache will silently freeze: The ratio `cache_total / (cache_total + backend_total)` is the cache hit rate. +#### Eviction visibility for prepared-statement caches + +Per-eviction events from the named and anonymous query interner and +from the per-client anonymous LRU are now emitted as `TRACE` log +lines. The default `INFO` level is unchanged; turn them on at +runtime with + +``` +SET log_level = 'info,pg_doorman::server::prepared_statement_cache=trace,pg_doorman::client::protocol=trace'; +``` + +The GC sweep task additionally emits one `DEBUG` aggregate line per +cycle that actually evicted something. Operators that previously had +only the aggregate `pg_doorman_query_interner_evictions_total` and +`pg_doorman_clients_prepared_anonymous_evictions_total` Prometheus +counters can now follow individual evictions during an incident. + +The 80-char-with-ellipsis and 120-char preview helpers used in those +log lines live in a new `utils::strings` module and replace three +inline copies that had drifted apart. + +#### Web UI lifecycle events + +The sidebar used to toast "pg_doorman restarted — rate baseline reset" +on every routine RELOAD. Totals are summed across the live pool set, +and RELOAD plus dynamic-pool GC drop pools from that set, so the sum +legitimately falls without the process going anywhere. The heuristic +is gone. A real restart is detected by a change in `pid`, +`started_at_ms`, or `uptime_seconds`. + +`/api/events` grows two new event targets: + +- `PROCESS_START` — emitted once when setup finishes; carries the + binary version and pid. +- `CONFIG_VALIDATION_ERROR` — emitted when SIGHUP, admin RELOAD, or + `/api/admin/reload` rejects the new config. Rate-limited to one + per second per target so a SIGHUP loop with a bad config cannot + fill the 1024-entry ring with duplicates. + +A persistent banner across the top of the UI replaces the transient +toast for conditions an operator must not miss: + +- `shutdown_in_progress` — pg_doorman is draining. +- `migration_in_progress` — binary upgrade in flight. +- Last unresolved `CONFIG_VALIDATION_ERROR` — stays up until a + successful `RELOAD` clears it. +- `/api/overview` silent for >15 s — banner switches to + "pg_doorman unreachable — last contact 23s ago", so the operator + knows the rest of the page is no longer trustworthy. + +A no-op SIGHUP (config file re-parsed identically) now emits a +`RELOAD` entry with message `config unchanged` instead of going +silent — one event per signal keeps the audit timeline complete. + +`/api/events` and `/api/overview` send `Cache-Control: no-store` so +intermediate proxies cannot collapse two consecutive polls into the +same response. + ### 3.9.1 Web admin console refresh and a follow-up pass on `startup_parameters`. diff --git a/frontend/dist/.source-hash b/frontend/dist/.source-hash index 499933def..0d74b398d 100644 --- a/frontend/dist/.source-hash +++ b/frontend/dist/.source-hash @@ -1 +1 @@ -c8bfd5e0b4dac8f2ea1c9087b2da622babd97e9e6147fbbd82e0ae2cbd5d15f7 +6fa4a154951b9f32e12061b06973d3d5b253db63daff38b1835d44c33d31407e diff --git a/frontend/dist/assets/index-BVfh1LHM.css.gz b/frontend/dist/assets/index-BVfh1LHM.css.gz new file mode 100644 index 000000000..34f359ae5 Binary files /dev/null and b/frontend/dist/assets/index-BVfh1LHM.css.gz differ diff --git a/frontend/dist/assets/index-BYU6T1v2.js.gz b/frontend/dist/assets/index-BYU6T1v2.js.gz new file mode 100644 index 000000000..ab40cd6e3 Binary files /dev/null and b/frontend/dist/assets/index-BYU6T1v2.js.gz differ diff --git a/frontend/dist/assets/index-DaKqKAO1.css.gz b/frontend/dist/assets/index-DaKqKAO1.css.gz deleted file mode 100644 index 0d24b70db..000000000 Binary files a/frontend/dist/assets/index-DaKqKAO1.css.gz and /dev/null differ diff --git a/frontend/dist/assets/index-Dg2wtyD8.js.gz b/frontend/dist/assets/index-Dg2wtyD8.js.gz deleted file mode 100644 index ebbd8338a..000000000 Binary files a/frontend/dist/assets/index-Dg2wtyD8.js.gz and /dev/null differ diff --git a/frontend/dist/index.html.gz b/frontend/dist/index.html.gz index 1800d71e9..06ba92103 100644 Binary files a/frontend/dist/index.html.gz and b/frontend/dist/index.html.gz differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa6806751..c48a18ec7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,10 @@ import { Toaster } from "sonner"; import { AuthGate } from "./components/AuthGate"; import { CommandPalette } from "./components/CommandPalette"; import { HelpModal } from "./components/HelpModal"; +import { LifecycleBanner } from "./components/LifecycleBanner"; import { Sidebar } from "./components/Sidebar"; import { SilentCallback } from "./components/SilentCallback"; +import { useLifecycleEvents } from "./hooks/useLifecycleEvents"; import { AdminAuthProvider } from "./hooks/useAdminAuth"; import { ThemeProvider, useTheme } from "./hooks/useTheme"; import Overview from "./pages/Overview"; @@ -83,13 +85,7 @@ function AppMain() { -
- - -
- - - +
@@ -97,6 +93,30 @@ function AppMain() { ); } +/** + * Inner shell. Splits out from `AppMain` because it needs to be inside + * `AdminAuthProvider` to read `authHeader` from the lifecycle-events + * hook — that hook polls `/api/events`, which goes through the same + * auth path as every other UI request. + */ +function Shell() { + useLifecycleEvents(); + return ( + <> +
+ +
+ + +
+
+ + + + + ); +} + function AppToaster() { const { resolved } = useTheme(); return ( diff --git a/frontend/src/components/LifecycleBanner.tsx b/frontend/src/components/LifecycleBanner.tsx new file mode 100644 index 000000000..38ea0b5fd --- /dev/null +++ b/frontend/src/components/LifecycleBanner.tsx @@ -0,0 +1,111 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../api"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { useValidationErrorState } from "../hooks/useLifecycleEvents"; +import type { OverviewDto } from "../types"; + +/** + * Persistent banner for lifecycle conditions an operator must not miss: + * + * 1. Config validation error — the last deploy step rejected the new + * config; the live config is whatever pg_doorman loaded before. The + * banner stays up until a successful RELOAD lands. + * 2. Shutdown — pg_doorman is draining, no new transactions. + * 3. Migration — binary upgrade in progress, clients moving to the + * new process. + * 4. Unreachable — `/api/overview` has not answered for ~15 s. The UI + * is talking to a dead pooler; do not trust the rest of the page. + * + * Banners are persistent on purpose: toasts vanish in a few seconds + * and miss operators who alt-tabbed to a terminal to act on the alert. + */ + +const POLL_MS = 5_000; +const STALE_MS = 15_000; + +export function LifecycleBanner() { + const { authHeader } = useAdminAuth(); + // Share the `/api/overview` cache with Sidebar — same queryKey means + // TanStack Query deduplicates the HTTP call, so two banners-worth of + // polling do not show up on the wire. + const { data, dataUpdatedAt, isError } = useQuery({ + queryKey: ["sidebar.overview", authHeader], + queryFn: ({ signal }) => + apiGet("/api/overview", authHeader, signal), + refetchInterval: POLL_MS, + }); + const validationError = useValidationErrorState(); + + // Unreachable: either the last successful fetch is older than the + // staleness threshold or the most recent attempt errored. Both + // are operator-visible "the pooler is not talking" signals. + const lastContactMs = dataUpdatedAt > 0 ? Date.now() - dataUpdatedAt : 0; + const unreachable = + (!!data && lastContactMs > STALE_MS) || (isError && !data); + if (unreachable) { + return ( + + ); + } + + // Validation error sticks until a successful RELOAD lands. The + // backend rate-limits the event push to 1/sec, so even a SIGHUP loop + // produces a single visible banner instead of stacking entries. + if (validationError) { + return ( + + ); + } + + if (data?.shutdown_in_progress) { + return ( + + ); + } + if (data?.migration_in_progress) { + return ( + + ); + } + return null; +} + +type BarKind = "shutdown" | "migration" | "error"; + +function Bar({ kind, text }: { kind: BarKind; text: string }) { + // Amber for shutdown (operator-impacting). Red for error states + // (validation reject, unreachable). Accent for migration (info). + const cls = + kind === "error" + ? "bg-danger/15 text-danger border-danger/40" + : kind === "shutdown" + ? "bg-warning/15 text-warning border-warning/40" + : "bg-accent/15 text-accent border-accent/40"; + return ( +
+ {text} +
+ ); +} + +function formatAgo(ms: number): string { + if (ms < 1_000) return "just now"; + if (ms < 60_000) return `${Math.round(ms / 1_000)}s ago`; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`; + return `${Math.round(ms / 3_600_000)}h ago`; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index bb2b1fb5f..6bf15fe36 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -14,9 +14,9 @@ import { type LucideIcon, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; import { apiGet } from "../api"; import { useAdminAuth } from "../hooks/useAdminAuth"; +import { useProcessIdentityToast } from "../hooks/useProcessIdentity"; import { fmtRate, fmtUptime } from "../lib/format"; import { getSsoTokenUsername } from "../lib/jwt"; import { ThemeToggle } from "./ThemeToggle"; @@ -155,6 +155,12 @@ export function Sidebar() { // Derive QPS / errors-per-second from the previous snapshot whenever // /api/overview returns. Persisted prevRef survives mounts so the // very first response after a page change immediately yields a rate. + // + // Counter rollback alone is NOT a restart signal: RELOAD and dynamic + // pool GC drop pools from `pool_lookup`, which is what the backend + // sums to produce query_count_total — so totals legitimately fall + // without the process going anywhere. Real restart detection lives + // in useProcessIdentity() and is fed by pid + started_at_ms. useEffect(() => { if (!overview) return; const cur: PrevTotals = { @@ -167,23 +173,25 @@ export function Sidebar() { const dt = (cur.ts - prev.ts) / 1000; const counterReset = cur.queries < prev.queries || cur.errors < prev.errors; - if (counterReset) { - // pg_doorman restarted between polls — counters rolled back to - // zero. Showing "0 qps" would mislead the operator into thinking - // traffic stopped; keep the previous rate visible and pick up a - // real delta on the next tick against the new baseline below. - toast.info("pg_doorman restarted — rate baseline reset"); - } else if (dt > 0 && dt < 60) { + if (!counterReset && dt > 0 && dt < 60) { setRate({ qps: Math.max(0, (cur.queries - prev.queries) / dt), errsPerSec: Math.max(0, (cur.errors - prev.errors) / dt), }); } + // counterReset = drop a tick rather than show a fake spike; the + // next /api/overview poll establishes a fresh baseline against + // the post-RELOAD pool set. } prevRef.current = cur; savePrevTotals(cur); }, [overview]); + // Identity-based restart detection — toast once per real restart, where + // "real" means pid or started_at_ms moved. Counter behaviour is + // ignored here on purpose. + useProcessIdentityToast(overview); + const health = useMemo(() => { if (!overview || !pools) return null; // The sidebar is an ambient indicator; pool history for the threshold diff --git a/frontend/src/hooks/useLifecycleEvents.ts b/frontend/src/hooks/useLifecycleEvents.ts new file mode 100644 index 000000000..4d70f6f8f --- /dev/null +++ b/frontend/src/hooks/useLifecycleEvents.ts @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../api"; +import { useAdminAuth } from "./useAdminAuth"; +import type { EventEntryDto, EventsDto } from "../types"; + +/** + * Incremental poll of `/api/events`. Tracks the most recent + * CONFIG_VALIDATION_ERROR so the LifecycleBanner can surface it as a + * persistent strip until a successful RELOAD clears it — toasts are too + * easy to miss when the operator alt-tabs to a terminal to investigate. + * + * The events ring (`src/admin/events.rs`) is also the source for the + * timeline annotations on Overview/Wall, so this hook does not own the + * data — it only decides which entries warrant an interruption. + */ + +const POLL_MS = 3_000; +const MAX_FETCH = 50; + +export interface ValidationErrorState { + /** Banner text — full event message, as pushed by the backend. */ + message: string; + /** Wall-clock ms of the rejection, for relative-time rendering. */ + ts_ms: number; + /** seq of the event, used to dedupe repeats of the same rejection. */ + seq: number; +} + +/** Last unresolved CONFIG_VALIDATION_ERROR, or null when cleared. */ +let validationListeners: Array<(s: ValidationErrorState | null) => void> = []; +let currentValidationError: ValidationErrorState | null = null; + +function emitValidationError(next: ValidationErrorState | null) { + currentValidationError = next; + for (const fn of validationListeners) fn(next); +} + +/** + * React hook for components that want to render the current validation + * error (LifecycleBanner). Returns the last unresolved CONFIG_VALIDATION_ERROR + * or `null`. Updates when useLifecycleEvents observes a new event. + */ +export function useValidationErrorState(): ValidationErrorState | null { + const [state, setState] = useState( + currentValidationError, + ); + useEffect(() => { + const fn = (s: ValidationErrorState | null) => setState(s); + validationListeners.push(fn); + return () => { + validationListeners = validationListeners.filter((x) => x !== fn); + }; + }, []); + return state; +} + +export function useLifecycleEvents() { + const { authHeader } = useAdminAuth(); + // Two-state baseline: `null` = the very first poll has not landed yet, + // we cannot tell what is new vs. retained, so suppress everything. + // `number` = we have a known cursor, anything strictly above it is + // a fresh event to react to. + const sinceRef = useRef(null); + + const { data } = useQuery({ + queryKey: ["lifecycle-events", authHeader], + queryFn: ({ signal }) => + apiGet( + `/api/events?since=${sinceRef.current ?? 0}&max=${MAX_FETCH}`, + authHeader, + signal, + ), + refetchInterval: POLL_MS, + }); + + useEffect(() => { + if (!data) return; + const baseline = sinceRef.current; + if (baseline === null) { + // First successful poll — adopt next_seq as the baseline without + // notifying on any event that arrived with it. The banner *does* + // pick up a still-open CONFIG_VALIDATION_ERROR from the initial + // batch though, so an operator opening the UI after the failure + // still sees the message. + sinceRef.current = data.next_seq; + seedValidationError(data.events); + return; + } + for (const ev of data.events) { + if (ev.seq <= baseline) continue; + observe(ev); + } + sinceRef.current = Math.max(baseline, data.next_seq); + }, [data]); +} + +/** + * On the very first poll: scan the batch for the latest still-open + * CONFIG_VALIDATION_ERROR (no successful RELOAD after it). That is the + * one the banner should keep visible — an operator who joined late + * still needs to know the deploy step rejected. + */ +function seedValidationError(events: EventEntryDto[]) { + let latestError: ValidationErrorState | null = null; + for (const ev of events) { + if (ev.target === "CONFIG_VALIDATION_ERROR") { + latestError = { message: ev.message, ts_ms: ev.ts_ms, seq: ev.seq }; + } else if (ev.target === "RELOAD") { + latestError = null; + } + } + if (latestError) emitValidationError(latestError); +} + +function observe(ev: EventEntryDto) { + if (ev.target === "CONFIG_VALIDATION_ERROR") { + emitValidationError({ + message: ev.message, + ts_ms: ev.ts_ms, + seq: ev.seq, + }); + } else if (ev.target === "RELOAD") { + // A successful RELOAD landed after the validation failure — the + // operator's next edit took effect, banner should clear. + if (currentValidationError) emitValidationError(null); + } +} diff --git a/frontend/src/hooks/useProcessIdentity.ts b/frontend/src/hooks/useProcessIdentity.ts new file mode 100644 index 000000000..123e1e91e --- /dev/null +++ b/frontend/src/hooks/useProcessIdentity.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import type { OverviewDto } from "../types"; + +/** + * Identity-based restart detection. The previous heuristic — "counter + * total fell, must be a restart" — false-positived on every RELOAD that + * dropped dynamic pools, because the backend sums query_count_total + * across the live pool set. pid plus started_at_ms is the source of + * truth: if either moves between polls, the pooler restarted; if + * neither moves, no toast even when counters legitimately decrease. + */ + +interface ProcessIdentity { + pid: number; + started_at_ms: number; + uptime_seconds: number; +} + +// Host-scoped so two pooler tabs do not overwrite each other's last +// known identity. +const KEY = `pgdoorman.proc.identity.${ + typeof window !== "undefined" ? window.location.host : "any" +}`; + +function load(): ProcessIdentity | null { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.pid !== "number" || + typeof parsed.started_at_ms !== "number" || + typeof parsed.uptime_seconds !== "number" + ) { + return null; + } + return parsed as ProcessIdentity; + } catch { + return null; + } +} + +function save(v: ProcessIdentity) { + try { + localStorage.setItem(KEY, JSON.stringify(v)); + } catch { + /* private mode / quota — no-op. */ + } +} + +/** + * Compare the current process identity against the one cached from the + * previous poll. Returns `true` exactly once per real restart so the + * caller can toast or annotate. Returns `false` when: + * - `cur` is the same identity as `prev` (steady state), + * - `prev` is null (first poll after a fresh tab load — nothing to + * compare against; we accept the current identity as the baseline + * without notifying). + * + * Three independent signals because PID recycle is a real risk on + * long-running hosts (Linux `kernel.pid_max` defaults to 32 768) and + * STARTED_AT is captured lazily on first read, so a back-to-back + * restart can in principle yield identical pid + started_at_ms tuples. + * `uptime_seconds < prev.uptime_seconds` closes that loophole. + */ +export function isRealRestart( + prev: ProcessIdentity | null, + cur: ProcessIdentity, +): boolean { + if (!prev) return false; + return ( + prev.pid !== cur.pid || + prev.started_at_ms !== cur.started_at_ms || + cur.uptime_seconds < prev.uptime_seconds + ); +} + +/** + * React hook variant. Subscribes to an `/api/overview` snapshot and + * toasts once when identity moves. Persists the identity across page + * navigations so an operator switching tabs does not re-trigger the + * toast on the next mount. + */ +export function useProcessIdentityToast(overview: OverviewDto | null) { + const prevRef = useRef(load()); + useEffect(() => { + if (!overview) return; + const cur: ProcessIdentity = { + pid: overview.pid, + started_at_ms: overview.started_at_ms, + uptime_seconds: overview.uptime_seconds, + }; + if (isRealRestart(prevRef.current, cur)) { + const prev = prevRef.current!; + toast.info( + `pg_doorman restarted (pid ${prev.pid} → ${cur.pid})`, + ); + } + prevRef.current = cur; + save(cur); + }, [overview]); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dffac2c5a..093a33851 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -31,6 +31,10 @@ export interface OverviewDto { // Process tile fields (added in the backend P0 inventory wedge). rss_bytes: number; uptime_seconds: number; + // Unix milliseconds the process started at. Pair with `pid` for + // identity-based restart detection — a change in either field is + // a real restart, counter rollback alone is not. + started_at_ms: number; pid: number; current_clients: number; clients_in_transactions: number; @@ -120,7 +124,10 @@ export type Severity = "ok" | "degraded" | "critical"; export interface EventEntryDto { seq: number; ts_ms: number; - // RELOAD, PAUSE, RESUME, RECONNECT (admin commands). + // Open enum on the wire — treat anything outside the known set as a + // neutral target so an older UI build keeps rendering future events. + // Known: PROCESS_START, RELOAD, PAUSE, RESUME, RECONNECT, + // CONFIG_VALIDATION_ERROR. target: string; message: string; } diff --git a/src/admin/commands.rs b/src/admin/commands.rs index ae61efa7d..9cf139ae7 100644 --- a/src/admin/commands.rs +++ b/src/admin/commands.rs @@ -20,8 +20,24 @@ where { info!("Reloading config"); - reload_config(client_server_map).await?; - crate::admin::events::push_event("RELOAD", "config reloaded".to_string()); + // Surface validation errors on the events ring so the Web UI shows a + // CONFIG_VALIDATION_ERROR annotation alongside the error returned to + // psql — the operator at the admin protocol gets the SQL error + // immediately, but the operator watching the UI is the one who + // usually needs the visual breadcrumb when the deploy step quietly + // fails. + match reload_config(client_server_map).await { + Ok(_) => { + crate::admin::events::push_event("RELOAD", "config reloaded".to_string()); + } + Err(e) => { + crate::admin::events::push_event_rate_limited( + "CONFIG_VALIDATION_ERROR", + format!("admin RELOAD rejected: {e}"), + ); + return Err(e); + } + } get_config().show(); diff --git a/src/admin/events.rs b/src/admin/events.rs index 504ce1cf4..a3d45f71f 100644 --- a/src/admin/events.rs +++ b/src/admin/events.rs @@ -1,16 +1,28 @@ -//! Bounded ring buffer of admin command events (RELOAD, PAUSE, RESUME, -//! RECONNECT). Frontend reads via `/api/events?since=&max=` to -//! render vertical-line annotations on Overview graphs. +//! Bounded ring buffer of lifecycle events. Frontend reads via +//! `/api/events?since=&max=` to render vertical-line annotations +//! on Overview/Wall graphs and to drive the restart/reload notifications +//! in the sidebar. Targets currently emitted: +//! +//! * `PROCESS_START` — pushed once when the binary finishes setup. +//! Carries pid and version so a fresh tab opening the UI can +//! reconcile its cached identity without waiting for the first +//! `/api/overview` poll. +//! * `RELOAD` / `PAUSE` / `RESUME` / `RECONNECT` — admin commands +//! (`src/admin/commands.rs`, `src/admin/operations.rs`). +//! * `CONFIG_VALIDATION_ERROR` — pushed when a config reload (admin +//! RELOAD or `SIGHUP`) is rejected by `Config::validate`. Carries +//! the validator's message so the operator sees *why* the new +//! config did not take effect. //! //! This module is intentionally simple: a single Mutex, no //! lock-free fancy. Admin commands fire on the order of one per few //! minutes per cluster; contention is negligible. Reads come from a //! handful of Web UI operators per hour. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; /// Maximum number of events retained in the ring. At one admin command /// every few minutes this is well over a day of history; oldest events @@ -23,7 +35,11 @@ pub struct EventEntry { pub seq: u64, /// Wall-clock timestamp in milliseconds since unix epoch. pub ts_ms: u64, - /// One of `"RELOAD"`, `"PAUSE"`, `"RESUME"`, `"RECONNECT"`. + /// One of `"PROCESS_START"`, `"RELOAD"`, `"PAUSE"`, `"RESUME"`, + /// `"RECONNECT"`, `"CONFIG_VALIDATION_ERROR"`. Treat as an open enum: + /// the frontend maps unknown targets to a neutral chip rather than + /// failing, so adding a new target on the backend never breaks an + /// older UI build. pub target: &'static str, /// Human-readable description of the event (e.g. `"pool main@db1 paused"`). pub message: String, @@ -60,6 +76,30 @@ pub fn push_event(target: &'static str, message: String) { } } +/// Rate-limited variant of [`push_event`]: at most one entry per target +/// per second. Use this for events whose source is operator-driven and +/// could be unbounded (`CONFIG_VALIDATION_ERROR` on a SIGHUP loop with +/// a malformed config). Drops silently when the budget is exhausted; +/// the operator still sees the first event and reads the underlying +/// error from the regular log. +pub fn push_event_rate_limited(target: &'static str, message: String) { + static LAST: OnceLock>> = OnceLock::new(); + let map = LAST.get_or_init(|| Mutex::new(HashMap::new())); + let now = Instant::now(); + let mut guard = match map.lock() { + Ok(g) => g, + Err(_) => return, + }; + if let Some(last) = guard.get(target) { + if now.duration_since(*last) < Duration::from_secs(1) { + return; + } + } + guard.insert(target, now); + drop(guard); + push_event(target, message); +} + /// Returns all events with `seq > since`, capped at `max` entries, plus the /// next sequence number an operator should poll with. Empty when no events /// have been pushed. @@ -146,4 +186,37 @@ mod tests { // Earliest 50 dropped — first surviving event is index 50 (message "event 50"). assert_eq!(events[0].message, "event 50"); } + + /// A burst of rate-limited pushes within the 1 s window collapses + /// to a single entry — the defence against a SIGHUP loop flooding + /// the ring with CONFIG_VALIDATION_ERROR entries. Independent + /// targets are not throttled against each other. + #[test] + fn rate_limited_collapses_burst_per_target() { + let _g = lock(); + drain(); + for i in 0..10 { + push_event_rate_limited("CONFIG_VALIDATION_ERROR", format!("err {i}")); + } + // Different target, same burst — must not be suppressed by the + // first target's budget. + push_event_rate_limited("RELOAD", "ok".into()); + let (events, _) = get_events_since(0, 100); + let validation: Vec<_> = events + .iter() + .filter(|e| e.target == "CONFIG_VALIDATION_ERROR") + .collect(); + let reload: Vec<_> = events.iter().filter(|e| e.target == "RELOAD").collect(); + assert_eq!( + validation.len(), + 1, + "rate limit must collapse same-target burst" + ); + assert_eq!(validation[0].message, "err 0"); + assert_eq!( + reload.len(), + 1, + "different target must not share the validation budget" + ); + } } diff --git a/src/admin/operations.rs b/src/admin/operations.rs index dff6867f3..e968b1acc 100644 --- a/src/admin/operations.rs +++ b/src/admin/operations.rs @@ -66,7 +66,16 @@ pub async fn reload_now() -> Result { let csm = get_client_server_map() .ok_or_else(|| Error::SocketError("client_server_map not initialised".into()))?; info!("Reloading config (via /api/admin/reload)"); - let changed = reload_config(csm).await?; + let changed = match reload_config(csm).await { + Ok(c) => c, + Err(e) => { + crate::admin::events::push_event_rate_limited( + "CONFIG_VALIDATION_ERROR", + format!("/api/admin/reload rejected: {e}"), + ); + return Err(e); + } + }; crate::admin::events::push_event("RELOAD", "config reloaded".to_string()); crate::config::get_config().show(); Ok(changed) diff --git a/src/app/server.rs b/src/app/server.rs index f06d785ae..4732c2790 100644 --- a/src/app/server.rs +++ b/src/app/server.rs @@ -56,6 +56,18 @@ pub static MIGRATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false); pub static STARTED_AT: std::sync::LazyLock = std::sync::LazyLock::new(std::time::SystemTime::now); +/// `STARTED_AT` rendered as milliseconds since the Unix epoch. Mirrors +/// the wall-clock format the Web UI consumes via `OverviewDto.started_at_ms` +/// and `ProcessDto.started_at_ms`; computing it once and sharing the +/// `LazyLock` keeps the two endpoints in lockstep without repeating the +/// `duration_since(UNIX_EPOCH)` call on every poll. +pub static STARTED_AT_MS: std::sync::LazyLock = std::sync::LazyLock::new(|| { + STARTED_AT + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +}); + /// Channel sender for migration payloads. Set once when migration starts. pub static MIGRATION_TX: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -309,6 +321,22 @@ pub fn run_server(args: Args, config: Config) -> Result<(), Box Result<(), Box { info!("Reloading config"); - _ = reload_config(client_server_map.clone()).await; + match reload_config(client_server_map.clone()).await { + Ok(true) => { + crate::admin::events::push_event("RELOAD", "config reloaded (SIGHUP)".to_string()); + } + Ok(false) => { + // No-op reload — file re-parsed identically. Still + // emit a RELOAD entry with "config unchanged" so + // audit-driven SIGHUP'ing leaves a trace; one + // event per signal is the natural rate. + crate::admin::events::push_event("RELOAD", "config unchanged (SIGHUP)".to_string()); + } + Err(e) => { + error!("Config reload rejected: {e}"); + crate::admin::events::push_event_rate_limited( + "CONFIG_VALIDATION_ERROR", + format!("SIGHUP reload rejected: {e}"), + ); + } + } get_config().show(); }, diff --git a/src/web/routes/collect/overview.rs b/src/web/routes/collect/overview.rs index 229b8164d..a84088d51 100644 --- a/src/web/routes/collect/overview.rs +++ b/src/web/routes/collect/overview.rs @@ -3,7 +3,7 @@ use std::sync::atomic::Ordering; use crate::app::server::{ CLIENTS_IN_TRANSACTIONS, CURRENT_CLIENT_COUNT, MIGRATION_IN_PROGRESS, SHUTDOWN_IN_PROGRESS, - STARTED_AT, + STARTED_AT, STARTED_AT_MS, }; use crate::pool::PoolIdentifier; use crate::stats::pool::PoolStats; @@ -96,6 +96,7 @@ pub(crate) fn collect_overview() -> OverviewDto { rss_bytes: get_process_memory_usage(), uptime_seconds: STARTED_AT.elapsed().map(|d| d.as_secs()).unwrap_or(0), + started_at_ms: *STARTED_AT_MS, pid: std::process::id(), current_clients: CURRENT_CLIENT_COUNT.load(Ordering::Relaxed), clients_in_transactions: CLIENTS_IN_TRANSACTIONS.load(Ordering::Relaxed), diff --git a/src/web/routes/collect/process.rs b/src/web/routes/collect/process.rs index f2d5ac94d..68866d3b8 100644 --- a/src/web/routes/collect/process.rs +++ b/src/web/routes/collect/process.rs @@ -15,7 +15,7 @@ use std::sync::atomic::Ordering; use std::sync::LazyLock; -use crate::app::server::STARTED_AT; +use crate::app::server::{STARTED_AT, STARTED_AT_MS}; use crate::web::metrics::system::get_process_memory_usage; use crate::web::routes::dto::{ CgroupMemoryDto, JemallocStatsDto, MemoryBreakdownDto, MemoryCategoryDto, ProcessDto, @@ -37,12 +37,6 @@ static CPU_CORES: LazyLock = LazyLock::new(|| num_cpus::get() as u32); static PID: LazyLock = LazyLock::new(std::process::id); #[cfg(target_os = "linux")] static FD_LIMIT: LazyLock = LazyLock::new(read_linux_fd_limit_uncached); -static STARTED_AT_MS: LazyLock = LazyLock::new(|| { - STARTED_AT - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -}); pub(crate) fn collect_process() -> ProcessDto { let pid = *PID; diff --git a/src/web/routes/dto.rs b/src/web/routes/dto.rs index 222933d56..62b04d491 100644 --- a/src/web/routes/dto.rs +++ b/src/web/routes/dto.rs @@ -57,6 +57,12 @@ pub(crate) struct OverviewDto { pub rss_bytes: u64, /// Seconds since the binary started (`STARTED_AT` lazy in app/server.rs). pub uptime_seconds: u64, + /// Unix milliseconds at which `STARTED_AT` was captured — the same + /// value `ProcessDto.started_at_ms` exposes, mirrored here so the + /// frontend can do identity-based restart detection without paying + /// the extra `/api/process` round-trip on every Overview poll. Pair + /// with `pid` and treat a change of *either* field as a restart. + pub started_at_ms: u64, /// OS process id. Useful when correlating with external tools (`htop`, /// `lsof`, `gdb`) on the same host. pub pid: u32, diff --git a/src/web/routes/events.rs b/src/web/routes/events.rs index 77a4591b5..d074d4456 100644 --- a/src/web/routes/events.rs +++ b/src/web/routes/events.rs @@ -9,7 +9,7 @@ use crate::web::server::Response; pub(crate) fn handle_events(query: &BTreeMap>) -> Response { let since = parse_u64(query, "since", 0); let max = parse_u64(query, "max", 200); - Response::ok_json(&collect_events(since, max)) + Response::ok_json(&collect_events(since, max)).with_header("Cache-Control", "no-store") } #[cfg(test)] diff --git a/src/web/routes/overview.rs b/src/web/routes/overview.rs index 814071d85..cf6db4dda 100644 --- a/src/web/routes/overview.rs +++ b/src/web/routes/overview.rs @@ -4,7 +4,7 @@ use crate::web::routes::collect::collect_overview; use crate::web::server::Response; pub(crate) fn handle_overview() -> Response { - Response::ok_json(&collect_overview()) + Response::ok_json(&collect_overview()).with_header("Cache-Control", "no-store") } #[cfg(test)] diff --git a/src/web/server/wire.rs b/src/web/server/wire.rs index c3057abcc..52c018304 100644 --- a/src/web/server/wire.rs +++ b/src/web/server/wire.rs @@ -250,6 +250,18 @@ impl Response { self } + /// Append (or overwrite) an HTTP header on a Response in flight. + /// Used by live-data endpoints (`/api/overview`, `/api/events`) to + /// pin `Cache-Control: no-store` so intermediate proxies and the + /// browser never collapse two consecutive polls into one stale + /// response. + pub(crate) fn with_header(mut self, name: &'static str, value: &'static str) -> Self { + self.extra_headers + .retain(|(existing, _)| !existing.eq_ignore_ascii_case(name)); + self.extra_headers.push((name, value.into())); + self + } + pub(crate) fn ok_json(value: &T) -> Self { match serde_json::to_vec(value) { Ok(body) => Response { diff --git a/tests/bdd/features/web-ui.feature b/tests/bdd/features/web-ui.feature index 3ae9f53d6..295406649 100644 --- a/tests/bdd/features/web-ui.feature +++ b/tests/bdd/features/web-ui.feature @@ -169,3 +169,27 @@ Feature: Web UI listener And the command output should contain '"key":"web.host"' And the command output should contain '"key":"web.port"' And the command output should contain '"changeable":"no"' + + # /api/events must carry a PROCESS_START entry as the first event after + # boot. The Web UI uses this to draw the "process started" annotation on + # Overview/Wall without waiting for the first admin command. + Scenario: /api/events emits PROCESS_START as the first lifecycle event + When I run shell command: + """ + curl -s --user 'admin:webui_bdd' 'http://127.0.0.1:9127/api/events?since=0&max=10' + """ + Then the command should succeed + And the command output should contain '"target":"PROCESS_START"' + And the command output should contain '"message":"pg_doorman ' + + # /api/overview must carry started_at_ms next to pid so the frontend can + # detect a real restart by comparing identity tuples rather than by + # watching counters fall. + Scenario: /api/overview includes started_at_ms for identity-based restart detection + When I run shell command: + """ + curl -s --user 'admin:webui_bdd' http://127.0.0.1:9127/api/overview + """ + Then the command should succeed + And the command output should contain '"started_at_ms":' + And the command output should contain '"pid":'