feat(db): configurable writer session timeouts (lock, idle-txn, statement) - #6229
feat(db): configurable writer session timeouts (lock, idle-txn, statement)#6229TheSentinel454 wants to merge 5 commits into
Conversation
…ment) A wedged relay boot pod holding a relation lock can park every other writer in the fleet behind it: DB load pins at pool capacity in Lock:relation waits while CPU stays flat, and nothing server-side releases the lock until the holder dies. Apply session-level timeouts to every writer connection inside the existing single after_connect hook: - lock_timeout (BUZZ_DB_LOCK_TIMEOUT_MS, default 5000): statements waiting on any lock fail fast instead of parking, so a stuck holder produces visible per-statement errors rather than a fleet stall. - idle_in_transaction_session_timeout (BUZZ_DB_IDLE_TXN_TIMEOUT_MS, default 60000): reaps wedged clients idling inside an open transaction while holding locks. - statement_timeout (BUZZ_DB_STATEMENT_TIMEOUT_MS, default 0/off): opt-in runaway-statement cap; off by default because startup migrations and backfills legitimately run long statements. All three are env-tunable through the same Config::from_env -> DbConfig path as the pool-size knobs; 0 disables a timeout (Postgres semantics) and must pass through, so the env parsing deliberately does not filter zero like the pool sizes do. Reader pool untouched: replica sessions never take contended locks and already fail acquire in 150ms. Closest existing PR/issue: none found. Co-authored-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
| /// lock wait on the relay's hot paths, yet turns a wedged relation-lock | ||
| /// holder from a fleet-wide stall into per-statement errors that surface in | ||
| /// logs and retry naturally. | ||
| pub const DEFAULT_LOCK_TIMEOUT_MS: u64 = 5_000; |
There was a problem hiding this comment.
Open to bumping this until we have a better understanding of how long we tend to spend running migrations.
There was a problem hiding this comment.
🤖 Leaving this thread open: the 5s default is unchanged pending real migration-duration data. There are no boot-time migration duration metrics today; the proposal on the table is a buzz_boot_migration_duration_seconds gauge around db.migrate() (each pod boot is a sample), either in this PR or a small follow-up — awaiting the maintainer's call. Note that as of 605244c the migration advisory-lock path is exempt from lock_timeout/statement_timeout, so the default now only governs runtime traffic, which lowers the stakes of the choice.
This reply was generated by an AI agent (Fizz).
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Combined review — two independent agent passes (Paul and Thufir) that converged on the migration finding, deduped into the inline comments below. The mechanism itself looks right to me: one composed after_connect hook, 0-passthrough env semantics, reader pool left alone, bare-integer ms values verified live against Postgres. Nobody's asking for a different design — two blocking items and two nits.
… env knobs Addresses the four actionable review threads on the writer session timeout PR: - Exempt the migration/schema-destruction advisory-lock connection from lock_timeout and statement_timeout. Without this, a non-winning boot pod waiting out the migration winner would crash-loop on SQLSTATE 55P03 after 5s. idle_in_transaction_session_timeout deliberately stays active there: a wedged idle migration client is exactly the holder that must be reaped, and backend death releases the advisory lock. Proven by a Postgres-backed test that runs Db::migrate() while another session holds the advisory lock past lock_timeout, wired into the Backend Integration CI job. - Move the BUZZ_DB_*_TIMEOUT_MS env parsing from buzz-relay's Config into buzz-db as DbConfig::with_session_timeouts_from_env(), so buzz-admin and buzz-deletion writers honor the knobs instead of silently ignoring them. All three binaries now overlay the same env keys onto DbConfig; zero still passes through (Postgres "disabled" semantics) and invalid values fall back to defaults, covered by a unit test. - Reword the after_connect hook comment to describe each knob's actual actor: lock_timeout fails the *waiter*, idle-txn timeout reaps idle in-transaction holders, and an actively-executing wedged holder is only bounded by statement_timeout. - Warn in .env.example that a pathologically low statement_timeout can fail connection setup itself. Also adds an end-to-end Postgres test asserting the effective GUCs on pooled connections and the fast 55P03 failure of a contended lock wait. Co-authored-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
| Fut: Future<Output = (PgConnection, Result<T>)>, | ||
| { | ||
| let mut lock_conn = pool.acquire().await?.detach(); | ||
| // Exempt this connection from the lock/statement writer-session timeouts. |
There was a problem hiding this comment.
To be honest, we shouldn't be doing this, and we need to move away from applying migrations on boot, but that's a bigger change that I'll be pushing for separately.
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Re-reviewed the exact current head. The previous two IMPORTANT findings plus two MINOR notes are addressed for pools constructed through Db::new(): the Postgres-backed CI test covers effective GUCs, ordinary 55P03 contention, and migration-lock exemption; independent PG 17 verification reproduced 55P03 at 502 ms and migration success after waiting 1,521 ms past both configured lock/statement budgets. Admin and deletion now share the centralized env overlay, and the comments/docs are accurate.
IMPORTANT / Correctness — crates/buzz-relay/src/main.rs:356-364: the timeout policy is only installed by Db::new(), but the audit service is a separate production relay writer pool built with raw PgPoolOptions. AuditService::log() writes audit_log in a transaction and waits on a session-scoped pg_advisory_lock; live verification at this exact head showed all three GUCs remained 0 on a production-shaped direct pool and the waiter was still blocked at the 1,201 ms harness deadline. Because the relay has one audit worker and a bounded queue whose producers use .send().await, a blocked audit lock can stall the worker, fill the queue, and backpressure event/media handlers.
Please arm the audit writer with the same session settings—preferably through a reusable buzz-db pool configuration helper rather than duplicated SQL—and add a Postgres-backed regression asserting its effective GUCs and bounded advisory-lock wait. The separately deployed push gateway also constructs a raw writer pool; explicitly decide/document whether it belongs in this PR’s stated “every writer” scope.
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
* origin/main: (64 commits) chore(deps): pin earshot below 1.2.0 pending a VAD threshold re-pick (#6392) polish(desktop): finish Projects navigation and context chrome (#6429) fix(desktop): clarify add agents channel action (#6374) Repair stale large channel roster snapshots (#6251) feat(desktop-messages): show compact Buzz link metadata (#6252) feat(workflows): reply in-thread from send_message action (#6178) perf(desktop): split discover_acp_providers into cheap and forced paths (#6330) fix(desktop): restore recent channel sorting (#6402) fix(desktop): isolate main timeline stacking context from focus drawer (#6398) fix(desktop): make reconnect repair lossless (#6415) fix(hooks): scope pre-push lanes to branch merge-base diff (#6423) Enforce a three-day dependency cooldown (#6426) perf(desktop): resolve references without directory scans (#6328) feat(llm): stamp thinking effort on call-completed log line (#6424) Fix cross-owner relay agent mentions in owner-only builds (#6338) feat(cli): accept Buzz message links for thread reads (#6359) feat(workflows): add workflow editor (#6248) fix(desktop): preserve huddle speech boundaries (#6397) test(desktop): use a wordlist-safe separator in passphrase word-count test (#6356) fix(models): curate Databricks alias-aware labels for 5 missing endpoints (#6360) ... Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
There was a problem hiding this comment.
🤖 Combined review of exact head 9bbcade722b7a680e764feabcdb5acf738347ed3 — three independent agent passes (two source reviews plus a clean-relay live E2E run), deduped here. Both live probes converged on the same new defect independently, which is strong confirmation it's real.
Previous round's findings — all addressed:
- The audit pool now goes through the renamed public
Db::connect_writer_poolviaconnect_audit_pool()(crates/buzz-relay/src/main.rs:38), inheriting the timeouts, thecreated_atfloor guard, and the READ COMMITTED assertion; the source-shape guard test tracks the new name so the single-after_connect-hook invariant can't drift. - Migration/schema-destruction connections exempt their legitimate long waits. Proven live twice: a relay with
BUZZ_DB_LOCK_TIMEOUT_MS=300waited ~3 s behind the schema-migration advisory lock and completed startup. - Admin, deletion, relay, and audit pool configuration share the centralized env overlay; comments and operator docs match PostgreSQL semantics.
- The requested Postgres-backed regressions exist and actually run: the Backend Integration job at this head executed both focused tests from the archive (
session_timeouts_install_through_db_new_and_bound_lock_waitsPASS 4.96 s,audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waitsPASS 0.52 s). The final commit's CI split is a genuine hardening: separate nextest invocations mean a relay-binary test missing from the archive fails loudly instead of the combined OR-filter silently passing on the buzz-db half. - Push-gateway scope decision is documented consistently in
.env.example, thewith_session_timeouts_from_envdoc, anddocs/push-gateway-deployment.md.
Core mechanism verified live (clean relay, isolated Postgres/Redis/MinIO, head-built binaries): a real message write behind an ACCESS EXCLUSIVE lock on events failed in 0.486 s instead of parking, the relay stayed ready throughout, and accepted/read messages normally after release — the exact incident shape this PR exists to fix.
IMPORTANT / Correctness — audit lock timeouts now permanently discard accepted events' audit entries. connect_audit_pool() correctly installs the 5 s default lock_timeout, but AuditService::log() propagates SQLSTATE 55P03 and log_audit_entry() (crates/buzz-relay/src/state.rs:1342-1349) only logs the error and increments a metric before consuming the queue item — no retry, no durable outbox. Both live probes reproduced this independently at this exact head: holding one community's audit advisory lock past the timeout, the relay accepted and persisted the message (event_rows=1), the worker logged canceling statement due to lock timeout, and audit_rows remained 0 (one probe additionally drove a real end-to-end channel message through the head-built CLI: event accepted, permanently unaudited). Releasing the lock let the next event audit normally — transient contention became permanent audit loss, not database unavailability. Before this PR the audit pool had no lock_timeout, so the failure mode was indefinite worker blockage; the fix converts it into silent audit-chain gaps, which regresses the durable-audit contract (SECURITY.md:67-74, VISION_MODERATION.md) and the queue's stated no-drop intent.
Required fix: preserve the current queued entry across retryable lock-timeout failures — retry with bounded backoff until appended, or use a transactional durable outbox if request-path decoupling must survive prolonged contention. Add a Postgres-backed worker-level regression that holds the advisory lock past lock_timeout, releases it, then proves the original accepted event is eventually audited (the current audit test proves the pool fails fast, not that the consuming workflow preserves the entry). A fix also needs a live contention re-run proving the accepted event ends up with exactly one audit row.
New-regression sweep — nothing else found: all remaining raw PgPoolOptions writers at this head are accounted for (search pool is SELECT-only FTS, mesh-boot/channel-snapshot pools are test-only, push gateway documented out of scope), and the merge from main introduced no semantic interaction with the PR's files.
Quality: Minimalism 9/10; Elegance 9/10; Correctness 7/10 pending durable recovery from the newly expected lock-timeout error.
Why
A wedged relay boot pod holding a relation lock can park every other writer in the fleet behind it: DB load pins at pool capacity in
Lock:relationwaits while CPU stays flat, and nothing server-side releases the lock until the holder dies. We hit exactly this in production — ~1,400 sessions queued behind one crash-looping pod's boot transaction for ~20 minutes until kubelet killed the container.What
Applies session-level Postgres timeouts to every writer connection inside the existing single
after_connecthook inbuzz-db, all env-tunable through the sameConfig::from_env → DbConfigpath as the existing pool-size knobs:BUZZ_DB_LOCK_TIMEOUT_MSlock_timeoutBUZZ_DB_IDLE_TXN_TIMEOUT_MSidle_in_transaction_session_timeoutBUZZ_DB_STATEMENT_TIMEOUT_MSstatement_timeout0disables a timeout (Postgres semantics) and deliberately passes through the env parsing — unlike the pool-size knobs where0falls back to the default. The reader pool is untouched: replica sessions never take contended locks and already fail acquire in 150 ms.Deployers tune these via plain env vars (
.env, orrelay.extraEnvin the Helm chart) — no code changes needed.Behavior change to note
With the 5 s default
lock_timeout, a boot-time migration or backfill that waits >5 s on a lock now errors (surfacing in logs / crash-looping the pod) instead of stalling silently. That is the intended visible-failure-over-fleet-stall tradeoff; deployers with slow contended migrations can setBUZZ_DB_LOCK_TIMEOUT_MS=0.Testing
cargo test -p buzz-db -p buzz-relay— buzz-db green; buzz-relay has 9 failures that also fail on cleanmainin this environment (api::admin/api::media/mesh_demo — unrelated, pre-existing).0-passthrough / invalid-fallback for all three env vars.writer_pool_safety_hook_is_single_and_composedsource-shape test so the timeouts can't drift out of the singleafter_connecthook (SQLx replaces hooks — a second hook would silently disarm the floor guard).cargo fmt --checkandcargo clippy --all-targetsclean for the touched crates.Closest existing PR/issue: none found.