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":'