Skip to content

fix(db): bound runtime Postgres statements and lock waits - #4613

Open
jmecom wants to merge 7 commits into
mainfrom
codex/security-postgres-timeouts
Open

fix(db): bound runtime Postgres statements and lock waits#4613
jmecom wants to merge 7 commits into
mainfrom
codex/security-postgres-timeouts

Conversation

@jmecom

@jmecom jmecom commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

This change applies a statement_timeout and lock_timeout whenever the writer, lazy replica reader, audit, and search pools establish a connection. The limits prevent slow statements and lock waits from holding pool capacity indefinitely. Legitimate work exceeding those bounds will now be canceled and must be retried or redesigned.

The bounds are runtime-only. Schema migrations run on a connection with both limits lifted, and that connection is closed rather than returned to the pool: an index build on a populated table, or an ACCESS EXCLUSIVE wait behind live traffic, routinely outlasts them, and startup treats a migration failure as fatal. sqlx also takes its migration advisory lock as a single waiting statement, so a second replica rolling out would be canceled mid-wait instead of queueing behind the first.

lock_timeout bounds heavyweight and row lock waits. Advisory-lock waits — event replacement, the per-community audit lock, sqlx migrations — are bounded by statement_timeout instead.

Both values are configurable through DbConfig, wired to BUZZ_DB_STATEMENT_TIMEOUT and BUZZ_DB_LOCK_TIMEOUT, defaulting to 30s and 5s. An operator running a backfill or working an incident does not need a code change, and 0 disables a limit. A malformed value falls back to the default with a warning rather than failing config: handing it to Postgres would fail every after_connect and take all database access with it.

Testing

  • cargo test -p buzz-db --lib at 6f8e115: 94 passed, 153 ignored (Postgres)
  • cargo test -p buzz-relay --lib at 6f8e115: 830 passed, 10 failed — every failure is pre-existing and needs a live Postgres/Redis, verified by diffing the failure set against the same run with these changes stashed
  • cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings and cargo fmt -- --check: clean
  • git diff --check origin/main...codex/security-postgres-timeouts
  • Rebased onto origin/main at 5c98932

Unit tests cover the timeout parser (Postgres spellings accepted; empty, unit-less junk, and 30 seconds rejected in favor of the default) and the env plumbing end to end through Config::from_env.

Postgres-backed tests, run locally against postgres:17-alpine:

  • armed_pool_rejects_old_channel_inserts_through_public_apiSHOW statement_timeout / SHOW lock_timeout are the configured values on both the writer and reader pools
  • migration_connection_is_unbounded_and_is_retired_not_reused — the migrator's connection reports 0 for both, and a single-slot pool hands out a freshly configured connection afterwards
  • migrations_ignore_runtime_timeouts_and_leak_no_relaxed_session — a real db.migrate() against a tight-timeout Db succeeds, and every connection in the pool still carries the configured limits

The exemption assertions were checked against a neutered implementation — a no-op lift and a drop instead of a close each fail the test. A timing-based test does not discriminate here: on an empty database every migration statement finishes well inside the runtime cap, so it passes with or without the exemption.

Originating Buzz thread: buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1

@jmecom
jmecom marked this pull request as ready for review August 3, 2026 21:00
@jmecom
jmecom requested a review from a team as a code owner August 3, 2026 21:00
elifoster-block
elifoster-block previously approved these changes Aug 4, 2026

@elifoster-block elifoster-block left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excluded migrations from the timeouts and set the timeouts as a DBconfig option.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing on Wes's behalf.

Blocking regression: the migration exemption starts too late. run_migrations calls reject_legacy_nip_rs_cardinality_ambiguity(pool) before run_migrator_without_runtime_timeouts acquires and relaxes its connection. On a pre-v7 database, that preflight executes a potentially large scan of events (including per-row JSON expansion), so it inherits the new 30s statement_timeout. If it exceeds the cap, startup treats the error as fatal and the relay cannot boot—the exact regression the migration exemption is intended to prevent.

Please acquire the dedicated migration connection and lift both limits before running the legacy preflight, then run the migrator on that same connection and retire it on every normal success/error path. The preflight can still remain before sqlx begins its migration transaction. Add a Postgres-backed regression test that makes the preflight exceed a tight runtime timeout while confirming it completes on the exempt connection; the current fresh-database migration test returns before this scan and cannot catch this ordering bug.

Non-blocking hardening note: the relaxed PoolConnection is returned to the runtime pool if the migration future is cancelled after lift_runtime_timeouts but before explicit retirement. The current relay startup awaits migration directly and normally exits on shutdown, which narrows the practical exposure, but making the exemption structurally cancellation-safe would better preserve the stated invariant that an unlimited session can never serve runtime traffic.

@elifoster-block

Copy link
Copy Markdown
Contributor

🤖 Both review points addressed in c1277d2.

Blocking: preflight ordering. Confirmed — run_migrations ran reject_legacy_nip_rs_cardinality_ambiguity(pool) before run_migrator_without_runtime_timeouts acquired anything, so on a pre-0007 database the events scan inherited the runtime statement_timeout and a slow scan became a fatal startup failure. The exempt connection is now acquired and relaxed first, and both the preflight and MIGRATOR.run execute on it. The preflight still runs ahead of sqlx's migration transaction, so an operator can inspect and repair before any DDL.

verify_floor_guard_catalog stays on the pool — it's catalog-only, bounded by partition count, and shared with the non-migrating startup probe.

Hardening: cancellation safety. Made structural rather than narrowed. exempt_migration_connection calls .detach() before lifting the limits, so the value is a PgConnection, not a PoolConnection. A PoolConnection releases itself to the pool on drop, which is the leak you described; a detached connection closes on drop. There is no longer a window where a cancelled future can return an unbounded session — the explicit close() is now just eager cleanup and error reporting, not the invariant. migration_connection_is_unbounded_and_is_retired_not_reused was updated to drop without closing (standing in for the cancellation) and still asserts the single-slot pool hands out a freshly capped connection.

New regression test: legacy_preflight_scan_outlives_the_runtime_statement_timeout. It applies migrations 1–6, then seeds conforming kind-30078 read-state rows in batches, timing the preflight on a capped pooled connection after each batch until Postgres cancels it with 57014 — so the row count is calibrated at runtime instead of hardcoded to whatever is slow on one machine. It then runs run_migrations against that same capped pool, asserts it reaches version 26, and asserts the pool still hands out capped sessions afterwards. Rows are deliberately non-ambiguous so the EXISTS cannot short-circuit early.

Verified discriminating: with the preflight moved back onto a pooled connection, the test fails with Sqlx(Database(PgDatabaseError { code: "57014", message: "canceling statement due to statement timeout" })).

Testing

  • cargo test -p buzz-db --lib: 94 passed, 156 ignored
  • cargo test -p buzz-db --lib migration:: -- --ignored: all 5 pass, including the new test (calibration settled at 20k rows, 3.3s total)
  • cargo test -p buzz-db --lib migrations_ignore_runtime_timeouts -- --ignored and ... armed_pool -- --ignored: pass
  • cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings and cargo fmt -- --check: clean

Same caveat as before: the Postgres-backed tests are #[ignore] and CI selects ignored tests by explicit filter, so they run on demand rather than per-PR.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing on Wes's behalf.

The migration-ordering and cancellation-safety blockers are fixed at c1277d268: preflight and migrator share a detached, timeout-exempt connection, and detaching before relaxation prevents an unlimited session from returning to the runtime pool.

One operational blocker remains in the new timeout configuration. pg_timeout_or_default accepts any non-empty ASCII digit string followed by a recognized unit but never parses or bounds the magnitude. For example, BUZZ_DB_STATEMENT_TIMEOUT=999999999999999999999999999999999999999999d passes validation unchanged; PostgreSQL then rejects it when set_config runs. Because the timeout setup is installed in every writer, reader, audit, and search pool's after_connect, one superficially "valid" but out-of-range value can prevent all database connections and fail relay startup—the exact failure this fallback helper says it prevents.

Please parse and range-check the magnitude (including unit conversion against PostgreSQL's supported timeout range), fall back on overflow/out-of-range values, and add regression coverage proving such a value never reaches after_connect unchanged.

@elifoster-block

Copy link
Copy Markdown
Contributor

🤖 Remaining blocker addressed in 37cb419.

Out-of-range magnitudes. Confirmed — pg_timeout_or_default checked shape only, so BUZZ_DB_STATEMENT_TIMEOUT=999999999999999999999999999999999999999999d passed through unchanged and Postgres rejected it inside after_connect, failing every writer/reader/audit/search connection and relay startup.

The magnitude is now parsed and converted to milliseconds by unit (us/ms/s/min/h/d, with us rounded the way Postgres rounds it), and anything that overflows or exceeds the ceiling falls back to the documented default with a warning. A digit string too wide for any integer type fails the parse rather than wrapping.

The ceiling is pinned, not asserted from memory. statement_timeout and lock_timeout are int GUCs in milliseconds, so the bound is i32::MAX ms (~24.8 days). It lives in buzz-db as PG_TIMEOUT_MAX_MILLIS, next to RUNTIME_STATEMENT_TIMEOUT and apply_runtime_connection_timeouts, and pg_timeout_max_millis_matches_postgres sets it against a live server from both sides: the boundary (and the same instant spelled in seconds) must be settable, and boundary+1 in each unit must fail with 22023. Too high a constant and an "accepted" value still breaks after_connect; too low and we refuse settings Postgres would take. Neither direction can drift silently.

Coverage.

  • pg_timeout_refuses_magnitudes_postgres_cannot_store2147483648, 2147483648ms, 2147484s, 35792min, 597h, 25d, and both over-wide digit strings fall back; the boundary in every unit (2147483647, 2147483647000us, 2147483s, 35791min, 596h, 24d) stays valid.
  • db_timeout_env_overrides_and_invalid_fallback — extended to prove an out-of-range env value never reaches DbConfig, which is what keeps it out of after_connect.
  • pg_timeout_max_millis_matches_postgres (Postgres-backed) — the drift test above, plus a check that a rejected set_config leaves the session's existing limit intact rather than disabling it.

.env.example now documents the ceiling alongside the accepted units.

Testing

  • cargo test -p buzz-db --lib: 94 passed, 157 ignored
  • cargo test -p buzz-db --lib -- --ignored --test-threads=1 for migration:: and armed_pool: all pass, including the new drift test. Run in parallel, four migration tests fail on scratch-database contention against my local Postgres — pre-existing and unrelated; each passes in isolation.
  • cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings and cargo fmt --all -- --check: clean

Same caveat as before: the Postgres-backed tests are #[ignore] and CI selects ignored tests by explicit filter, so they run on demand rather than per-PR.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing on Wes's behalf at 37cb4195d19142c4a58934fee17472282c2290c5.

The intended magnitude guard and migration invariant are otherwise sound, but one representable boundary still bypasses the guard:

[P1] Avoid overflowing the microsecond rounding addition

pg_timeout_millis parses the magnitude as u128, then evaluates (value + 500) / 1_000 for us. Inputs from u128::MAX - 499 through u128::MAX therefore overflow after parsing successfully. In checked/debug execution, config loading panics. With wrapping release arithmetic, the conversion produces a tiny millisecond value that passes the PG_TIMEOUT_MAX_MILLIS check, after which pg_timeout_or_default returns the original gigantic candidate and every pool's after_connect hands it to PostgreSQL—the startup failure this guard is intended to prevent.

The new over-wide tests use values larger than u128, so they safely fail parsing and do not exercise this edge. Please use checked arithmetic or an overflow-free equivalent such as value / 1_000 + u128::from(value % 1_000 >= 500), and add format!("{}us", u128::MAX) as a regression proving it falls back rather than panicking or passing through.

The rest of the fix checks out: representable values above PostgreSQL's i32::MAX-millisecond ceiling now fall back; the live PostgreSQL test covers the exact ceiling; and the detached migration connection remains exempt for both preflight and migrator without returning an unlimited session to the runtime pool.

jmecom and others added 7 commits August 6, 2026 17:44
Set a 30-second statement timeout and 5-second lock timeout on writer, reader, audit, and search pool connections. Verify the effective session settings alongside the existing writer guard.

Co-authored-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Jordan Mecom <jm@squareup.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Jordan Mecom <jm@squareup.com>
Schema migrations now run on a connection with both limits lifted. An
index build on a populated table, or an ACCESS EXCLUSIVE wait behind live
traffic, routinely outlasts the runtime caps, and startup treats a
migration failure as fatal — so inheriting them turned a slow migration
into a relay that cannot boot. sqlx also takes its migration advisory
lock as one waiting statement, so a second replica rolling out would be
canceled mid-wait instead of queueing. The connection is closed rather
than returned to the pool, since its session still carries no limits.

The two values move to DbConfig, wired to BUZZ_DB_STATEMENT_TIMEOUT and
BUZZ_DB_LOCK_TIMEOUT with the previous constants as defaults, so a
backfill or an incident does not need a code change. A malformed value
falls back to the default with a warning: handing it to Postgres would
fail every after_connect and take all database access with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Split the exemption into `lift_runtime_timeouts` and `retire_connection`
so both halves are observable, and cover them: the migrator's connection
reports both limits as `0`, and a single-slot pool hands out a freshly
configured connection afterwards rather than the relaxed session.

Both assertions were checked against a neutered implementation — a no-op
lift and a `drop` instead of a close each fail the test — because a
timing-based test does not discriminate here: on an empty database every
migration statement finishes well inside the runtime cap, so it would
pass with or without the exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
`run_migrations` ran `reject_legacy_nip_rs_cardinality_ambiguity` on a
pooled connection before acquiring the timeout-exempt migration
connection. On a pre-0007 database that preflight is a full scan of
`events` with per-row JSON expansion — exactly the databases large
enough for it to be slow — so it inherited the runtime
`statement_timeout`, and startup treats the error as fatal. The relay
could not boot: the regression the migration exemption exists to prevent.

Acquire and relax the connection first, then run the preflight and the
migrator on it. The preflight still precedes sqlx's migration
transaction, so an operator can inspect and repair before any DDL.

Detach the connection from the pool before lifting its limits, which
makes the exemption structurally cancellation-safe. A `PoolConnection`
returns itself to the pool on drop, so a migration future cancelled
after the lift handed an unbounded session to runtime traffic; a
detached `PgConnection` closes on drop instead.

Add a Postgres-backed regression test that seeds conforming kind-30078
rows until the preflight exceeds a tight cap on a pooled connection,
then asserts `run_migrations` completes against the same capped pool and
that the pool still hands out capped sessions afterwards. Row count is
calibrated at runtime rather than hardcoded, so the test stays
discriminating across hardware. Verified against a neutered
implementation: moving the preflight back to the pool fails it with
57014.

Signed-off-by: Eli Foster <efoster@squareup.com>
pg_timeout_or_default only checked shape, so a well-formed but
unstorable value ("999...9d") reached every pool's after_connect and
Postgres rejected it there — failing all database access, the exact
outcome the fallback exists to avoid. Parse the magnitude, convert it to
milliseconds by unit, and fall back on overflow or anything above the
int GUC ceiling.

The ceiling lives in buzz-db as PG_TIMEOUT_MAX_MILLIS next to the
timeouts it bounds, pinned to the live server by a Postgres-backed test
so it can drift in neither direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
pg_timeout_millis rounded `us` values with `(value + 500) / 1_000`, which
overflows for the top 500 representable u128 magnitudes — a debug panic during
config load, or in release a wrapped near-zero millisecond value that slips
past the range check and hands the original gigantic string to every pool's
after_connect. Round without the intermediate, and cover u128::MAX and the
first overflowing value in the fallback test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
@shellz-n-stuff
shellz-n-stuff force-pushed the codex/security-postgres-timeouts branch from 72c60d1 to 637a323 Compare August 6, 2026 07:52
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.

3 participants