|
1 | | -use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; |
| 1 | +use sqlx::postgres::{PgConnectOptions, PgConnection, PgPool, PgPoolOptions}; |
| 2 | +use sqlx::Connection; |
2 | 3 | use std::str::FromStr; |
3 | 4 | use std::time::Duration; |
4 | 5 |
|
@@ -36,7 +37,102 @@ pub async fn connect(url: &str) -> anyhow::Result<PgPool> { |
36 | 37 | Ok(pool) |
37 | 38 | } |
38 | 39 |
|
39 | | -pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> { |
40 | | - sqlx::migrate!("./migrations").run(pool).await?; |
| 40 | +/// Run pending migrations on a single dedicated connection — deliberately |
| 41 | +/// NOT the query pool from [`connect`] above, so a migration never inherits |
| 42 | +/// that pool's 60s `statement_timeout` or waits behind ingest traffic for a |
| 43 | +/// lock. (JEF-580: migration 0017's `CREATE INDEX` ran on the shared pool, |
| 44 | +/// inherited the 60s bound, and was killed mid-run — crashlooping the pod.) |
| 45 | +/// |
| 46 | +/// * `lock_timeout=3s`: a migration that can't acquire the lock it needs |
| 47 | +/// (e.g. blocked behind a long-running transaction on the target table) |
| 48 | +/// aborts fast and fails startup loudly, instead of queuing ahead of |
| 49 | +/// ingest and head-of-line-blocking that table for as long as the |
| 50 | +/// competing lock is held. |
| 51 | +/// * `statement_timeout=0` (unbounded), decoupled from the query pool's 60s: |
| 52 | +/// a legitimate transactional migration on a large table shouldn't be |
| 53 | +/// capped at the app's query-latency bound. The policy (ADR 0021) is that |
| 54 | +/// heavy/locking DDL — anything that needs `CREATE INDEX CONCURRENTLY` or |
| 55 | +/// similarly can't run inside a migration's transaction — belongs in the |
| 56 | +/// separate online-DDL lane, not here, so what runs via `sqlx::migrate!` |
| 57 | +/// is expected to stay short regardless of the unbounded timeout. |
| 58 | +/// |
| 59 | +/// Migrations apply serially at startup, so one connection — not a pool — |
| 60 | +/// is all this needs. |
| 61 | +pub async fn migrate(url: &str) -> anyhow::Result<()> { |
| 62 | + let mut conn = PgConnection::connect_with(&migrate_connect_options(url)?).await?; |
| 63 | + sqlx::migrate!("./migrations").run(&mut conn).await?; |
41 | 64 | Ok(()) |
42 | 65 | } |
| 66 | + |
| 67 | +/// The connect options [`migrate`] runs on — split out so a test can open a |
| 68 | +/// connection with these exact settings without going through a real migration. |
| 69 | +fn migrate_connect_options(url: &str) -> anyhow::Result<PgConnectOptions> { |
| 70 | + Ok(PgConnectOptions::from_str(url)? |
| 71 | + .options([("lock_timeout", "3s"), ("statement_timeout", "0")])) |
| 72 | +} |
| 73 | + |
| 74 | +#[cfg(test)] |
| 75 | +mod tests { |
| 76 | + use super::{migrate_connect_options, PgConnection}; |
| 77 | + use sqlx::Connection; |
| 78 | + use std::time::{Duration, Instant}; |
| 79 | + |
| 80 | + /// JEF-580/590: a migration that can't get the lock it needs must abort within |
| 81 | + /// `lock_timeout`, not queue behind the holder and head-of-line-block the |
| 82 | + /// table. Proven at the connection level per the ticket (no real migration |
| 83 | + /// file needed): a competing transaction takes an ACCESS EXCLUSIVE lock on a |
| 84 | + /// throwaway table, and a trivial DDL statement on a connection configured |
| 85 | + /// exactly like `migrate`'s must fail fast with a lock_timeout error rather |
| 86 | + /// than hang for the test's duration. |
| 87 | + #[tokio::test] |
| 88 | + async fn migrate_connection_aborts_fast_on_a_held_lock() { |
| 89 | + let Ok(url) = std::env::var("DATABASE_URL") else { |
| 90 | + eprintln!("skipping: DATABASE_URL not set"); |
| 91 | + return; |
| 92 | + }; |
| 93 | + |
| 94 | + // Holds a conflicting lock on a throwaway table until we roll it back below. |
| 95 | + let mut blocker = PgConnection::connect(&url).await.expect("connect blocker"); |
| 96 | + sqlx::query("CREATE TABLE IF NOT EXISTS jef_590_lock_test (id int)") |
| 97 | + .execute(&mut blocker) |
| 98 | + .await |
| 99 | + .expect("create throwaway table"); |
| 100 | + sqlx::query("BEGIN") |
| 101 | + .execute(&mut blocker) |
| 102 | + .await |
| 103 | + .expect("begin"); |
| 104 | + sqlx::query("LOCK TABLE jef_590_lock_test IN ACCESS EXCLUSIVE MODE") |
| 105 | + .execute(&mut blocker) |
| 106 | + .await |
| 107 | + .expect("lock"); |
| 108 | + |
| 109 | + // A connection with exactly migrate()'s settings, attempting a trivial DDL |
| 110 | + // against the table the blocker holds locked. |
| 111 | + let opts = migrate_connect_options(&url).expect("connect options"); |
| 112 | + let mut migrate_conn = PgConnection::connect_with(&opts) |
| 113 | + .await |
| 114 | + .expect("connect migrate-style"); |
| 115 | + |
| 116 | + let start = Instant::now(); |
| 117 | + let result = sqlx::query("ALTER TABLE jef_590_lock_test ADD COLUMN probe int") |
| 118 | + .execute(&mut migrate_conn) |
| 119 | + .await; |
| 120 | + let elapsed = start.elapsed(); |
| 121 | + |
| 122 | + // Release the lock and clean up regardless of what the assertions below find. |
| 123 | + let _ = sqlx::query("ROLLBACK").execute(&mut blocker).await; |
| 124 | + let _ = sqlx::query("DROP TABLE IF EXISTS jef_590_lock_test") |
| 125 | + .execute(&mut blocker) |
| 126 | + .await; |
| 127 | + |
| 128 | + let err = result.expect_err("DDL against a held conflicting lock must error, not hang"); |
| 129 | + assert!( |
| 130 | + err.to_string().to_lowercase().contains("lock timeout"), |
| 131 | + "expected a lock_timeout error, got: {err}" |
| 132 | + ); |
| 133 | + assert!( |
| 134 | + elapsed < Duration::from_secs(10), |
| 135 | + "must abort within lock_timeout (3s), took {elapsed:?}" |
| 136 | + ); |
| 137 | + } |
| 138 | +} |
0 commit comments