max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+128 insertions,
-92 deletions
| @@ -6,7 +6,8 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use sqlx::postgres::PgPoolOptions; | |
| 8 | 8 | use sqlx::{Connection, Executor, PgConnection, PgPool}; | |
| 9 | - | use std::sync::{Once, OnceLock}; | |
| 9 | + | use std::panic::AssertUnwindSafe; | |
| 10 | + | use std::sync::OnceLock; | |
| 10 | 11 | use std::time::Duration; | |
| 11 | 12 | use uuid::Uuid; | |
| 12 | 13 | ||
| @@ -55,8 +56,29 @@ | |||
| 55 | 56 | .await; | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 58 | - | /// Ensures template creation runs exactly once, across all threads and runtimes. | |
| 59 | - | static TEMPLATE_INIT: Once = Once::new(); | |
| 59 | + | /// Ensures template creation runs exactly once, across all threads and runtimes, | |
| 60 | + | /// and remembers how it went. | |
| 61 | + | /// | |
| 62 | + | /// This was a `std::sync::Once`, which poisons when the closure panics. One | |
| 63 | + | /// failure to reach the database then reported its real cause on a single line | |
| 64 | + | /// out of ~15000, and the other 1207 tests said only "Once instance has | |
| 65 | + | /// previously been poisoned" and "template setup panicked: JoinError::Panic". | |
| 66 | + | /// Caching the setup error instead means every test after the first states the | |
| 67 | + | /// reason setup failed. | |
| 68 | + | static TEMPLATE_INIT: OnceLock<Result<(), String>> = OnceLock::new(); | |
| 69 | + | ||
| 70 | + | /// Render a caught panic payload as its message. | |
| 71 | + | fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { | |
| 72 | + | payload.downcast_ref::<&str>().map_or_else( | |
| 73 | + | || { | |
| 74 | + | payload | |
| 75 | + | .downcast_ref::<String>() | |
| 76 | + | .cloned() | |
| 77 | + | .unwrap_or_else(|| "template setup panicked".to_string()) | |
| 78 | + | }, | |
| 79 | + | |s| (*s).to_string(), | |
| 80 | + | ) | |
| 81 | + | } | |
| 60 | 82 | ||
| 61 | 83 | fn admin_url() -> String { | |
| 62 | 84 | std::env::var("TEST_DATABASE_URL") | |
| @@ -119,60 +141,68 @@ | |||
| 119 | 141 | /// Create the template database with all migrations. Runs in a dedicated | |
| 120 | 142 | /// single-threaded tokio runtime so it works from any context (including | |
| 121 | 143 | /// inside `#[tokio::test]` and plain `#[test]`). | |
| 122 | - | fn ensure_template() { | |
| 123 | - | TEMPLATE_INIT.call_once(|| { | |
| 124 | - | let rt = tokio::runtime::Builder::new_current_thread() | |
| 125 | - | .enable_all() | |
| 126 | - | .build() | |
| 127 | - | .expect("build template setup runtime"); | |
| 144 | + | fn ensure_template() -> Result<(), String> { | |
| 145 | + | TEMPLATE_INIT | |
| 146 | + | .get_or_init(|| { | |
| 147 | + | std::panic::catch_unwind(AssertUnwindSafe(template_setup)) | |
| 148 | + | .map_err(|payload| panic_message(payload.as_ref())) | |
| 149 | + | }) | |
| 150 | + | .clone() | |
| 151 | + | } | |
| 128 | 152 | ||
| 129 | - | rt.block_on(async { | |
| 130 | - | let t0 = std::time::Instant::now(); | |
| 131 | - | let admin = admin_url(); | |
| 132 | - | let mut conn = PgConnection::connect(&admin) | |
| 133 | - | .await | |
| 134 | - | .expect("connect to admin DB for template setup"); | |
| 153 | + | /// The template setup itself. Panics on failure; `ensure_template` catches that | |
| 154 | + | /// and caches the message. | |
| 155 | + | fn template_setup() { | |
| 156 | + | let rt = tokio::runtime::Builder::new_current_thread() | |
| 157 | + | .enable_all() | |
| 158 | + | .build() | |
| 159 | + | .expect("build template setup runtime"); | |
| 135 | 160 | ||
| 136 | - | // Adopt the shared role so the template (and every clone) is owned | |
| 137 | - | // by `mnw_test`, droppable by any member. No-ops on hosts without | |
| 138 | - | // the role, falling back to per-role namespacing below. | |
| 139 | - | assume_shared_role(&mut conn).await; | |
| 161 | + | rt.block_on(async { | |
| 162 | + | let t0 = std::time::Instant::now(); | |
| 163 | + | let admin = admin_url(); | |
| 164 | + | let mut conn = PgConnection::connect(&admin) | |
| 165 | + | .await | |
| 166 | + | .expect("connect to admin DB for template setup"); | |
| 140 | 167 | ||
| 141 | - | // Namespace the template by the *effective* role: with the shared | |
| 142 | - | // role assumed this collapses to one template for everyone; without | |
| 143 | - | // it, each login role gets its own so a developer's `max` run and | |
| 144 | - | // the Sando gate's `sando` run never collide on a shared cluster. | |
| 145 | - | let (role,): (String,) = sqlx::query_as("SELECT current_user") | |
| 146 | - | .fetch_one(&mut conn) | |
| 147 | - | .await | |
| 148 | - | .expect("query current_user for template namespacing"); | |
| 149 | - | let template = format!("{TEMPLATE_DB_BASE}_{}", sanitize_role(&role)); | |
| 150 | - | let _ = TEMPLATE_NAME.set(template.clone()); | |
| 168 | + | // Adopt the shared role so the template (and every clone) is owned | |
| 169 | + | // by `mnw_test`, droppable by any member. No-ops on hosts without | |
| 170 | + | // the role, falling back to per-role namespacing below. | |
| 171 | + | assume_shared_role(&mut conn).await; | |
| 151 | 172 | ||
| 152 | - | // Serialize template setup across ALL processes on this cluster, not | |
| 153 | - | // just this process's `Once`. Without it, concurrent test binaries | |
| 154 | - | // each unconditionally drop+recreate the SAME shared-role template, | |
| 155 | - | // so one drops the template another is mid-clone from, the mass | |
| 156 | - | // "template database ... does not exist" flake on the deploy gate. | |
| 157 | - | // Held until `conn` drops at the end of this block. | |
| 158 | - | sqlx::query("SELECT pg_advisory_lock($1)") | |
| 159 | - | .bind(TEMPLATE_LOCK_KEY) | |
| 160 | - | .execute(&mut conn) | |
| 161 | - | .await | |
| 162 | - | .expect("acquire template advisory lock"); | |
| 173 | + | // Namespace the template by the *effective* role: with the shared | |
| 174 | + | // role assumed this collapses to one template for everyone; without | |
| 175 | + | // it, each login role gets its own so a developer's `max` run and | |
| 176 | + | // the Sando gate's `sando` run never collide on a shared cluster. | |
| 177 | + | let (role,): (String,) = sqlx::query_as("SELECT current_user") | |
| 178 | + | .fetch_one(&mut conn) | |
| 179 | + | .await | |
| 180 | + | .expect("query current_user for template namespacing"); | |
| 181 | + | let template = format!("{TEMPLATE_DB_BASE}_{}", sanitize_role(&role)); | |
| 182 | + | let _ = TEMPLATE_NAME.set(template.clone()); | |
| 163 | 183 | ||
| 164 | - | // Reuse the template when it's already migration-current. Dropping a | |
| 165 | - | // live template is what races concurrent clones, and a FORCE drop | |
| 166 | - | // also fails when another role holds connections we can't terminate | |
| 167 | - | // ("permission denied to terminate process"). Only rebuild when the | |
| 168 | - | // template is missing or its migration head is stale. | |
| 169 | - | if template_is_current(&admin, &template).await { | |
| 170 | - | eprintln!("[test-harness] Reusing current template DB {template}"); | |
| 171 | - | } else { | |
| 172 | - | // We hold the cross-process lock, so no clone can be reading it. | |
| 173 | - | conn.execute( | |
| 174 | - | format!("DROP DATABASE IF EXISTS \"{template}\" WITH (FORCE)").as_str(), | |
| 175 | - | ) | |
| 184 | + | // Serialize template setup across ALL processes on this cluster, not | |
| 185 | + | // just this process's `Once`. Without it, concurrent test binaries | |
| 186 | + | // each unconditionally drop+recreate the SAME shared-role template, | |
| 187 | + | // so one drops the template another is mid-clone from, the mass | |
| 188 | + | // "template database ... does not exist" flake on the deploy gate. | |
| 189 | + | // Held until `conn` drops at the end of this block. | |
| 190 | + | sqlx::query("SELECT pg_advisory_lock($1)") | |
| 191 | + | .bind(TEMPLATE_LOCK_KEY) | |
| 192 | + | .execute(&mut conn) | |
| 193 | + | .await | |
| 194 | + | .expect("acquire template advisory lock"); | |
| 195 | + | ||
| 196 | + | // Reuse the template when it's already migration-current. Dropping a | |
| 197 | + | // live template is what races concurrent clones, and a FORCE drop | |
| 198 | + | // also fails when another role holds connections we can't terminate | |
| 199 | + | // ("permission denied to terminate process"). Only rebuild when the | |
| 200 | + | // template is missing or its migration head is stale. | |
| 201 | + | if template_is_current(&admin, &template).await { | |
| 202 | + | eprintln!("[test-harness] Reusing current template DB {template}"); | |
| 203 | + | } else { | |
| 204 | + | // We hold the cross-process lock, so no clone can be reading it. | |
| 205 | + | conn.execute(format!("DROP DATABASE IF EXISTS \"{template}\" WITH (FORCE)").as_str()) | |
| 176 | 206 | .await | |
| 177 | 207 | .unwrap_or_else(|e| { | |
| 178 | 208 | panic!( | |
| @@ -181,49 +211,51 @@ | |||
| 181 | 211 | ) | |
| 182 | 212 | }); | |
| 183 | 213 | ||
| 184 | - | conn.execute(format!("CREATE DATABASE \"{template}\"").as_str()) | |
| 185 | - | .await | |
| 186 | - | .expect("create template database"); | |
| 214 | + | conn.execute(format!("CREATE DATABASE \"{template}\"").as_str()) | |
| 215 | + | .await | |
| 216 | + | .expect("create template database"); | |
| 187 | 217 | ||
| 188 | - | // Connect to the template and run all migrations | |
| 189 | - | let tpl_url = replace_db_name(&admin, &template); | |
| 190 | - | let tpl_pool = PgPoolOptions::new() | |
| 191 | - | .max_connections(2) | |
| 192 | - | .acquire_timeout(Duration::from_secs(10)) | |
| 193 | - | .connect(&tpl_url) | |
| 194 | - | .await | |
| 195 | - | .expect("connect to template database"); | |
| 218 | + | // Connect to the template and run all migrations | |
| 219 | + | let tpl_url = replace_db_name(&admin, &template); | |
| 220 | + | let tpl_pool = PgPoolOptions::new() | |
| 221 | + | .max_connections(2) | |
| 222 | + | .acquire_timeout(Duration::from_secs(10)) | |
| 223 | + | .connect(&tpl_url) | |
| 224 | + | .await | |
| 225 | + | .expect("connect to template database"); | |
| 196 | 226 | ||
| 197 | - | let t_migrate = std::time::Instant::now(); | |
| 198 | - | #[allow(clippy::large_stack_arrays, reason = "sqlx::migrate! embeds the whole directory")] | |
| 199 | - | sqlx::migrate!("./migrations") | |
| 200 | - | .run(&tpl_pool) | |
| 201 | - | .await | |
| 202 | - | .expect("run migrations on template"); | |
| 203 | - | let migrate_ms = t_migrate.elapsed().as_millis(); | |
| 227 | + | let t_migrate = std::time::Instant::now(); | |
| 228 | + | #[allow( | |
| 229 | + | clippy::large_stack_arrays, | |
| 230 | + | reason = "sqlx::migrate! embeds the whole directory" | |
| 231 | + | )] | |
| 232 | + | sqlx::migrate!("./migrations") | |
| 233 | + | .run(&tpl_pool) | |
| 234 | + | .await | |
| 235 | + | .expect("run migrations on template"); | |
| 236 | + | let migrate_ms = t_migrate.elapsed().as_millis(); | |
| 204 | 237 | ||
| 205 | - | // Also create the session store table | |
| 206 | - | let session_store = tower_sessions_sqlx_store::PostgresStore::new(tpl_pool.clone()); | |
| 207 | - | session_store | |
| 208 | - | .migrate() | |
| 209 | - | .await | |
| 210 | - | .expect("session store migration on template"); | |
| 238 | + | // Also create the session store table | |
| 239 | + | let session_store = tower_sessions_sqlx_store::PostgresStore::new(tpl_pool.clone()); | |
| 240 | + | session_store | |
| 241 | + | .migrate() | |
| 242 | + | .await | |
| 243 | + | .expect("session store migration on template"); | |
| 211 | 244 | ||
| 212 | - | tpl_pool.close().await; | |
| 245 | + | tpl_pool.close().await; | |
| 213 | 246 | ||
| 214 | - | let total_ms = t0.elapsed().as_millis(); | |
| 215 | - | eprintln!( | |
| 216 | - | "[test-harness] Template DB created in {total_ms}ms (migrations: {migrate_ms}ms)" | |
| 217 | - | ); | |
| 218 | - | } | |
| 247 | + | let total_ms = t0.elapsed().as_millis(); | |
| 248 | + | eprintln!( | |
| 249 | + | "[test-harness] Template DB created in {total_ms}ms (migrations: {migrate_ms}ms)" | |
| 250 | + | ); | |
| 251 | + | } | |
| 219 | 252 | ||
| 220 | - | // Release explicitly (also released when `conn` drops); the lock was | |
| 221 | - | // held across the whole reuse-or-rebuild window above. | |
| 222 | - | let _ = sqlx::query("SELECT pg_advisory_unlock($1)") | |
| 223 | - | .bind(TEMPLATE_LOCK_KEY) | |
| 224 | - | .execute(&mut conn) | |
| 225 | - | .await; | |
| 226 | - | }); | |
| 253 | + | // Release explicitly (also released when `conn` drops); the lock was | |
| 254 | + | // held across the whole reuse-or-rebuild window above. | |
| 255 | + | let _ = sqlx::query("SELECT pg_advisory_unlock($1)") | |
| 256 | + | .bind(TEMPLATE_LOCK_KEY) | |
| 257 | + | .execute(&mut conn) | |
| 258 | + | .await; | |
| 227 | 259 | }); | |
| 228 | 260 | } | |
| 229 | 261 | ||
| @@ -244,9 +276,13 @@ | |||
| 244 | 276 | // ensure_template uses std::sync::Once + its own runtime, safe from any context. | |
| 245 | 277 | // When called from an async context, we run it on a blocking thread to avoid | |
| 246 | 278 | // nesting runtimes. | |
| 247 | - | tokio::task::spawn_blocking(ensure_template) | |
| 248 | - | .await | |
| 249 | - | .expect("template setup panicked"); | |
| 279 | + | // The setup error is cached, so every test after the first failure | |
| 280 | + | // reports the reason setup failed rather than a poisoned-Once message. | |
| 281 | + | match tokio::task::spawn_blocking(ensure_template).await { | |
| 282 | + | Ok(Ok(())) => {} | |
| 283 | + | Ok(Err(e)) => panic!("test database setup failed: {e}"), | |
| 284 | + | Err(e) => panic!("test database setup task failed: {e}"), | |
| 285 | + | } | |
| 250 | 286 | ||
| 251 | 287 | let t0 = std::time::Instant::now(); | |
| 252 | 288 | let admin = admin_url(); |