Skip to content

Commit 5a32799

Browse files
fix(server): run migrations on a dedicated connection, decoupled from the query pool's 60s timeout (JEF-590) (#150)
sqlx::migrate! ran on the shared query pool and inherited its 60s statement_timeout with no lock_timeout at all — that 60s killed migration 0017's CREATE INDEX and crashlooped the pod (JEF-580), and a migration blocked on a lock would otherwise queue ahead of ingest indefinitely. db::migrate now opens its own PgConnection with lock_timeout=3s (abort fast rather than head-of-line-block a table) and statement_timeout=0 (unbounded, independent of the query pool's 60s bound — heavy/locking DDL belongs in the separate online-DDL lane per ADR 0021, so a migration run here is expected to stay short regardless). The query pool from connect() is unchanged. Adds a connection-level test: a competing transaction holds an ACCESS EXCLUSIVE lock on a throwaway table, and a trivial DDL statement on a connection configured like migrate()'s aborts within lock_timeout instead of hanging.
1 parent 5acc825 commit 5a32799

4 files changed

Lines changed: 104 additions & 6 deletions

File tree

server/src/db.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions};
1+
use sqlx::postgres::{PgConnectOptions, PgConnection, PgPool, PgPoolOptions};
2+
use sqlx::Connection;
23
use std::str::FromStr;
34
use std::time::Duration;
45

@@ -36,7 +37,102 @@ pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
3637
Ok(pool)
3738
}
3839

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?;
4164
Ok(())
4265
}
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+
}

server/src/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@ async fn main() -> anyhow::Result<()> {
130130
to: std::env::var("WATCHER_ALERT_SMTP_TO").unwrap_or_default(),
131131
});
132132

133+
// Migrate on its own connection (independent lock_timeout/statement_timeout,
134+
// see db::migrate) before opening the query pool's connections.
135+
db::migrate(&database_url).await?;
133136
let pool = db::connect(&database_url).await?;
134-
db::migrate(&pool).await?;
135137

136138
// Alert rules are declarative: WATCHER_ALERTS_CONFIG points at a JSON file
137139
// (rendered from the chart's values) that is the source of truth. Reconcile

server/src/otlp.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1240,7 +1240,7 @@ mod tests {
12401240
async fn pool_or_skip() -> Option<PgPool> {
12411241
let url = std::env::var("DATABASE_URL").ok()?;
12421242
let pool = crate::db::connect(&url).await.expect("connect");
1243-
crate::db::migrate(&pool).await.expect("migrate");
1243+
crate::db::migrate(&url).await.expect("migrate");
12441244
Some(pool)
12451245
}
12461246

server/tests/smoke.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ fn kv(key: &str, value: &str) -> KeyValue {
272272
async fn pool_or_skip() -> Option<sqlx::PgPool> {
273273
let url = std::env::var("DATABASE_URL").ok()?;
274274
let pool = db::connect(&url).await.expect("connect");
275-
db::migrate(&pool).await.expect("migrate");
275+
db::migrate(&url).await.expect("migrate");
276276
sqlx::query("TRUNCATE spans, logs, metrics, metric_series_rollups, alert_rules, alert_events")
277277
.execute(&pool)
278278
.await

0 commit comments

Comments
 (0)