Skip to content

feat(db): configurable writer session timeouts (lock, idle-txn, statement) - #6229

Open
TheSentinel454 wants to merge 5 commits into
mainfrom
fizz/db-session-timeouts
Open

feat(db): configurable writer session timeouts (lock, idle-txn, statement)#6229
TheSentinel454 wants to merge 5 commits into
mainfrom
fizz/db-session-timeouts

Conversation

@TheSentinel454

Copy link
Copy Markdown
Contributor

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:relation waits 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_connect hook in buzz-db, all env-tunable through the same Config::from_env → DbConfig path as the existing pool-size knobs:

Env var GUC Default Effect
BUZZ_DB_LOCK_TIMEOUT_MS lock_timeout 5000 statements waiting on any lock fail fast instead of parking behind a wedged holder
BUZZ_DB_IDLE_TXN_TIMEOUT_MS idle_in_transaction_session_timeout 60000 reaps wedged clients idling inside an open transaction while holding locks
BUZZ_DB_STATEMENT_TIMEOUT_MS statement_timeout 0 (off) opt-in runaway-statement cap; off by default because startup migrations/backfills legitimately run long statements

0 disables a timeout (Postgres semantics) and deliberately passes through the env parsing — unlike the pool-size knobs where 0 falls 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, or relay.extraEnv in 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 set BUZZ_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 clean main in this environment (api::admin/api::media/mesh_demo — unrelated, pre-existing).
  • New config test covers override / 0-passthrough / invalid-fallback for all three env vars.
  • Extended the existing writer_pool_safety_hook_is_single_and_composed source-shape test so the timeouts can't drift out of the single after_connect hook (SQLx replaces hooks — a second hook would silently disarm the floor guard).
  • cargo fmt --check and cargo clippy --all-targets clean for the touched crates.

Closest existing PR/issue: none found.

…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>
Comment thread crates/buzz-db/src/lib.rs
/// 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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Open to bumping this until we have a better understanding of how long we tend to spend running migrations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 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).

@TheSentinel454
TheSentinel454 marked this pull request as ready for review August 18, 2026 15:50
@TheSentinel454
TheSentinel454 requested a review from a team as a code owner August 18, 2026 15:50

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 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.

Comment thread crates/buzz-db/src/lib.rs
Comment thread crates/buzz-db/src/lib.rs
Comment thread crates/buzz-db/src/lib.rs Outdated
Comment thread .env.example
… 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 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>

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 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_pool via connect_audit_pool() (crates/buzz-relay/src/main.rs:38), inheriting the timeouts, the created_at floor 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=300 waited ~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_waits PASS 4.96 s, audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits PASS 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, the with_session_timeouts_from_env doc, and docs/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.

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