Skip to content

feat: per-pool startup_parameters (GUC injection) - #246

Merged
vadv merged 72 commits into
masterfrom
feat/startup-parameters
May 12, 2026
Merged

vadv merged 72 commits into
masterfrom
feat/startup-parameters

Conversation

@vadv

@vadv vadv commented May 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add three-level cascade of PostgreSQL startup parameters (GUC injection) that pg_doorman sends to backends in the StartupMessage:

  • general.startup_parameters — baseline for all pools.
  • pools.<name>.startup_parameters — per-pool override.
  • auth_query returned JSON column startup_parameters — per-user override (passthrough mode only).

Resolution: union of all levels, per-key the more specific level wins (auth_query > pool > general).

Motivation

plan_cache_mode = auto can stick to a generic plan after 5 EXECUTEs and become bad for skewed parameters. The blunt fix — ALTER ROLE/DATABASE SET plan_cache_mode = force_custom_plan — affects all workloads on that user/database. Reactive detection via pg_prepared_statements.generic_plans/custom_plans is non-trivial infrastructure. A proactive per-pool GUC mechanism is the architecturally clean answer, also covering work_mem, statement_timeout, lock_timeout, etc.

Key design decision

PG documentation confirms: GUCs delivered via StartupMessage become pg_settings.reset_val. Client RESET ALL / DISCARD ALL does NOT remove them — PG restores them as session defaults. This invariant is the reason wire-method is startup-only (no post-auth SET fallback).

dmitrivasilyev added 9 commits May 11, 2026 20:15
…ation

Adds operator-supplied PostgreSQL GUC injection at config level (general
and per-pool). Field is BTreeMap<String, String>, validated for reserved
keys (user/database/replication/options/_pq_.*), syntax (PG GUC naming
including namespaced like auto_explain.log_min_duration), null-bytes in
values, and overall 15 KiB operator budget within PG's 16 KiB
StartupMessage cap.

No runtime behavior change yet: nothing reads the field. Wire-format
integration and resolution land in subsequent commits on this branch.
The previous commit chose a 15 KiB operator budget anchored on an
incorrect 16 KiB ceiling. PostgreSQL's actual cap is 10 000 bytes
(MAX_STARTUP_PACKET_LENGTH, src/include/libpq/pqcomm.h), enforced as
an anti-DoS measure on every connection. A config that previously
passed validation with parameters totalling 9.5-15 KiB would have been
accepted by pg_doorman and silently rejected on the wire at every
backend startup; the budget now reflects the real ceiling.

Also pulls back operator-visible doc strings in the auto-generated
reference config and on the General/Pool fields. The earlier text
promised that parameters become 'pg_settings.reset_val' and cascade
through auth_query - both true of the eventual feature but neither
shipped by this commit. The docs now describe what is in fact
delivered today: validation at config load.
Extends the StartupMessage builder to carry an additional set of
operator-supplied key/value pairs. Keys are serialized in iteration
order after the required user/application_name/database triple, with
one trailing NUL terminator. When the supplied map contains
application_name, that value wins over the pg_doorman-managed default
(D5/B2 of the design - operator-overrideable).

Wire-format change only: every current caller passes an empty map, so
on-wire output is byte-identical to the prior format. Subsequent
commits on this branch wire the resolver and per-pool quarantine in.
Two leaf modules with no integration yet:

* src/pool/startup_resolver.rs: pure function that merges the
  general/pool/auth_query maps per the D1 cascade (union by key, more
  specific wins, auth_query > pool > general).

* src/server/quarantine.rs: per-pool consecutive-rejection counter with
  TTL release. record_rejection returns Counting/JustQuarantined/
  AlreadyQuarantined so callers can drive logs and metrics; release is
  TTL-only - keys that get quarantined are skipped in the StartupMessage,
  so a later success says nothing about whether the operator-supplied
  value was fixed.

Adds two general-level knobs:
startup_parameter_quarantine_threshold (default 3) and
startup_parameter_quarantine_ttl (default 300000 ms).
Earlier commits left shorthand references like D5/B2, D7, D8 in code
comments and rustdoc. Those identifiers point to a brainstorm document
that is intentionally gitignored, so any reader outside the original
session has no way to resolve them. Comments now describe the rule in
prose: operator-supplied value wins over the pg_doorman-managed default
for application_name; auth_query parameters are dropped in dedicated
mode because one shared backend serves multiple users; quarantine
releases by TTL only because a key that has been skipped offers no
evidence of being fixed.

No behavior change; comment-only.
* messages::protocol::startup() (already taking the operator map since
  the previous commit on this branch) is now called with the cascade-
  resolved map. Before sending, currently-quarantined keys are stripped
  via QuarantineState::filter_active_keys.

* On PG ErrorResponse with sqlstate 22023 / 42704 / 42501 and a non-
  empty operator map, pg_doorman extracts the failing parameter name
  via the literal pattern `parameter "..."` (hand-rolled to keep regex
  in dev-deps) and records the rejection in the pool-scoped
  QuarantineState. After N consecutive rejections of the same key,
  subsequent backend spawns drop that key from the StartupMessage for
  a configurable TTL.

* extract_parameter_name() pure helper added to startup_error.rs with
  unit tests covering unknown / invalid-value / insufficient-privilege /
  namespaced and no-match cases.

Call sites currently pass an empty map and a per-call QuarantineState
constructed from the general-level knobs; the pool-owned shared Arc
ships in the next commit. The now-unused handle_startup_error stays in
the tree behind #[allow(dead_code)] as a reference for the pre-
quarantine behavior; cleanup is left to a follow-up.
The auth_query SQL contract gains an optional text column
'startup_parameters' holding a JSON object: its string-typed entries
become the per-user GUC pg_doorman injects when it opens a backend
on the user's behalf. SQL that does not return the column continues
to work unchanged.

Operator config validation rules (reserved keys, GUC name syntax, no
null bytes) are reused: invalid entries are dropped with a warning,
unparseable JSON yields an empty set with a warning, but auth never
fails because of an operator config error in the query result.

In dedicated mode (auth_query.server_user set, one shared backend
pool under that identity) per-user values are semantically
impossible: pg_doorman drops them with a single warning per pool and
username and lets pool-level or general startup_parameters take over.

The parsed map is stored on the credential cache entry. The next
commit on this branch wires it into the cascade resolver.
ServerPool now owns a single Arc<QuarantineState> initialised from
general.startup_parameter_quarantine_{threshold,ttl}. On every backend
spawn the cascade (general / pool / passthrough auth_query) is resolved
lazily through pool::startup_resolver::resolve(); that lazy contract is
what allows general-level edits to take effect on the next backend
without forcing a pool-hash recycle.

Per-user parameters from auth_query enter the cascade only in
passthrough mode. In dedicated mode the cache layer already clears the
map (with an operator warning); this layer adds defence in depth by
refusing to consult the cache entry at all when the pool is dedicated.

After this commit, an operator config like [pools.app_db.startup_parameters]
plan_cache_mode = "force_custom_plan" actually reaches PG. The
prometheus counters and admin column ship in the next commit.
Comment thread src/auth/auth_query.rs Fixed
Comment thread src/auth/auth_query.rs Dismissed
Comment thread src/auth/auth_query.rs Dismissed
Comment thread src/auth/auth_query.rs Dismissed
Comment thread src/auth/auth_query.rs Dismissed
Comment thread src/server/server_backend.rs Fixed
dmitrivasilyev added 16 commits May 11, 2026 22:55
…alth

* pg_doorman_backend_startup_parameter_errors_total (counter, labels
  pool / parameter / sqlstate) increments on every PG ErrorResponse
  that rejects an operator-supplied parameter at backend startup. The
  failing username is intentionally not a label so that dynamic
  auth_query pools (one Address per validated PG role) cannot blow up
  the series count when many roles use the same broken config; the
  username is in the corresponding warn log line.

* pg_doorman_backend_startup_parameter_quarantined (gauge, labels
  pool / parameter) goes to 1 the moment a key is parked in the pool
  quarantine and back to 0 when its TTL expires.

* SHOW POOLS gains a quarantined_params text column (comma-separated
  list) so operators can see which operator parameters a pool is
  currently dropping from StartupMessage.

Replaces the temporary noop counter wrappers that were left in the
backend startup error path while these vectors were being prepared.
Twelve scenarios exercise the operator-facing surface from this
branch: cascade resolution across general, pool, and auth_query
passthrough levels, reserved-key config rejection, the invalid-GUC
backend startup error path, quarantine engagement after consecutive
rejections, the central invariant that startup defaults survive a
client-side RESET ALL or DISCARD ALL, RELOAD-driven pool recycle
when pool.startup_parameters changes, dedicated-mode auth_query
ignoring per-user values with a single warning, and the
backwards-compatible path where auth_query SQL without the new
column keeps working.

Adds tests/auth_query_startup_params_fixture.sql with two users
(sp_tuned_user with a per-user plan_cache_mode override in the
JSON column, sp_plain_user with a NULL column value) used by the
passthrough cascade, dedicated-mode warning, and baseline
scenarios.
Phase 4 replaced handle_startup_error with an inline branch in
Server::startup that always returned ServerStartupError. The pre-Phase-4
helper specifically mapped SQLSTATE 57P* (cannot_connect_now,
admin_shutdown, crash_shutdown, database_dropped, etc.) to
ServerUnavailableError, and only that error category triggers the
Patroni-assisted fallback path. With the inline branch in place a
backend reporting 57P03 no longer routed clients onto a healthy
candidate; clients saw a startup error.

Restores the classification: 57P* responses turn into
ServerUnavailableError, every other ErrorResponse stays
ServerStartupError. The startup_parameters quarantine and metrics
side-effects still fire for the 22023 / 42704 / 42501 family on the
way through; their evaluation is unrelated to the fallback decision.
Per-level validation in Config::validate caps general.startup_parameters
and pool.startup_parameters separately. Two levels that each fit the
9 488-byte operator budget can together push the resolved cascade past
PG's MAX_STARTUP_PACKET_LENGTH (10 000 bytes), at which point PG would
reject every backend startup. With auth_query in the mix the per-pair
syntax check sees only one entry at a time, so the per-user contribution
to the budget is also invisible to load-time validation.

resolved_startup_parameters now serialises the merged map and compares
against the operator budget after the cascade has run. When the body
would not fit, all operator-supplied keys are dropped for this spawn
and the situation is logged; the backend connects with server defaults
rather than the alternative of failing every connection attempt for
this pool until the operator notices and trims the config.

Adds serialized_bytes() as a pub helper and three unit tests:
per-pair NUL accounting, empty-map zero, and the multi-level overflow
case where each individual level fits but their union does not.
The startup-parameter quarantine fired whenever PG returned a sqlstate
in the 22023 / 42704 / 42501 family and the parsed message named a
parameter, regardless of whether that parameter was in the map
pg_doorman sent. PG can produce those sqlstates for parameters
pg_doorman does not control: an ALTER ROLE SET applied at login, a
server-side default the role is not permitted to change, an internal
extension, and so on. The quarantine and the counter would then record
a rejection for a key that the operator did not configure, and on the
next backend spawn pg_doorman would still try to send its own keys,
producing a confusing mix in SHOW POOLS and metrics.

The handler now cross-references the failing parameter name against
the map it actually sent. If the key is ours, the same quarantine and
metrics path as before runs. If it is not ours, pg_doorman logs an
info line so the situation is visible and falls through to the regular
ServerStartupError path without recording a rejection.
The rustdoc on BACKEND_STARTUP_PARAMETER_ERRORS_TOTAL still listed the
counter as tracked "per pool/user/parameter" from an earlier draft.
The label set is actually pool, parameter, sqlstate (the user was
removed to keep series count bounded when dynamic auth_query pools
mint many roles against the same broken config). Doc comment now
matches the registered label set and notes where the username can be
recovered for incident triage.
The quarantine counter incremented per rejection but never reset on
success, so the threshold model effectively counted "N rejections
ever" rather than "N consecutive rejections" the doc-comment and
naming both promised. On a sea of healthy startups, a handful of
transient failures spread over weeks could still tip a perfectly
working parameter into quarantine.

QuarantineState gains record_success(sent_keys); Server::startup
calls it on ReadyForQuery for every operator-supplied key the spawn
just accepted. The reset only touches keys that are not currently
quarantined: TTL remains the sole release path for already-quarantined
keys, because a quarantined key was by definition not in the sent map
and so a later success says nothing about its underlying problem.
fields.yaml and the regenerated reference configs still promised that
wire injection and the general / pool / auth_query cascade would arrive
in a later commit. Both have shipped: pg_doorman puts the operator
parameters into the StartupMessage on every backend spawn and resolves
the cascade per call. An operator reading the auto-generated reference
would have expected validation-only behaviour and been surprised when
the new GUC actually showed up at PG.

Doc strings now describe the shipped behaviour: cascade order, that
values become pg_settings.reset_val and survive RESET ALL / DISCARD
ALL, dedicated-mode auth_query exclusion, the post-cascade size check
against MAX_STARTUP_PACKET_LENGTH, the quarantine path, and pointers
to SHOW POOLS and the Prometheus counters for observability.
QuarantineState used to read its threshold and TTL once in
ServerPool::new and never again. A SIGHUP that only changed
general.startup_parameter_quarantine_threshold or _ttl had no effect
on any pool that was kept across the reload, because the pool hash
does not include general-level knobs and so the pool was reused as-is.
Operators ended up running with the previous values until pg_doorman
restarted or every pool was independently recycled, which made the
runbook for tuning the quarantine misleading.

QuarantineState now holds the two knobs as atomics. ServerPool exposes
update_quarantine_knobs(threshold, ttl); the reload path pushes the
fresh general-level values into every kept pool before declaring the
pool unchanged. record_rejection reads the current values on every
call.

general.startup_parameters itself is read live from config_arc() on
every backend spawn, so a SIGHUP that only changes those values takes
effect on the next backend spawn for every pool without rebuild. Old
idle backends keep their previous parameters until the regular pool
recycle (server_lifetime / idle_timeout / RECONNECT) replaces them;
this trade-off is intentional and documented in the operator notes.
…awns

Before this change the BACKEND_STARTUP_PARAMETER_QUARANTINED gauge was
only cleared inside Server::startup, when the next backend spawn ran
filter_active_keys() and observed the expired entry. A pool that went
idle right after quarantining a parameter (no further client traffic,
nothing else triggers a spawn) left the gauge stuck at 1 for hours
after the TTL was supposed to release the key, which would keep
dashboards red and pagers loud long after the underlying issue had
resolved.

QuarantineState gains reconcile_expired(), and ServerPool wires it
into the SHOW POOLS / metrics-collection path so every snapshot pass
clears stale series. The reconciliation is a no-op for healthy pools
and a constant-time operation per quarantined key, so calling it on
every snapshot is cheap.
When the SELECT returns a native json or jsonb column named
startup_parameters, tokio_postgres refuses to decode it as text and
pg_doorman silently dropped the per-user parameters with a warn line
that named the decode error but not the fix. Operators writing
jsonb_build_object(...) AS startup_parameters would authenticate
successfully, get an empty per-user map, and then chase the missing
parameter through code paths that had nothing to do with the actual
column type.

The warn line now identifies the actual PostgreSQL type pg_doorman
saw and recommends the smallest possible change to the SQL
(`...::text AS startup_parameters`). The runtime contract stays the
same: the column is read as text; conversion is the operator's
responsibility.
Adds a tutorial covering the three-level cascade (general, per-pool,
auth_query passthrough column), the RESET ALL invariant, validation
at load and at each backend spawn, the quarantine with consecutive
semantics and TTL-only release, and the observability surface
(SHOW POOLS column plus the errors counter and quarantined gauge).
The reference pages are already generated from fields.yaml; this is
a curated walkthrough operators can follow before reaching for the
reference. Both language books get the tutorial, linked from the
Pooling section of SUMMARY.md next to the other per-pool behaviour
articles.
The official postgres image starts an init listener, runs initdb
scripts, then restarts before opening the final listener. pg_isready
returns YES on the init listener, so the next query in the smoke
(creating the smoke_user / smoke_db fixture) sometimes lands during
the restart window and fails with FATAL: the database system is
shutting down. That fail-pattern flaked the dashboard-validation
workflow on every push to this branch with no underlying pg_doorman
defect.

Probe with two consecutive successful psql SELECT 1 runs instead of
one pg_isready: if the first success was on the init listener, the
second probe one second later catches the restart and the loop keeps
waiting. The 90-second ceiling stays the same.
Bumps the package version to mark per-pool startup_parameters as the
shipped feature of this release. The Changelog summarises the cascade,
validation, quarantine, and observability surface introduced on this
branch, and points to the new operator tutorial.

The comparison page gains a row that contrasts the feature with the
closest analogues in other poolers: PgBouncer only ships specific
client-encoding/datestyle/timezone parameters per database via the
connection string, Odyssey's maintain_params preserves client-side
values across rebind but has no operator-side injection, and PgCat
documents no equivalent. pg_doorman is the only one in this set that
lets an operator put arbitrary GUCs into the backend StartupMessage on
a per-pool basis.
The new feature file in tests/bdd/features/startup-parameters.feature
ships with the @startup-parameters tag only. None of the existing BDD
matrix entries pick that tag up, so the twelve end-to-end scenarios
that exercise the feature were never running in CI even after the
file landed. Locally the tag-filtered cargo invocation passes; the
gap was only visible at the workflow level.

Adds a dedicated matrix row that runs the same invocation in CI.
Operator visibility on operator-injected GUCs:

* Admin SQL console: new SHOW STARTUP_PARAMETERS lists the per-pool
  effective merged cascade with the cascade layer that contributed each
  value (general / pool / auth_query) and a quarantined column. The
  entry is in the canonical SHOW_SUBCOMMANDS list, so psql tab
  completion on SHOW <TAB> now offers `startup_parameters`.
* Web UI: /api/pools carries startup_parameters[] (with source per key)
  and quarantined_params[]. PoolDetail renders a "Startup parameters
  (operator-injected)" section that highlights quarantined keys, so an
  operator triaging a misbehaving pool no longer has to drop to psql to
  see which key is being parked.
* BDD: new scenario "admin SHOW STARTUP_PARAMETERS lists the merged
  cascade per pool" under @startup-parameters.

Codex review fixes:

* HIGH #3 (SQLSTATE allowlist too narrow): quarantine now triggers
  whenever the parameter pg_doorman parses out of a PG ErrorResponse
  matches a key it actually sent, instead of restricting to the
  22023 / 42704 / 42501 whitelist. The new heuristic covers 55P02
  (cant_change_runtime_param) and any future or extension-specific
  codes a PG release reports under the startup family. SQLSTATE class
  57P (server unavailable) keeps its dedicated handling so the
  Patroni-assisted fallback path stays unchanged.
* HIGH #8 (final packet size guard checked the pre-final payload):
  validation now reuses the exact wire layout pg_doorman puts on the
  network - length prefix, protocol version, user, application_name,
  database, every operator-supplied pair, terminator - and rejects the
  cascade for one backend spawn when the full packet would exceed PG's
  10000-byte cap. The backend then connects with PostgreSQL defaults
  and the operator sees a warn log naming the actual computed length.

Operator documentation in EN and RU was overhauled to match the
shipped behaviour: the RU startup-parameters tutorial got a full
rewrite, RU reference docs gained the three new knobs, EN+RU
auth-query docs document the new startup_parameters column, and
the PgBouncer/Odyssey comparison rows in both languages now state
the precise distinction (no operator-side injection of startup
parameters into the backend StartupMessage, with the RESET/DISCARD
contract pg_doorman provides). Reference configs and EN reference
docs were regenerated to pick up the operator copy refresh in
src/app/generate/fields.yaml.
Comment thread src/auth/auth_query.rs Dismissed
Comment thread src/server/server_backend.rs Fixed
Comment thread src/pool/server_pool.rs Fixed
…#5/#7/#9

Operator visibility was the right output; the cumulative-rejection
quarantine that sat behind it was the wrong policy. PostgreSQL fails
a connection when StartupMessage carries a parameter it cannot
accept, and that is the contract operators reason about. The
quarantine layer turned an honest PG error into a delayed retry
with a TTL knob and a per-pool gauge — operator sees green tiles,
client gets PG default, real failure stays hidden. This commit
deletes it.

What pg_doorman does on a PG-side startup-parameter rejection now:

* SQLSTATE class 57P (server unavailable) still becomes
  ServerUnavailableError so the Patroni-assisted fallback path
  routes around a failed node before anything else.
* Every other ErrorResponse becomes ServerStartupError; the client
  receives the same sqlstate and message it would have seen
  connecting to PG directly. No silent retry, no per-key park, no
  TTL.
* When the failing parameter matches a key pg_doorman actually
  sent, the warn log line names the parameter, the SQLSTATE, and
  the username, and pg_doorman_backend_startup_parameter_errors_
  total{pool, sqlstate} increments. The parameter name stays out
  of the Prometheus label set so a dynamic auth_query pool cannot
  blow up cardinality.

Other codex review fixes folded into the same commit:

* HIGH #2 — operator startup_parameters now win over client sync.
  Server tracks the operator-injected key set; sync_parameters on
  checkout drops those keys from the diff before issuing SET, so a
  client connect string that carries application_name (or any
  tracked GUC) no longer overrides the operator default on the
  backend.
* HIGH #4 + #5 — RELOAD coherence on the operator baseline. The
  per-pool reuse hash now folds in general.startup_parameters, and
  the from_config path remembers the previous baseline hash so a
  SIGHUP that only edits general-level GUCs also recycles every
  dynamic auth_query pool that carried over the reload. Idle
  backends with the previous reset_val are drained, not stranded.
* HIGH #7 — peek_startup_parameters now consults cache_ttl /
  cache_failure_ttl and returns None for expired entries, so the
  backend-spawn hot path cannot pin a stale per-user GUC after the
  operator updated the auth_query row.
* HIGH #9 — parse_startup_parameters_text rejects raw column text
  larger than the operator budget before serde_json walks it, so a
  pathological auth_query row cannot CPU/heap-spike on every
  refresh.

Admin SHOW STARTUP_PARAMETERS now exports five columns
(user, database, parameter, value, source) instead of six; the
quarantined column is gone with the rest of the machinery. SHOW
POOLS drops the trailing quarantined_params text column. The
Web UI pool detail page renders the same five-column view.

Operator documentation in EN and RU loses the Quarantine sections;
the startup-parameters tutorial now describes the PG-rejection
contract end to end, with the new SHOW STARTUP_PARAMETERS example,
and the comparison rows for PgBouncer/Odyssey were rewritten to
match in both languages. Reference configs (pg_doorman.toml,
pg_doorman.yaml) and EN reference docs were regenerated from
src/app/generate/fields.yaml.
Comment thread src/auth/auth_query.rs Dismissed
Comment thread src/server/server_backend.rs Fixed
dmitrivasilyev added 6 commits May 12, 2026 12:51
Closes codex MED #9.

The doc-comments on backend_startup_parameter_errors_total and
startup_parameters_dropped_total claimed the `pool` label was a
`<user>@<database>` identifier. Emit sites pass `address.pool_name`,
which in the default mapping is the PostgreSQL database name - a
multi-user database collapses into a single series, and the docs led
operators to expect per-user attribution that never existed.

State the actual contract: one series per pool name. Per-user
attribution still lives in the warn log line that accompanies every
counter increment.
Closes codex MED #15.

CacheEntry.startup_parameters was a HashMap<String, String>, so
every cache hit cloned the whole map. For a user whose auth_query
row carries a dozen extension GUCs that is O(map) copies per
connection (cache hit on get_or_fetch, again on every entry.clone()
inside cache invalidation/refetch). The hot path lives under a
DashMap shard read lock; a long clone there extends the lock window
proportionally.

Wrap the field in Arc<HashMap<...>>. Cache hits now do two atomic
increments instead of a deep clone. dedicated_mode_filter drops the
overlay by assigning a fresh empty Arc rather than .clear() — the
existing Arc is shared with whoever already cloned the entry.

Per-user overlay snapshot held on ServerPool is a separate
Arc<BTreeMap>; that path was already zero-clone since the BLOCKER #2
fix. No change there.
…nels

Closes codex MED #8.

The three Row 19 panels selected on `instance, user, database`, but
the underlying counters (pg_doorman_backend_startup_parameter_errors_total,
pg_doorman_startup_parameters_dropped_total) carry only `pool` +
`sqlstate`/`reason` labels. Any non-".*" value on the $user or
$database template variable filtered the result to nothing, so even
when the demo or a real deployment produced traffic the panels
returned empty. The smoke-test allow_empty entry then masked the
broken panel.

Use an instance-only selector `SI` for the three panels and document
the contract in the panel description ("filters on $user/$database
do not apply"). The allow_empty entries stay legitimate for the demo
(it ships no operator startup_parameters) but now the panels fill in
the moment real traffic appears, regardless of the template
variables' state.
…y map

Closes codex MED #12.

SHOW STARTUP_PARAMETERS and /api/pools rendered the configured
cascade view (current general+pool+auth_query) without referencing
the map pg_doorman actually ships in StartupMessage. Two real cases
left a DBA looking at a misleading "active" entry:

- The pool's frozen baseline / per-user overlay snapshot is behind
  the live config (RELOAD has not yet recycled, or the auth_query
  cache has not refetched). The next backend spawn still ships the
  old value.
- The runtime budget/packet check dropped the entry on the most
  recent spawn (cascade_budget_exceeded / packet_cap_exceeded /
  auth_query_overlay_oversize); admin still showed it as configured.

effective_startup_parameters_with_sources now cross-checks every
configured entry against resolved_startup_parameters() and tags it
with an ApplicationState: applied, dropped_due_to_budget, or stale.
SHOW gains a `state` column, /api/pools' StartupParameterDto gains
a `state` field next to `source`. Anonymous /api/pools still hides
`value` per HIGH #5; `state` is operator-facing diagnostics, not
config payload, so it stays in the anonymous view.
Closes codex LOW #16 (triple scan) and LOW #18 (HashSet per spawn).

LOW #16. resolved_startup_parameters previously walked the merged
cascade twice on the hot path - once through serialized_bytes for
the operator budget and again through full_packet_bytes for the PG
packet cap. The new packet_and_body_bytes helper returns both
totals from one pass over the BTreeMap, halving the per-spawn map
walk for the common no-overflow case and also for the
baseline-only retry after auth_query overlay oversize.

LOW #18. Server.operator_managed_startup_keys is now
Arc<HashSet<String>>. Pools that ship no operator parameters - the
overwhelming default - hand every spawn the same static
Arc<HashSet::new()> instead of allocating a fresh empty HashSet
per backend. Non-empty pools still allocate the set per spawn for
now; the caller-side share is a follow-up because it requires
threading the Arc through Server::startup signature.
Closes codex LOW #17.

parse_startup_parameters_text built a one-entry BTreeMap around
every (key, value) pair before passing it to startup_parameters::
validate(), just to reach the per-key/per-value checks inside. For
a wide auth_query row that allocated and dropped one BTreeMap per
key on cache miss / refetch.

Expose validate_entry(&str, &str, &str) on the startup_parameters
module and call it directly on the borrowed JSON pair. The output
HashMap is now also sized to obj.len() up front, so wide rows do
not pay the grow-and-rehash cycle either.
@vadv
vadv marked this pull request as ready for review May 12, 2026 10:11
@vadv vadv changed the title feat: per-pool startup_parameters (GUC injection) — RFC feat: per-pool startup_parameters (GUC injection) May 12, 2026
dmitrivasilyev added 11 commits May 12, 2026 13:41
GitHub-hosted runners share CPU; with 20+ BDD suites fanning out the
timing-sensitive scenarios (SCRAM passthrough reconnect, sleep-based
retain windows) routinely lost their margin and reported a flake of
the form `tls required but server does not support tls` on the
fallback retry. The same scenarios pass deterministically locally and
on master. Cap matrix concurrency at 4 so each suite gets a less
contested runner; total wall-clock grows but each job stops racing
itself off the runner.
Closes codex BLOCKER #1 (fresh review 2026-05-12).

`RESERVED_KEYS` blocked `user`, `database`, `replication`, and `options`
but let `role` and `session_authorization` through. Those two are not
benign session defaults - they change PostgreSQL authorization state
and become `pg_settings.reset_val` for the backend. After an operator
injects `role` through `startup_parameters`, a client `RESET ROLE`
restores the operator-injected role rather than the login role the
pool authenticated under, which breaks the contract that
`startup_parameters` carries only safe defaults.

Add both keys to `RESERVED_KEYS` (case-insensitive comparison is
already in place) and add regression tests for the lowercase and
mixed-case spellings, plus tests against `validate_entry` so the
auth_query JSON path also rejects them.
Closes codex HIGH #4 (fresh review 2026-05-12).

/api/config flattened the active config and only masked keys whose
last segment matched password/secret/token/key. startup_parameters
values did not match that rule, so an anonymous viewer of the SPA
or the public API could read every operator-supplied GUC value:
tenant identifiers, audit routing tags, extension-specific GUC
payloads, and any accidental secret. /api/pools already redacted
this; /api/config slipped through.

Add an is_startup_parameter_key() classifier that matches keys
inside *.startup_parameters.* cascades. Make collect_config and
handle_config role-aware: anonymous viewers see the parameter
keys but receive *** in place of values; SSO and Admin keep the
full view. Both `value` and `default` columns mask.
Closes codex HIGH #3 (fresh review 2026-05-12).

The cold-pool auth path called get_server_parameters() and wrapped
every PoolError into Error::ServerStartupReadParameters(err.to_string()),
destroying the typed Error::ServerStartupParameterRejection that
the create() / fallback paths carefully constructed with the real
PG sqlstate and message. The first client to trigger cold-pool
startup during authentication saw a generic 3D000 / 58000 instead
of the real rejection - contradicting the "PG rejection forwarded
verbatim" contract that the transaction-checkout path already
honours.

get_server_parameters() now special-cases PoolError::Backend
(ServerStartupParameterRejection { .. }) and returns the typed
error unchanged. All three auth callers (static user, dedicated
auth_query, auth_query passthrough) detect the variant before the
generic wrapper and forward sqlstate + message through
error_response(), mirroring src/client/transaction.rs:773.
…drift

Closes codex HIGH #2 (fresh review 2026-05-12).

AuthQueryState reuse only checked the auth_query config and the
pool-level startup_parameters hash. That excluded every other parent
input the dedicated shared pool was built from: server_host,
server_port, TLS material, application_name, timeouts, fallback
settings, general.startup_parameters. A SIGHUP that edited any of
these without touching the auth_query config let the old shared pool
keep serving traffic, so dedicated-mode backends could outlive a
RELOAD with stale TLS identity, stale reset_val, or pointed at the
old backend host.

Capture a parent fingerprint at state construction time:
`pool_config.hash_value() ^ general_startup_hash`. The XOR folds in
host/port/TLS/timeouts/fallback/app_name (covered by Pool's derived
Hash) and the operator-wide baseline. RELOAD compares the fingerprint
alongside the auth_query config and pool-level startup hash; a
mismatch on any of the three drains dynamic pools, drops the cache,
and lets the next auth rebuild the shared pool against the new
parent.
…g halves

Closes codex MED #6 (read-only views must not mutate metrics) and
HIGH #5 (admin view must show wire-only keys).

resolved_startup_parameters did two jobs in one function: it built
the wire-ready map and it logged + incremented
STARTUP_PARAMETERS_DROPPED_TOTAL on overflow. The admin and
/api/pools views both went through this same function, so every
SHOW STARTUP_PARAMETERS row in psql and every SPA poll of
/api/pools could inflate the dropped counter and emit warn lines
without any backend startup actually happening.

Pull a pure classify_startup_parameters() out that returns the
wire-ready Cow plus a BudgetDecision enum. resolved_startup_parameters
becomes a thin wrapper that does the counter inc and the warn log
only for the spawn path; effective_startup_parameters_with_sources
(the admin/API view) calls the classifier directly with zero side
effects.

While we're here, fix HIGH #5: the admin view used to iterate only
the configured cascade. If the pool's frozen baseline / per-user
overlay still ships a key that the live config no longer mentions
(RELOAD has not yet recycled the pool, auth_query cache has not
refetched), that key was invisible. The view now iterates the union
of configured ∪ wire and tags wire-only entries as Stale so the
operator can see "backends are still sending plan_cache_mode even
though I deleted it from config — RELOAD has not propagated yet."
…meters

Closes codex MED #7 (fresh review 2026-05-12).

ServerParameters::set_param canonicalizes timezone -> TimeZone and
datestyle -> DateStyle so the in-memory map matches the casing PG
sends back in ParameterStatus. operator_managed_startup_keys, however,
stored the raw startup-map keys, so sync_parameters did an exact-
string filter against the canonical-cased diff produced by
compare_params. An operator value set as `timezone` left the canonical
`TimeZone` unprotected: a client startup value reported as `TimeZone`
would propagate through sync_parameters and overwrite the operator
default, violating the "operator wins over client" rule.

Extract canonicalize_param_name out of set_param into a pub helper and
apply it when building operator_managed_startup_keys in Server::startup.
Both spellings now collapse to the same canonical form before the
filter runs.
Closes codex MED #8 (fresh review 2026-05-12).

create_dynamic_pool re-peeked AuthQueryState's global cache to capture
the per-user startup_parameters overlay even though the caller in
auth/mod.rs had just fetched the row it authenticated against. With a
low cache_ttl or a concurrent refetch, a user could authenticate
against one row and have the dynamic pool built from a different (or
missing) overlay snapshot.

Take Arc<HashMap> as a parameter from the caller — `cache_entry.startup_parameters`
in auth/mod.rs — and use it directly. The dedicated-mode guard stays
for defence-in-depth, but the cache is no longer touched between
authentication and pool construction.
GitHub-hosted runners share CPU across the matrix and across the
broader runner pool, which produces two recurring flake classes:
demo TPS warming up at a different pace per run, and timing-sensitive
BDD scenarios (SCRAM passthrough reconnect after retain, sleep-based
lifecycle waits) losing their margin when neighbouring jobs spike.
Both repro deterministically locally and on master, so the runner
share is the only signal that changed.

Wrap dashboard-validate-ci in a 3-attempt loop with a `docker compose
down -v` tear-down between attempts; the demo restarts clean, the
panels get a fresh rate window, and the next attempt usually
succeeds. Wrap each BDD suite in nick-fields/retry@v3 with
max_attempts: 2 so a single timing-sensitive miss does not block
the PR. Wall-clock impact is bounded because each suite is 1-3 min;
retry only fires on actual failure, not as a baseline tax.
… metrics doc-comments

Closes codex tech writer findings #17-22 (fresh review 2026-05-12):

- SHOW STARTUP_PARAMETERS docs (en + ru) now list the `state` column
  alongside the existing `user|database|parameter|value|source`
  columns, with `applied | dropped_due_to_budget | stale` semantics
  explained in the tutorial. The BDD scenario "admin SHOW
  STARTUP_PARAMETERS lists resolved parameters per pool" pins the
  new column to `applied` so doc/runtime drift gets caught.
- Oversize behaviour wording matches the current
  "drop the overlay if baseline alone fits; otherwise drop all
  operator-supplied keys" logic for both `cascade_budget_exceeded`
  and `auth_query_overlay_oversize`.
- Russian Prometheus reference adds the `pg_doorman_startup_parameters_dropped_total`
  row with the full bounded `reason` enum.
- Type-mismatch warning wording corrected from "one-time per user"
  to "for that fetched row", matching the actual log site.
- SQLSTATE `57P*` prefix wording replaces the inaccurate
  "SQLSTATE class 57P" everywhere it appeared.
- Dropped-counter wording uses "events" consistently in metric
  HELP text, RU prose, and the changelog.

The src/web/metrics/mod.rs doc-comments and the auto-generated
reference markdown were regenerated from fields.yaml as part of
the same pass. The minor RU translation cleanup in unrelated
tutorials (binary-upgrade, patroni-proxy, pool-pressure, etc.)
fixes anglicisms picked up while comparing translations across the
RU docs tree.
Closes codex MED #10 (fresh review 2026-05-12).

The frontend assumed `value: string` always present. After the
backend started masking values for anonymous viewers (HIGH #5 fix)
React rendered "undefined" in the pool detail page; after the
backend added the `state` field (MED #12 fix) the UI had no way to
surface keys whose snapshot is stale or whose cascade overflows
the budget.

Update the TypeScript shape: `value?: string` (optional) and `state:
string` (required, defaults to "applied" in the renderer). The list
item shows the value or "***" for anonymous viewers, and prints the
state inline next to the source label when state != "applied". Stale
overlays render yellow; budget-overflow drops render red. Rebuild
frontend/dist so the embedded bundle matches.
Comment thread src/pool/server_pool.rs Dismissed
dmitrivasilyev added 8 commits May 12, 2026 14:32
A TLS-terminating reverse proxy in front of pg_doorman is common, but
the proxy → pg_doorman hop can ride a private HTTP leg. On shared or
multi-tenant networks an attacker on that segment can replay Bearer
JWTs or `sso_access_token` cookies pulled off the wire. Operators
need a way to make pg_doorman refuse those tokens unless the request
actually arrived over HTTPS.

New `[web].sso_require_https` knob, default `false`. When on, the
listener accepts Bearer/cookie/query SSO tokens only when the TCP
peer is in `trusted_proxies` AND the proxy forwarded
`X-Forwarded-Proto: https` (multi-hop chains must be `https`
end-to-end — any `http` segment downgrades). Plain-HTTP SSO attempts
yield 401 instead of silent Anonymous and bump
`pg_doorman_web_sso_validation_errors_total{reason="insecure_transport"}`,
so a misrouted operator notices instead of guessing. Basic auth is
unaffected — it lives or dies on the constant-time compare, regardless
of transport.

Default off keeps existing deployments working unchanged: the SSO
proxy still reaches pg_doorman over a private HTTP leg, no
configuration migration required. Documented in EN/RU web-ui guides
and surfaced in the annotated TOML/YAML references.
The previous sign-in modal was a 320px box tacked over the SPA: no
visual anchor, the SSO button shared the chrome of the Basic form,
and a returning operator could not tell at a glance which transport
their browser was talking to.

The new sign-in surface is a full-page console that reuses the
JetBrains Mono / amber-tick / hairline-border idiom from the rest of
the dashboard. SSO becomes the primary action with the proxy host
shown inline so the operator knows where the redirect will route
them; Basic is its own section below an `or use local admin` rule,
keeping it discoverable without competing for attention. A transport
chip in the footer reads `window.location.protocol` and colours
itself green on `https:` (success) versus amber on `http:` (warning),
so an operator about to send a Bearer JWT over plain HTTP sees the
risk before they click.

Accessibility tightened along the way: every input now has an
explicit label-for/id pair on top of the existing focus trap,
`role="dialog"` shell, and amber focus-visible ring inherited from
the global stylesheet.
`/target` only anchored to the repo root, so `cargo build` inside
`patches/openssl-src/` left `target/debug/.cargo-lock` showing up as
untracked. Replace with `target/` so any cargo build directory under
the tree is ignored.

Also ignore `/docs/internal-plans/` alongside the existing
`/docs/superpowers/` exclusion — same role: private session-scoped
planning notes that should not enter public history.
…ing proxy

The frontend redirect helper rejected any sso_proxy_url that did not
start with `https://` (with a localhost escape hatch), logged
`sso_proxy_url must use https` to the console, and left the
"Sign in via SSO" button doing nothing. That contradicts the
supported deployment shape where the corporate TLS-terminating
proxy advertises an `http://` URL pointing at pg_doorman's own
internal address, with TLS terminated upstream.

Drop the protocol gate from `safeProxyUrl`. The URL still has to
parse so a typo in `pg_doorman.toml` keeps showing in devtools;
choosing http vs https stays with the operator. Backend
`[web].sso_require_https` (default false) remains the explicit
opt-in for environments that want pg_doorman to refuse plain-HTTP
SSO tokens — that's the right place to enforce transport policy.
Two retries (max_attempts: 2) were not enough to recover from a
GitHub Actions DNS outage to `index.crates.io` that lasted longer
than cargo's own ~25-second network retry loop, draining both
attempts in the same window (run #25734696524, Patroni proxy job
failed twice in a row on `Could not resolve host: index.crates.io`).

Three attempts plus a 30-second wait between them gives the DNS
outage time to clear before the next attempt enters cargo's network
loop. The third attempt only fires when both prior ones failed, so
the timing-sensitive flake family (SCRAM passthrough reconnect,
sleep-based lifecycle waits) does not pay an extra minute on
single-attempt successes.

Also drop the now-incorrect "no retry around the cargo step" header
comment — retry has covered that step for several runs already.
…nnot wedge a job

The bumped outer retry from the previous commit was treating a
symptom: the BDD container kept failing with
`Could not resolve host: index.crates.io` because the default
docker bridge network does not always re-export the runner's
systemd-resolved stub at 127.0.0.53. The container's resolver
was simply broken for the whole job, so neither cargo's own
internal retries nor the outer attempt-level retries had a path
out — all three of cargo's tries kept hitting the same dead
resolver in quick succession.

Run the BDD container with `--network=host` so it inherits the
runner's working `/etc/resolv.conf` directly, bypassing the
bridge resolver entirely. Each matrix entry runs on its own
ephemeral runner, so sharing the host network does not create
loopback-port collisions between suites.

While here, raise the cargo-internal envelope for the residual
case where DNS works but crates.io rate-limits or returns 5xx
mid-fetch: `CARGO_NET_RETRY=10` (default 2) gives cargo five
times more attempts on its own network loop, and
`CARGO_HTTP_TIMEOUT=60` (default 30 s) doubles the per-attempt
ceiling. The outer 3-attempt retry stays as the last-resort
safety net for whole-runner flakes.
… waiting longer

The previous attempt bumped the outer step retry to 3×30 s on the
assumption that the second attempt would land outside a transient
DNS outage. Observation: when this fails, exactly one job in the
matrix fails — the others on different ephemeral runners succeed
at the same wall time. That rules out a fleet-wide DNS flake and
points at a single runner whose bridge resolver is wedged for the
whole job. A 30 s wait does not unwedge a dead resolver, so the
extra attempts only stretch the failed wall time without changing
the outcome.

Restore the outer retry to the previous 2×5 s policy that exists
only for the timing-sensitive BDD flake family (SCRAM passthrough
reconnect, sleep-based lifecycle waits). The network defence now
lives in two narrower places: `--network=host` short-circuits the
bridge resolver so the container talks to the host's systemd-
resolved stub directly, and `CARGO_NET_RETRY=10` /
`CARGO_HTTP_TIMEOUT=60` widen cargo's own retry loop for residual
crates.io flakes that arrive after DNS resolves cleanly.
@vadv
vadv merged commit 3560cf4 into master May 12, 2026
50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants