Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ REDIS_URL=redis://localhost:6379
# READ_DATABASE_URL is set, reader (default 50).
# BUZZ_DB_POOL_SIZE=50

# Writer-session Postgres timeouts for buzz-db-backed pools and the relay audit
# pool, all in milliseconds; 0 disables. The separately deployed push gateway
# owns its own database and session policy and does not consume these knobs.
# lock_timeout: fail a statement that waits this long on any lock instead of
# parking behind a wedged holder (default 5000).
# BUZZ_DB_LOCK_TIMEOUT_MS=5000
# idle_in_transaction_session_timeout: reap sessions idle inside an open
# transaction — bounds how long a wedged client can hold locks (default 60000).
# BUZZ_DB_IDLE_TXN_TIMEOUT_MS=60000
# statement_timeout: cap any single statement's runtime. Off by default —
# startup migrations/backfills legitimately run long statements. Warning: a
# pathologically low value (e.g. 1) also times out connection setup and can
# prevent any DB connection from establishing.
# BUZZ_DB_STATEMENT_TIMEOUT_MS=0
Comment thread
TheSentinel454 marked this conversation as resolved.

# -----------------------------------------------------------------------------
# Typesense (search)
# -----------------------------------------------------------------------------
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ jobs:
-p buzz-relay \
-p buzz-test-client \
--lib \
--bin buzz-relay \
--test e2e_event_reminder \
--archive-file target/ci/backend-integration-tests.tar.zst
- name: Save relay artifacts cache
Expand Down Expand Up @@ -709,6 +710,31 @@ jobs:
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Writer session timeout guardrails
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-db) and test(session_timeouts_install_through_db_new_and_bound_lock_waits)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Audit writer session timeout guardrails
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Audit worker lock-timeout recovery
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(audit_worker_retries_lock_timeout_until_original_entry_is_appended_once)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
Expand Down
11 changes: 7 additions & 4 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,13 @@ async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {
async fn connect_db() -> Result<Db> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
let db = Db::new(&DbConfig {
database_url: db_url,
..DbConfig::default()
})
let db = Db::new(
&DbConfig {
database_url: db_url,
..DbConfig::default()
}
.with_session_timeouts_from_env(),
)
.await?;
Ok(db)
}
Expand Down
9 changes: 9 additions & 0 deletions crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ where
let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer)
.await?
.detach();
// This dedicated connection intentionally waits for the current migration
// or schema-destruction owner and may then run long DDL. Exempt those two
// phases from runtime lock/statement budgets. Keep the idle-in-transaction
// timeout: a client wedged idle mid-migration is still a lock holder that
// should be reaped. The detached connection is closed below and never
// returns these session settings to the pool.
sqlx::raw_sql("SET lock_timeout = 0; SET statement_timeout = 0")
.execute(&mut lock_conn)
.await?;
crate::observability::observe_advisory_lock(
crate::observability::LockType::MigrationSchemaSafety,
sqlx::query("SELECT pg_advisory_lock($1)")
Expand Down
79 changes: 75 additions & 4 deletions crates/buzz-db/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,16 @@ pub struct DbConfig {
/// than the staleness gate never routes anyway, so a larger budget
/// would only misrepresent the config.
pub replica_read_max_age_ms: u64,
/// Session `lock_timeout` in milliseconds for writer connections (env
/// `BUZZ_DB_LOCK_TIMEOUT_MS`). `0` disables the timeout.
pub lock_timeout_ms: u64,
/// Session `idle_in_transaction_session_timeout` in milliseconds for
/// writer connections (env `BUZZ_DB_IDLE_TXN_TIMEOUT_MS`). `0` disables.
pub idle_txn_timeout_ms: u64,
/// Session `statement_timeout` in milliseconds for writer connections
/// (env `BUZZ_DB_STATEMENT_TIMEOUT_MS`). `0` disables it and is the
/// default because migrations and backfills may legitimately run long.
pub statement_timeout_ms: u64,
}

impl Default for DbConfig {
Expand All @@ -471,10 +481,47 @@ impl Default for DbConfig {
max_lifetime_secs: 1800,
idle_timeout_secs: 600,
replica_read_max_age_ms: 0,
lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS,
idle_txn_timeout_ms: DEFAULT_IDLE_TXN_TIMEOUT_MS,
statement_timeout_ms: 0,
}
}
}

/// Default writer `lock_timeout` in milliseconds.
pub const DEFAULT_LOCK_TIMEOUT_MS: u64 = 5_000;

/// Default writer `idle_in_transaction_session_timeout` in milliseconds.
pub const DEFAULT_IDLE_TXN_TIMEOUT_MS: u64 = 60_000;

impl DbConfig {
/// Overlay writer session timeouts from the shared `BUZZ_DB_*_TIMEOUT_MS`
/// environment variables. Missing or invalid values retain the existing
/// configuration; explicit zeroes pass through to disable a timeout.
///
/// This belongs in `buzz-db` so relay, admin, deletion, and audit writers
/// share one policy. The separately deployed push gateway owns its own
/// database and session policy.
pub fn with_session_timeouts_from_env(mut self) -> Self {
fn parse(key: &str) -> Option<u64> {
std::env::var(key)
.ok()
.and_then(|value| value.parse::<u64>().ok())
}

if let Some(value) = parse("BUZZ_DB_LOCK_TIMEOUT_MS") {
self.lock_timeout_ms = value;
}
if let Some(value) = parse("BUZZ_DB_IDLE_TXN_TIMEOUT_MS") {
self.idle_txn_timeout_ms = value;
}
if let Some(value) = parse("BUZZ_DB_STATEMENT_TIMEOUT_MS") {
self.statement_timeout_ms = value;
}
self
}
}

impl Db {
/// Creates a new `Db` by connecting a Postgres pool with the given config.
///
Expand All @@ -486,7 +533,7 @@ impl Db {
/// `buzz.created_at_floor` GUC — this is what makes the replica fence
/// proof hold for every insert path that goes through this pool.
pub async fn new(config: &DbConfig) -> Result<Self> {
let pool = Self::connect_pool(config, &config.database_url).await?;
let pool = Self::connect_writer_pool(config).await?;
let read_max_connections = config
.read_max_connections
.unwrap_or(config.max_connections);
Expand All @@ -511,20 +558,44 @@ impl Db {
/// SQLx stores one `after_connect` hook, so the floor guard and transaction
/// isolation assertion must remain in this single closure. Registering a
/// second hook replaces the first and silently disarms the floor trigger.
async fn connect_pool(config: &DbConfig, url: &str) -> Result<PgPool> {
/// Additional writer pools, including the relay audit pool, must use this
/// constructor so they inherit the timeout, floor-guard, and isolation
/// policy installed by [`Db::new`].
pub async fn connect_writer_pool(config: &DbConfig) -> Result<PgPool> {
let lock_timeout_ms = config.lock_timeout_ms;
let idle_txn_timeout_ms = config.idle_txn_timeout_ms;
let statement_timeout_ms = config.statement_timeout_ms;
let options = PgPoolOptions::new()
.max_connections(config.max_connections)
.min_connections(config.min_connections)
.acquire_timeout(Duration::from_secs(config.acquire_timeout_secs))
.max_lifetime(Duration::from_secs(config.max_lifetime_secs))
.idle_timeout(Duration::from_secs(config.idle_timeout_secs))
.after_connect(|conn, _meta| {
.after_connect(move |conn, _meta| {
Box::pin(async move {
// `SET` cannot take bind parameters; `set_config` can.
sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)")
.bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string())
.execute(&mut *conn)
.await?;
// `lock_timeout` fails the waiting statement; it does not
// cancel the holder. `idle_in_transaction_session_timeout`
// reaps only holders idling inside an open transaction,
// while actively executing holders are bounded only by
// `statement_timeout` (off by default). Bare values are
// milliseconds. Migration/schema-destruction connections
// reset lock and statement timeouts before their intentional
// long wait (see `with_exclusive_schema_destruction_lock`).
sqlx::query(
"SELECT set_config('lock_timeout', $1, false), \
set_config('idle_in_transaction_session_timeout', $2, false), \
set_config('statement_timeout', $3, false)",
)
.bind(lock_timeout_ms.to_string())
.bind(idle_txn_timeout_ms.to_string())
.bind(statement_timeout_ms.to_string())
.execute(&mut *conn)
.await?;
let isolation: String = sqlx::query_scalar("SHOW transaction_isolation")
.fetch_one(&mut *conn)
.await?;
Expand All @@ -539,7 +610,7 @@ impl Db {
Ok(())
})
});
Ok(options.connect(url).await?)
Ok(options.connect(&config.database_url).await?)
}

/// Reader acquire timeout — deliberately far below the writer's
Expand Down
Loading
Loading