Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
58 changes: 58 additions & 0 deletions documentation/en/src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion frontend/dist/.source-hash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
c8bfd5e0b4dac8f2ea1c9087b2da622babd97e9e6147fbbd82e0ae2cbd5d15f7
6fa4a154951b9f32e12061b06973d3d5b253db63daff38b1835d44c33d31407e
Binary file added frontend/dist/assets/index-BVfh1LHM.css.gz
Binary file not shown.
Binary file added frontend/dist/assets/index-BYU6T1v2.js.gz
Binary file not shown.
Binary file removed frontend/dist/assets/index-DaKqKAO1.css.gz
Binary file not shown.
Binary file removed frontend/dist/assets/index-Dg2wtyD8.js.gz
Binary file not shown.
Binary file modified frontend/dist/index.html.gz
Binary file not shown.
34 changes: 27 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -83,20 +85,38 @@ function AppMain() {
<ThemeProvider>
<AdminAuthProvider>
<BrowserRouter>
<div className="flex min-h-screen bg-bg text-text">
<Sidebar />
<RoutedShell />
</div>
<CommandPalette />
<HelpModal />
<AppToaster />
<Shell />
</BrowserRouter>
</AdminAuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}

/**
* 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 (
<>
<div className="flex min-h-screen flex-col bg-bg text-text">
<LifecycleBanner />
<div className="flex min-h-0 flex-1">
<Sidebar />
<RoutedShell />
</div>
</div>
<CommandPalette />
<HelpModal />
<AppToaster />
</>
);
}

function AppToaster() {
const { resolved } = useTheme();
return (
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/components/LifecycleBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<OverviewDto>("/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 (
<Bar
kind="error"
text={`pg_doorman unreachable — last contact ${formatAgo(lastContactMs)}.`}
/>
);
}

// 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 (
<Bar
kind="error"
text={`Config reload rejected: ${validationError.message}`}
/>
);
}

if (data?.shutdown_in_progress) {
return (
<Bar
kind="shutdown"
text="pg_doorman is draining — new client connections may be refused while open transactions wind down."
/>
);
}
if (data?.migration_in_progress) {
return (
<Bar
kind="migration"
text="Binary upgrade in progress — clients are migrating to the new process."
/>
);
}
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 (
<div
role="status"
className={`w-full border-b px-4 py-2 text-sm font-mono ${cls}`}
>
{text}
</div>
);
}

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`;
}
24 changes: 16 additions & 8 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand Down
Loading
Loading