use anyhow::{Context, Result}; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; /// Keep in step with `.env.example` and the `db` sizing comment in `docker-compose.yml`. /// These three drifted apart once (code 10 / `.env.example` 15 / runbook 30) and the runbook /// presented its number as authoritative, so the contradiction was invisible at deploy time. /// 15 is sized to 2 vCPU and the 1G `db` memory limit — raise it only alongside both. const DEFAULT_MAX_CONNECTIONS: u32 = 15; /// SQLSTATE for `invalid_password`. const PG_INVALID_PASSWORD: &str = "28P01"; /// Turn the one connect failure with an unguessable cause into a self-explaining one. /// /// `POSTGRES_PASSWORD` is honoured ONLY when Postgres initialises its data directory. Change it in /// `.env` afterwards and the app authenticates with the new password against a volume that still /// holds the old one — a permanent restart loop whose only symptom is /// `password authentication failed`. /// /// The production secret guard makes that sequence NEARLY CERTAIN rather than rare: it stops the /// app on the first `docker compose up -d`, but not the `db` service in that same command, which /// initialises and bakes in whatever password was in `.env` at that moment. So the intended /// recovery — see the refusal, fix your secrets, boot again — is exactly the sequence that breaks /// it. Nothing in the error names the cause, and the remedy destroys data, so it is the last thing /// an operator should guess at. fn explain_auth_failure(err: &sqlx::Error) { let is_auth_failure = match err { sqlx::Error::Database(db) => db.code().as_deref() == Some(PG_INVALID_PASSWORD), _ => false, }; if !is_auth_failure { return; } tracing::error!( "Postgres rejected the credentials in DATABASE_URL (SQLSTATE {PG_INVALID_PASSWORD}).\n\ \n\ This almost always means POSTGRES_PASSWORD was changed AFTER the database volume was \ first created. Postgres applies that variable only when it initialises its data \ directory; editing .env and restarting does not change the stored password, so the two \ drift apart permanently.\n\ \n\ If the event has NOT started and you have no data worth keeping:\n\n \ docker compose down -v && docker compose up -d\n\n\ (-v DELETES the database, the uploaded media and the exports. There is no undo.)\n\ \n\ If you DO have data: restore the old password into DATABASE_URL instead, or change the \ stored one with ALTER ROLE inside the running db container. Never reach for -v to fix a \ login problem on a live event." ); } pub async fn create_pool(database_url: &str) -> Result { // A malformed value must not silently become the default: an operator who typed // `DATABASE_MAX_CONNECTIONS=3O` (letter O) would otherwise get 15 with no indication, // and would keep tuning a knob that never took effect. let max_connections = match std::env::var("DATABASE_MAX_CONNECTIONS") { Err(_) => DEFAULT_MAX_CONNECTIONS, Ok(raw) => match raw.trim().parse::() { Ok(0) => { anyhow::bail!("DATABASE_MAX_CONNECTIONS must be at least 1 (got 0)"); } Ok(n) => n, Err(e) => { anyhow::bail!( "DATABASE_MAX_CONNECTIONS must be a positive integer (got {raw:?}): {e}" ); } }, }; let pool = match PgPoolOptions::new() .max_connections(max_connections) // Fail fast instead of parking. sqlx's default is 30s, which on a DB blip means every // request AND all ~100 SSE session revalidations sit on the pool for half a minute // before erroring — the app looks hung rather than degraded, and the backlog outlives // the blip. Five seconds is far longer than a healthy acquire ever takes. .acquire_timeout(std::time::Duration::from_secs(5)) // Keep a couple of connections warm so the first request after an idle stretch (the gap // between setting the venue up and the guests arriving) doesn't pay TCP + auth. .min_connections(2) // Bound every statement server-side. Without this a single pathological query holds a // pool slot indefinitely and no client-side timeout can take it back — the slot is only // released when Postgres finishes. `lock_timeout` covers the same hazard for a row lock // contended by, say, a release running against an in-flight upload. .after_connect(|conn, _meta| { Box::pin(async move { // Two statements, two round-trips, deliberately. `sqlx::query` uses the extended // query protocol, which permits exactly ONE statement per call — sending them as // `SET a; SET b` makes every new connection fail, which surfaces as the pool // never opening one at all and `create_pool` reporting a connect timeout. sqlx::query("SET statement_timeout = '15s'") .execute(&mut *conn) .await?; sqlx::query("SET lock_timeout = '5s'").execute(conn).await?; Ok(()) }) }) .connect(database_url) .await { Ok(pool) => pool, Err(e) => { explain_auth_failure(&e); return Err(e).context("failed to connect to database"); } }; // Migrations run on their OWN connection, deliberately NOT from the pool. // // `after_connect` above puts `lock_timeout = 5s` on every pooled connection, and the migrator // would inherit it. Migrations that take ACCESS EXCLUSIVE (026's index swap, 027's ADD COLUMN) // then turn a short WAIT into a hard FAILURE: anything holding ACCESS SHARE on `upload` or // `"user"` for more than five seconds — the hourly `pg_dump` the runbook installs in §10.2, or // an operator's open `psql` transaction — aborts the migration, `create_pool` returns an // error, `main` exits 1, and `restart: unless-stopped` crash-loops the app behind a live Caddy. // The rollback is clean and a later retry succeeds, which is exactly what makes it a confusing // intermittent outage rather than an obvious one. // // `statement_timeout` is left off here too: a migration on a real table can legitimately run // longer than the 15s a request is allowed. let mut migrator_conn = ::connect(database_url) .await .context("failed to open a connection for migrations")?; sqlx::migrate!() .run(&mut migrator_conn) .await .context("failed to run database migrations")?; let _ = sqlx::Connection::close(migrator_conn).await; tracing::info!(max_connections, "database connected and migrations applied"); Ok(pool) }