fix(db): bound runtime Postgres statements and lock waits - #4613
Conversation
elifoster-block
left a comment
There was a problem hiding this comment.
Excluded migrations from the timeouts and set the timeouts as a DBconfig option.
wesbillman
left a comment
There was a problem hiding this comment.
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.
|
🤖 Both review points addressed in c1277d2. Blocking: preflight ordering. Confirmed —
Hardening: cancellation safety. Made structural rather than narrowed. New regression test: Verified discriminating: with the preflight moved back onto a pooled connection, the test fails with Testing
Same caveat as before: the Postgres-backed tests are |
wesbillman
left a comment
There was a problem hiding this comment.
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.
|
🤖 Remaining blocker addressed in 37cb419. Out-of-range magnitudes. Confirmed — The magnitude is now parsed and converted to milliseconds by unit ( The ceiling is pinned, not asserted from memory. Coverage.
Testing
Same caveat as before: the Postgres-backed tests are |
wesbillman
left a comment
There was a problem hiding this comment.
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.
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>
72c60d1 to
637a323
Compare
This change applies a
statement_timeoutandlock_timeoutwhenever 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 EXCLUSIVEwait 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_timeoutbounds heavyweight and row lock waits. Advisory-lock waits — event replacement, the per-community audit lock, sqlx migrations — are bounded bystatement_timeoutinstead.Both values are configurable through
DbConfig, wired toBUZZ_DB_STATEMENT_TIMEOUTandBUZZ_DB_LOCK_TIMEOUT, defaulting to 30s and 5s. An operator running a backfill or working an incident does not need a code change, and0disables a limit. A malformed value falls back to the default with a warning rather than failing config: handing it to Postgres would fail everyafter_connectand take all database access with it.Testing
cargo test -p buzz-db --libat6f8e115: 94 passed, 153 ignored (Postgres)cargo test -p buzz-relay --libat6f8e115: 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 stashedcargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warningsandcargo fmt -- --check: cleangit diff --check origin/main...codex/security-postgres-timeoutsorigin/mainat5c98932Unit tests cover the timeout parser (Postgres spellings accepted; empty, unit-less junk, and
30 secondsrejected in favor of the default) and the env plumbing end to end throughConfig::from_env.Postgres-backed tests, run locally against
postgres:17-alpine:armed_pool_rejects_old_channel_inserts_through_public_api—SHOW statement_timeout/SHOW lock_timeoutare the configured values on both the writer and reader poolsmigration_connection_is_unbounded_and_is_retired_not_reused— the migrator's connection reports0for both, and a single-slot pool hands out a freshly configured connection afterwardsmigrations_ignore_runtime_timeouts_and_leak_no_relaxed_session— a realdb.migrate()against a tight-timeoutDbsucceeds, and every connection in the pool still carries the configured limitsThe exemption assertions were checked against a neutered implementation — a no-op lift and a
dropinstead 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