Skip to main content

max / makenotwork

pom: batch migrations via raw_sql; make two-statement writes atomic - run_one_migration used split(';'), which breaks on the first ; inside a trigger body or string literal. Execute the migration through sqlx::raw_sql so SQLite's own parser splits statements — the first trigger migration is now safe rather than a startup failure. - insert_test_details wraps its N inserts in one transaction (a crash mid-loop left a partial detail set that skewed get_test_regressions). - CloseAndOpen incident handling gets a transactional close_and_open_incident so a crash between the close and the re-open can't lose the new incident. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 17:32 UTC
Commit: 8f36dfa05debff054577bef73ff49049504468a9
Parent: 276f3d1
2 files changed, +50 insertions, -13 deletions
@@ -93,11 +93,8 @@ pub(crate) fn spawn_health_tasks(
93 93 }
94 94 }
95 95 IncidentAction::CloseAndOpen => {
96 - if let Err(e) = db::close_open_incidents(&pool, &name).await {
97 - tracing::error!("{name}: failed to close incidents: {e}");
98 - }
99 - if let Err(e) = db::insert_incident(&pool, &name, &from_status.to_string(), &to_status.to_string()).await {
100 - tracing::error!("{name}: failed to open incident: {e}");
96 + if let Err(e) = db::close_and_open_incident(&pool, &name, &from_status.to_string(), &to_status.to_string()).await {
97 + tracing::error!("{name}: failed to close+open incident: {e}");
101 98 }
102 99 }
103 100 }
M pom/src/db.rs +48 -8
@@ -297,13 +297,13 @@ async fn run_one_migration(
297 297
298 298 let mut tx = pool.begin().await?;
299 299
300 - // Execute each statement in the migration SQL
301 - for statement in sql.split(';') {
302 - let trimmed = statement.trim();
303 - if !trimmed.is_empty() {
304 - sqlx::query(trimmed).execute(&mut *tx).await?;
305 - }
306 - }
300 + // Execute the whole migration as a batch via `raw_sql`, which hands the
301 + // string to SQLite's own parser to split on statement boundaries. A naive
302 + // `split(';')` breaks on the first `;` inside a trigger body or string
303 + // literal (fuzz-2026-07-06 migration split trap); today's migrations have
304 + // none, but this makes the first such migration safe rather than a startup
305 + // failure.
306 + sqlx::raw_sql(sql).execute(&mut *tx).await?;
307 307
308 308 // Record the version inside the same transaction
309 309 let now = chrono::Utc::now().to_rfc3339();
@@ -494,6 +494,10 @@ pub async fn insert_test_details(
494 494 run_id: TestRunId,
495 495 details: &[TestDetail],
496 496 ) -> Result<()> {
497 + // One transaction for the whole detail set: a crash mid-loop otherwise left a
498 + // partial set, which skews get_test_regressions (fuzz-2026-07-06 crash
499 + // atomicity). All-or-nothing.
500 + let mut tx = pool.begin().await?;
497 501 for detail in details {
498 502 sqlx::query(
499 503 "INSERT INTO test_details (run_id, test_name, passed) VALUES (?, ?, ?)",
@@ -501,9 +505,10 @@ pub async fn insert_test_details(
501 505 .bind(run_id.0)
502 506 .bind(&detail.test_name)
503 507 .bind(detail.passed)
504 - .execute(pool)
508 + .execute(&mut *tx)
505 509 .await?;
506 510 }
511 + tx.commit().await?;
507 512 Ok(())
508 513 }
509 514
@@ -940,6 +945,41 @@ pub async fn close_open_incidents(
940 945 Ok(result.rows_affected())
941 946 }
942 947
948 + /// Close any open incidents for `target` and open a new one, atomically. A status
949 + /// change like `error -> unreachable` closes the old incident and opens the new
950 + /// one; done as two pool calls, a crash between them left the old incident closed
951 + /// and the new one never recorded (fuzz-2026-07-06 crash atomicity). One tx.
952 + #[instrument(skip_all)]
953 + pub async fn close_and_open_incident(
954 + pool: &SqlitePool,
955 + target: &str,
956 + from_status: &str,
957 + to_status: &str,
958 + ) -> Result<()> {
959 + let now = chrono::Utc::now().to_rfc3339();
960 + let mut tx = pool.begin().await?;
961 + sqlx::query(
962 + "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER)
963 + WHERE target = ? AND ended_at IS NULL",
964 + )
965 + .bind(&now)
966 + .bind(&now)
967 + .bind(target)
968 + .execute(&mut *tx)
969 + .await?;
970 + sqlx::query(
971 + "INSERT INTO incidents (target, started_at, from_status, to_status) VALUES (?, ?, ?, ?)",
972 + )
973 + .bind(target)
974 + .bind(&now)
975 + .bind(from_status)
976 + .bind(to_status)
977 + .execute(&mut *tx)
978 + .await?;
979 + tx.commit().await?;
980 + Ok(())
981 + }
982 +
943 983 #[instrument(skip_all)]
944 984 pub async fn get_open_incident(
945 985 pool: &SqlitePool,