Skip to main content

max / makenotwork

Count cascade-deleted test_details in prune result test_details has ON DELETE CASCADE on run_id and the pool runs with foreign_keys=ON, so deleting old test_runs already removed the details. The orphan sweep that produced the count could therefore only ever see rows the cascade missed, making PruneResult.test_details always ~0. Count the doomed rows before deleting their runs and keep the orphan sweep as a safety net for databases written with foreign_keys off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 19:19 UTC
Commit: 63efc42e07a049a75608c769e8c08105033c4d60
Parent: 0e7ac16
2 files changed, +51 insertions, -3 deletions
@@ -37,13 +37,26 @@ pub async fn prune_old_records(
37 37 .execute(pool)
38 38 .await?;
39 39
40 + // test_details has ON DELETE CASCADE on run_id and the pool runs with
41 + // foreign_keys=ON, so deleting the runs below already removes their details.
42 + // Count them first: the orphan sweep afterwards can only ever report rows the
43 + // cascade missed, which is why PruneResult.test_details always read ~0.
44 + let cascaded_details = sqlx::query_as::<_, (i64,)>(
45 + "SELECT COUNT(*) FROM test_details
46 + WHERE run_id IN (SELECT id FROM test_runs WHERE started_at < ?)",
47 + )
48 + .bind(&cutoff_str)
49 + .fetch_one(pool)
50 + .await?;
51 +
40 52 let test_result = sqlx::query("DELETE FROM test_runs WHERE started_at < ?")
41 53 .bind(&cutoff_str)
42 54 .execute(pool)
43 55 .await?;
44 56
45 - // Prune orphaned test_details (run was deleted above).
46 - let test_details_result = sqlx::query(
57 + // Safety net for details orphaned some other way (a database written while
58 + // foreign_keys was off). Normally zero.
59 + let orphan_result = sqlx::query(
47 60 "DELETE FROM test_details WHERE run_id NOT IN (SELECT id FROM test_runs)",
48 61 )
49 62 .execute(pool)
@@ -92,7 +105,7 @@ pub async fn prune_old_records(
92 105 Ok(PruneResult {
93 106 health: health_result.rows_affected(),
94 107 tests: test_result.rows_affected(),
95 - test_details: test_details_result.rows_affected(),
108 + test_details: cascaded_details.0 as u64 + orphan_result.rows_affected(),
96 109 heartbeats: peer_hb_result.rows_affected(),
97 110 alerts: alerts_result.rows_affected(),
98 111 tls: tls_result.rows_affected(),
@@ -1892,6 +1892,41 @@ async fn prune_with_days_one_keeps_today() {
1892 1892 assert_eq!(remaining[0].response_time_ms, 100);
1893 1893 }
1894 1894
1895 + #[tokio::test]
1896 + async fn prune_counts_cascade_deleted_test_details() {
1897 + let pool = db::connect_in_memory().await.unwrap();
1898 +
1899 + let run = TestRun {
1900 + id: None,
1901 + target: "mnw".to_string(),
1902 + started_at: (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(),
1903 + finished_at: None,
1904 + duration_secs: Some(120),
1905 + exit_code: Some(0),
1906 + passed: true,
1907 + summary: TestSummary {
1908 + steps: vec![],
1909 + total_passed: Some(3),
1910 + total_failed: Some(0),
1911 + details: vec![
1912 + TestDetail { test_name: "foo::bar".to_string(), passed: true },
1913 + TestDetail { test_name: "foo::baz".to_string(), passed: true },
1914 + TestDetail { test_name: "foo::qux".to_string(), passed: true },
1915 + ],
1916 + },
1917 + raw_output: String::new(),
1918 + filter: None,
1919 + };
1920 + let run_id = db::insert_test_run(&pool, &run).await.unwrap();
1921 + db::insert_test_details(&pool, run_id, &run.summary.details).await.unwrap();
1922 +
1923 + // The ON DELETE CASCADE removes the details along with the run; the count
1924 + // must still reflect them rather than reading 0 off an empty orphan sweep.
1925 + let result = db::prune_old_records(&pool, 7).await.unwrap();
1926 + assert_eq!(result.tests, 1);
1927 + assert_eq!(result.test_details, 3);
1928 + }
1929 +
1895 1930 // --- SSH timeout_secs config test ---
1896 1931
1897 1932 #[test]