Jump to…
snowfix(db): bound every pool liveness ping so a dead connection cannot strand its slotsszylowxqlqt1mo
Matt W1//! `df-db` — connection pooling, migrations, and shared row types.
Matt W2//!
Matt W3//! Every other crate talks to Postgres through this one so that pool
Matt W4//! configuration, migration locking, and the enum mappings live in one place.
Matt W5
Matt W6use std::time::Duration;
Matt W7
Matt W8use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
Matt W9use sqlx::{ConnectOptions, Connection};
Matt W10
Matt W11pub mod ids;
Matt W12pub mod models;
Matt W13
Matt W14pub use sqlx;
Matt W15pub use sqlx::PgPool;
Matt W16pub use sqlx::PgPool as Pool;
Matt W17
Matt W18/// Advisory lock key for schema migrations.
Matt W19///
Matt W20/// `web` runs migrations at startup (spec §10) and more than one instance may
Matt W21/// start at once, so the lock serialises them. The constant is arbitrary but
Matt W22/// must never change — a different value would let two versions migrate
Matt W23/// concurrently.
Matt W24const MIGRATION_LOCK_KEY: i64 = 0x0D06_F00D_0000_0001u64 as i64;
Matt W25
Matt W26/// How long a liveness `ping()` may take before we treat the connection as dead.
Matt W27///
Matt W28/// Comfortably longer than a healthy round trip to the pooler and well inside
Matt W29/// `acquire_timeout`, so discarding a dead connection still leaves most of the
Matt W30/// acquire budget to open a replacement.
Matt W31const PING_TIMEOUT: Duration = Duration::from_secs(2);
Matt W32
Matt W33/// Open the pool.
Matt W34///
Matt W35/// Statement logging is disabled below WARN: at INFO sqlx logs every statement,
Matt W36/// which on the push path means logging the full contents of an indexing
Matt W37/// transaction on every push.
Matt W38pub async fn connect(url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
Matt W39 let opts: PgConnectOptions = url
Matt W40 .parse::<PgConnectOptions>()?
Matt W41 .log_statements(tracing::log::LevelFilter::Debug)
Matt W42 .log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500))
Matt W43 .application_name("dogfood");
Matt W44
Matt W45 let pool = PgPoolOptions::new()
Matt W46 .max_connections(max_connections)
Matt W47 .min_connections(1)
Matt W48 .acquire_timeout(Duration::from_secs(10))
Matt W49 // No `idle_timeout` or `max_lifetime`, deliberately.
Matt W50 //
Matt W51 // Both are implemented by closing the connection from
Matt W52 // `PoolConnection::return_to_pool`, which awaits `PgConnection::close()`
Matt W53 // with no timeout of its own — unlike the `close_on_drop` path, which
Matt W54 // bounds it. `close()` sends Terminate and then awaits the stream
Matt W55 // shutdown. Against the pooler on :6543 the Terminate lands but the
Matt W56 // shutdown never completes, so the spawned task hangs forever still
Matt W57 // holding its pool slot, and the socket is never closed.
Matt W58 //
Matt W59 // With `max_lifetime` set to 30 minutes that leaked one slot every 30
Matt W60 // minutes, so every process died a fixed `max_connections * 30min`
Matt W61 // after boot: on 2026-08-03 the worker booted at 01:01 and started
Matt W62 // failing every acquire at 06:02. sqlx 0.8 has no keepalive option and
Matt W63 // `test_before_acquire` cannot rescue it either, because `ping()` is
Matt W64 // likewise unbounded and just burns the whole `acquire_timeout`.
Matt W65 //
Matt W66 // Recycling was never load-bearing: the instance allows 100
Matt W67 // connections and all three services together hold ~23.
Matt W68 //
Matt W69 // The built-in `test_before_acquire` is off for the same reason: its
Matt W70 // `ping()` is unbounded, so a connection the pooler dropped silently
Matt W71 // hangs the acquire instead of failing it. The hooks below do the same
Matt W72 // liveness check under `PING_TIMEOUT`.
Matt W73 //
Matt W74 // They must report a dead connection as `Err`, never `Ok(false)`:
Matt W75 // sqlx answers `Err` with `close_hard()`, which only awaits
Matt W76 // `stream.shutdown()`, but answers `Ok(false)` with the same unbounded
Matt W77 // `close()` that stranded slots above.
Matt W78 .test_before_acquire(false)
Matt W79 .before_acquire(|conn, _meta| Box::pin(async move { ping_bounded(conn).await.map(|()| true) }))
Matt W80 // `return_to_pool` pings unconditionally on release, and that ping is
Matt W81 // unbounded too — a connection that died while checked out would hang
Matt W82 // the spawned release task and strand the slot for good. Pinging here
Matt W83 // first means the unbounded one runs only against a connection that
Matt W84 // answered milliseconds ago.
Matt W85 .after_release(|conn, _meta| Box::pin(async move { ping_bounded(conn).await.map(|()| true) }))
Matt W86 .connect_with(opts)
Matt W87 .await?;
Matt W88
Matt W89 Ok(pool)
Matt W90}
Matt W91
Matt W92/// `ping()` under a timeout, reporting a timeout as an error.
Matt W93///
Matt W94/// Returning `Err` matters: it is what routes the connection to sqlx's bounded
Matt W95/// `close_hard()` rather than the `close()` that hangs against the pooler.
Matt W96async fn ping_bounded(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
Matt W97 match tokio::time::timeout(PING_TIMEOUT, conn.ping()).await {
Matt W98 Ok(result) => result,
Matt W99 Err(_elapsed) => Err(sqlx::Error::Io(std::io::Error::new(
Matt W100 std::io::ErrorKind::TimedOut,
Matt W101 "connection ping timed out",
Matt W102 ))),
Matt W103 }
Matt W104}
Matt W105
Matt W106/// Apply pending migrations under an advisory lock.
Matt W107///
Matt W108/// sqlx has its own locking, but it is per-migration; taking one session-level
Matt W109/// lock around the whole run means a second starting instance waits for the
Matt W110/// entire migration set rather than interleaving with it.
Matt W111pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> {
Matt W112 // A dedicated connection: the lock is session-scoped, so it must be held on
Matt W113 // one connection for the duration and released explicitly.
Matt W114 let mut conn = pool.acquire().await?;
Matt W115
Matt W116 tracing::info!("acquiring migration advisory lock");
Matt W117 sqlx::query("SELECT pg_advisory_lock($1)")
Matt W118 .bind(MIGRATION_LOCK_KEY)
Matt W119 .execute(&mut *conn)
Matt W120 .await?;
Matt W121
Matt W122 let result = sqlx::migrate!("../../migrations").run(&mut *conn).await;
Matt W123
Matt W124 // Release the lock whether or not migration succeeded, so a failed deploy
Matt W125 // does not wedge every subsequent start.
Matt W126 let unlock = sqlx::query("SELECT pg_advisory_unlock($1)")
Matt W127 .bind(MIGRATION_LOCK_KEY)
Matt W128 .execute(&mut *conn)
Matt W129 .await;
Matt W130
Matt W131 result?;
Matt W132 unlock?;
Matt W133
Matt W134 tracing::info!("migrations up to date");
Matt W135 Ok(())
Matt W136}
Matt W137
Matt W138/// Liveness check for `/readyz`.
Matt W139pub async fn ping(pool: &PgPool) -> anyhow::Result<()> {
Matt W140 sqlx::query_scalar::<_, i32>("SELECT 1")
Matt W141 .fetch_one(pool)
Matt W142 .await?;
Matt W143 Ok(())
Matt W144}

144 lines · Rust