max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+846 insertions,
-43 deletions
| @@ -1228,7 +1228,7 @@ | |||
| 1228 | 1228 | ||
| 1229 | 1229 | [[package]] | |
| 1230 | 1230 | name = "sando-daemon" | |
| 1231 | - | version = "0.2.13" | |
| 1231 | + | version = "0.2.14" | |
| 1232 | 1232 | dependencies = [ | |
| 1233 | 1233 | "anyhow", | |
| 1234 | 1234 | "async-trait", | |
| @@ -2270,29 +2270,37 @@ | |||
| 2270 | 2270 | name = "docengine" | |
| 2271 | 2271 | version = "0.7.0" | |
| 2272 | 2272 | ||
| 2273 | + | [[patch.unused]] | |
| 2274 | + | name = "synckit-client" | |
| 2275 | + | version = "0.9.1" | |
| 2276 | + | ||
| 2277 | + | [[patch.unused]] | |
| 2278 | + | name = "synckit-config" | |
| 2279 | + | version = "0.2.0" | |
| 2280 | + | ||
| 2273 | 2281 | [[patch.unused]] | |
| 2274 | 2282 | name = "quasi-axum" | |
| 2275 | - | version = "0.56.0" | |
| 2283 | + | version = "0.59.0" | |
| 2276 | 2284 | ||
| 2277 | 2285 | [[patch.unused]] | |
| 2278 | 2286 | name = "quasi-basics" | |
| 2279 | - | version = "0.56.0" | |
| 2287 | + | version = "0.59.0" | |
| 2280 | 2288 | ||
| 2281 | 2289 | [[patch.unused]] | |
| 2282 | 2290 | name = "quasi-http" | |
| 2283 | - | version = "0.56.0" | |
| 2291 | + | version = "0.59.0" | |
| 2284 | 2292 | ||
| 2285 | 2293 | [[patch.unused]] | |
| 2286 | 2294 | name = "quasi-immediate" | |
| 2287 | - | version = "0.56.0" | |
| 2295 | + | version = "0.59.0" | |
| 2288 | 2296 | ||
| 2289 | 2297 | [[patch.unused]] | |
| 2290 | 2298 | name = "quasi-notifs" | |
| 2291 | - | version = "0.56.0" | |
| 2299 | + | version = "0.59.0" | |
| 2292 | 2300 | ||
| 2293 | 2301 | [[patch.unused]] | |
| 2294 | 2302 | name = "quasi-router" | |
| 2295 | - | version = "0.56.0" | |
| 2303 | + | version = "0.59.0" | |
| 2296 | 2304 | ||
| 2297 | 2305 | [[patch.unused]] | |
| 2298 | 2306 | name = "quasi-store" | |
| @@ -2300,24 +2308,16 @@ | |||
| 2300 | 2308 | ||
| 2301 | 2309 | [[patch.unused]] | |
| 2302 | 2310 | name = "quasi-tauri" | |
| 2303 | - | version = "0.56.0" | |
| 2311 | + | version = "0.59.0" | |
| 2304 | 2312 | ||
| 2305 | 2313 | [[patch.unused]] | |
| 2306 | 2314 | name = "quasi-webview" | |
| 2307 | - | version = "0.56.0" | |
| 2315 | + | version = "0.59.0" | |
| 2308 | 2316 | ||
| 2309 | 2317 | [[patch.unused]] | |
| 2310 | 2318 | name = "quasi-type" | |
| 2311 | 2319 | version = "0.1.0" | |
| 2312 | 2320 | ||
| 2313 | - | [[patch.unused]] | |
| 2314 | - | name = "synckit-client" | |
| 2315 | - | version = "0.9.1" | |
| 2316 | - | ||
| 2317 | - | [[patch.unused]] | |
| 2318 | - | name = "synckit-config" | |
| 2319 | - | version = "0.2.0" | |
| 2320 | - | ||
| 2321 | 2321 | [[patch.unused]] | |
| 2322 | 2322 | name = "kberg" | |
| 2323 | 2323 | version = "0.1.0" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "sando-daemon" | |
| 3 | - | version = "0.2.13" | |
| 3 | + | version = "0.2.14" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "MIT" | |
| 6 | 6 |
| @@ -465,7 +465,8 @@ | |||
| 465 | 465 | .await | |
| 466 | 466 | .ok(); | |
| 467 | 467 | ||
| 468 | - | let accepted = crate::intake::accept(&cfg.release_root, staged, record_json) | |
| 468 | + | let pinned = crate::retention::pinned_dirs(pool, &cfg.id).await?; | |
| 469 | + | let accepted = crate::intake::accept(&cfg.release_root, staged, record_json, &pinned) | |
| 469 | 470 | .await | |
| 470 | 471 | .map_err(|e| anyhow::anyhow!("{e}"))?; | |
| 471 | 472 | ||
| @@ -656,8 +657,10 @@ | |||
| 656 | 657 | ) | |
| 657 | 658 | .await | |
| 658 | 659 | .context("writing bundle MANIFEST")?; | |
| 660 | + | let pinned = crate::retention::pinned_dirs(pool, &cfg.id).await?; | |
| 659 | 661 | let released = | |
| 660 | - | deploy::finalize_local_release(&cfg.release_root, &staged.staging, digest.short()).await?; | |
| 662 | + | deploy::finalize_local_release(&cfg.release_root, &staged.staging, digest.short(), &pinned) | |
| 663 | + | .await?; | |
| 661 | 664 | ||
| 662 | 665 | let staged_bin = released.join(cfg.primary_bin()); | |
| 663 | 666 | sqlx::query("UPDATE versions SET artifact_path = ? WHERE app = ? AND version = ?") |
| @@ -27,6 +27,7 @@ | |||
| 27 | 27 | //! not a model change. | |
| 28 | 28 | ||
| 29 | 29 | use crate::domain::Platform; | |
| 30 | + | use crate::retention::PinnedReleases; | |
| 30 | 31 | use crate::topology::Node; | |
| 31 | 32 | use anyhow::{Context, Result}; | |
| 32 | 33 | use async_trait::async_trait; | |
| @@ -133,9 +134,13 @@ | |||
| 133 | 134 | } | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 136 | - | /// Keep this many release dirs per node; older ones get gc'd after a | |
| 137 | + | /// Keep at least this many release dirs per node; older ones get gc'd after a | |
| 137 | 138 | /// successful deploy. Fixed for now; promote to config if the constant ever | |
| 138 | 139 | /// needs to vary by tier. | |
| 140 | + | /// | |
| 141 | + | /// A floor, not a ceiling. Whatever the deployed state still references is set | |
| 142 | + | /// aside first and this count applies to the remainder — see | |
| 143 | + | /// [`crate::retention`] for why a count alone could not express that. | |
| 139 | 144 | const RELEASES_TO_KEEP: usize = 5; | |
| 140 | 145 | ||
| 141 | 146 | /// A sink that drops streamed bytes. Deploy steps don't have a live-log handle | |
| @@ -211,10 +216,16 @@ | |||
| 211 | 216 | /// rows and burn-in clock stops being something to guard against and becomes | |
| 212 | 217 | /// something that cannot be expressed. If a release with this digest already | |
| 213 | 218 | /// exists (identical bytes rebuilt), the staging copy is redundant and dropped. | |
| 219 | + | /// | |
| 220 | + | /// `pinned` names the release dirs the deployed state still points at | |
| 221 | + | /// ([`crate::retention::pinned_dirs`]); they are never gc'd here, however old | |
| 222 | + | /// they are. It arrives as data rather than as a pool handle so this stays a | |
| 223 | + | /// filesystem operation and the intake seam keeps working without a database. | |
| 214 | 224 | pub async fn finalize_local_release( | |
| 215 | 225 | release_root: &Path, | |
| 216 | 226 | staging: &Path, | |
| 217 | 227 | digest16: &str, | |
| 228 | + | pinned: &PinnedReleases, | |
| 218 | 229 | ) -> Result<PathBuf> { | |
| 219 | 230 | let releases = release_root.join("releases"); | |
| 220 | 231 | tokio::fs::create_dir_all(&releases).await?; | |
| @@ -242,7 +253,7 @@ | |||
| 242 | 253 | String::from_utf8_lossy(&out.stderr), | |
| 243 | 254 | ); | |
| 244 | 255 | ||
| 245 | - | if let Err(e) = gc_local_releases(release_root).await { | |
| 256 | + | if let Err(e) = gc_local_releases(release_root, pinned).await { | |
| 246 | 257 | tracing::warn!(error = %e, "local release GC failed (non-fatal)"); | |
| 247 | 258 | } | |
| 248 | 259 | Ok(released) | |
| @@ -932,7 +943,13 @@ | |||
| 932 | 943 | ) | |
| 933 | 944 | } | |
| 934 | 945 | ||
| 935 | - | async fn gc_local_releases(release_root: &Path) -> Result<()> { | |
| 946 | + | /// Trim `releases/` to the pinned set plus the [`RELEASES_TO_KEEP`] newest of | |
| 947 | + | /// what is left. | |
| 948 | + | /// | |
| 949 | + | /// Pinning is applied before the count, so a referenced artifact cannot be aged | |
| 950 | + | /// out by rebuilds of a newer version — the failure that stranded production | |
| 951 | + | /// twice. See [`crate::retention`]. | |
| 952 | + | async fn gc_local_releases(release_root: &Path, pinned: &PinnedReleases) -> Result<()> { | |
| 936 | 953 | let releases = release_root.join("releases"); | |
| 937 | 954 | if !releases.exists() { | |
| 938 | 955 | return Ok(()); | |
| @@ -943,6 +960,15 @@ | |||
| 943 | 960 | if !entry.file_type().await?.is_dir() { | |
| 944 | 961 | continue; | |
| 945 | 962 | } | |
| 963 | + | // Set aside before anything is ordered or counted: a pinned dir is not | |
| 964 | + | // a candidate, so it can never occupy one of the count's slots either. | |
| 965 | + | if entry | |
| 966 | + | .file_name() | |
| 967 | + | .to_str() | |
| 968 | + | .is_some_and(|n| pinned.contains(n)) | |
| 969 | + | { | |
| 970 | + | continue; | |
| 971 | + | } | |
| 946 | 972 | let meta = entry.metadata().await?; | |
| 947 | 973 | entries.push((entry.path(), meta.modified()?)); | |
| 948 | 974 | } | |
| @@ -974,6 +1000,13 @@ | |||
| 974 | 1000 | #[cfg(test)] | |
| 975 | 1001 | mod tests { | |
| 976 | 1002 | use super::*; | |
| 1003 | + | ||
| 1004 | + | /// Nothing deployed, so nothing pinned: the tests that exercise the count | |
| 1005 | + | /// alone pass this, and the ones that exercise pinning build their own set. | |
| 1006 | + | fn no_pins() -> PinnedReleases { | |
| 1007 | + | PinnedReleases::none() | |
| 1008 | + | } | |
| 1009 | + | ||
| 977 | 1010 | use crate::topology::NodeCompanion; | |
| 978 | 1011 | use ops_exec::{CapabilitySet, LocalExec, SshExec}; | |
| 979 | 1012 | use std::os::unix::process::ExitStatusExt; | |
| @@ -1294,9 +1327,10 @@ | |||
| 1294 | 1327 | ); | |
| 1295 | 1328 | ||
| 1296 | 1329 | // Publish content-addressed at releases/<digest16>. | |
| 1297 | - | let released = finalize_local_release(&release_root, &staging, "deadbeefcafe0000") | |
| 1298 | - | .await | |
| 1299 | - | .expect("finalize_local_release should succeed"); | |
| 1330 | + | let released = | |
| 1331 | + | finalize_local_release(&release_root, &staging, "deadbeefcafe0000", &no_pins()) | |
| 1332 | + | .await | |
| 1333 | + | .expect("finalize_local_release should succeed"); | |
| 1300 | 1334 | assert_eq!( | |
| 1301 | 1335 | released, | |
| 1302 | 1336 | release_root.join("releases").join("deadbeefcafe0000") | |
| @@ -1337,14 +1371,14 @@ | |||
| 1337 | 1371 | let s1 = stage_local_bundle(&release_root, 1, std::slice::from_ref(&bin)) | |
| 1338 | 1372 | .await | |
| 1339 | 1373 | .unwrap(); | |
| 1340 | - | finalize_local_release(&release_root, &s1, "1111111111111111") | |
| 1374 | + | finalize_local_release(&release_root, &s1, "1111111111111111", &no_pins()) | |
| 1341 | 1375 | .await | |
| 1342 | 1376 | .unwrap(); | |
| 1343 | 1377 | tokio::fs::write(&bin, b"V2").await.unwrap(); | |
| 1344 | 1378 | let s2 = stage_local_bundle(&release_root, 2, std::slice::from_ref(&bin)) | |
| 1345 | 1379 | .await | |
| 1346 | 1380 | .unwrap(); | |
| 1347 | - | finalize_local_release(&release_root, &s2, "2222222222222222") | |
| 1381 | + | finalize_local_release(&release_root, &s2, "2222222222222222", &no_pins()) | |
| 1348 | 1382 | .await | |
| 1349 | 1383 | .unwrap(); | |
| 1350 | 1384 | ||
| @@ -1380,7 +1414,7 @@ | |||
| 1380 | 1414 | let s1 = stage_local_bundle(&release_root, 1, std::slice::from_ref(&bin)) | |
| 1381 | 1415 | .await | |
| 1382 | 1416 | .unwrap(); | |
| 1383 | - | finalize_local_release(&release_root, &s1, "abc123abc123abc1") | |
| 1417 | + | finalize_local_release(&release_root, &s1, "abc123abc123abc1", &no_pins()) | |
| 1384 | 1418 | .await | |
| 1385 | 1419 | .unwrap(); | |
| 1386 | 1420 | // Same digest rebuilt (e.g. a re-run at the same content): finalize must | |
| @@ -1388,7 +1422,7 @@ | |||
| 1388 | 1422 | let s2 = stage_local_bundle(&release_root, 2, std::slice::from_ref(&bin)) | |
| 1389 | 1423 | .await | |
| 1390 | 1424 | .unwrap(); | |
| 1391 | - | let released = finalize_local_release(&release_root, &s2, "abc123abc123abc1") | |
| 1425 | + | let released = finalize_local_release(&release_root, &s2, "abc123abc123abc1", &no_pins()) | |
| 1392 | 1426 | .await | |
| 1393 | 1427 | .expect("finalize is idempotent on a repeated digest"); | |
| 1394 | 1428 | assert_eq!(released, release_root.join("releases/abc123abc123abc1")); | |
| @@ -1474,7 +1508,7 @@ | |||
| 1474 | 1508 | names.push(name); | |
| 1475 | 1509 | } | |
| 1476 | 1510 | ||
| 1477 | - | gc_local_releases(root).await.unwrap(); | |
| 1511 | + | gc_local_releases(root, &no_pins()).await.unwrap(); | |
| 1478 | 1512 | ||
| 1479 | 1513 | let surviving_expected: Vec<_> = names | |
| 1480 | 1514 | .iter() | |
| @@ -1492,6 +1526,77 @@ | |||
| 1492 | 1526 | } | |
| 1493 | 1527 | } | |
| 1494 | 1528 | ||
| 1529 | + | #[tokio::test] | |
| 1530 | + | async fn gc_local_releases_never_evicts_a_pinned_dir() { | |
| 1531 | + | // The 2026-08-25 shape exactly: the oldest dir is the one production is | |
| 1532 | + | // running, and enough newer rebuilds exist to push it past the count. | |
| 1533 | + | // Under the count alone it was the first thing deleted. | |
| 1534 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1535 | + | let root = tmp.path(); | |
| 1536 | + | let releases = root.join("releases"); | |
| 1537 | + | tokio::fs::create_dir_all(&releases).await.unwrap(); | |
| 1538 | + | ||
| 1539 | + | let total = RELEASES_TO_KEEP + 3; | |
| 1540 | + | let mut names = Vec::new(); | |
| 1541 | + | for i in 0..total { | |
| 1542 | + | let name = format!("v{i:02}"); | |
| 1543 | + | let dir = releases.join(&name); | |
| 1544 | + | tokio::fs::create_dir(&dir).await.unwrap(); | |
| 1545 | + | let f = std::fs::File::open(&dir).unwrap(); | |
| 1546 | + | let when = | |
| 1547 | + | SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + i as u64); | |
| 1548 | + | f.set_times(std::fs::FileTimes::new().set_modified(when)) | |
| 1549 | + | .unwrap(); | |
| 1550 | + | names.push(name); | |
| 1551 | + | } | |
| 1552 | + | ||
| 1553 | + | // The two oldest: what a tier is running and what it would roll back to. | |
| 1554 | + | let pinned: PinnedReleases = [names[0].clone(), names[1].clone()].into_iter().collect(); | |
| 1555 | + | gc_local_releases(root, &pinned).await.unwrap(); | |
| 1556 | + | ||
| 1557 | + | for name in [&names[0], &names[1]] { | |
| 1558 | + | assert!( | |
| 1559 | + | releases.join(name).exists(), | |
| 1560 | + | "a referenced artifact was evicted: {name}" | |
| 1561 | + | ); | |
| 1562 | + | } | |
| 1563 | + | // And the count still applies to everything else, from a floor that the | |
| 1564 | + | // pinned pair did not eat into: the newest RELEASES_TO_KEEP unpinned | |
| 1565 | + | // dirs survive, so pinning two costs two extra slots rather than two of | |
| 1566 | + | // the five. | |
| 1567 | + | let unpinned: Vec<_> = names.iter().filter(|n| !pinned.contains(n)).collect(); | |
| 1568 | + | let cut = unpinned.len() - RELEASES_TO_KEEP; | |
| 1569 | + | for name in unpinned.iter().take(cut) { | |
| 1570 | + | assert!( | |
| 1571 | + | !releases.join(name).exists(), | |
| 1572 | + | "expected to be pruned: {name}" | |
| 1573 | + | ); | |
| 1574 | + | } | |
| 1575 | + | for name in unpinned.iter().skip(cut) { | |
| 1576 | + | assert!(releases.join(name).exists(), "expected to survive: {name}"); | |
| 1577 | + | } | |
| 1578 | + | } | |
| 1579 | + | ||
| 1580 | + | #[tokio::test] | |
| 1581 | + | async fn gc_local_releases_keeps_a_pinned_dir_that_is_not_even_present() { | |
| 1582 | + | // A pinned name with nothing on disk must not disturb the count. This is | |
| 1583 | + | // the state the bug leaves behind, and gc runs again while it holds. | |
| 1584 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1585 | + | let root = tmp.path(); | |
| 1586 | + | let releases = root.join("releases"); | |
| 1587 | + | tokio::fs::create_dir_all(&releases).await.unwrap(); | |
| 1588 | + | for i in 0..=RELEASES_TO_KEEP { | |
| 1589 | + | tokio::fs::create_dir(releases.join(format!("v{i}"))) | |
| 1590 | + | .await | |
| 1591 | + | .unwrap(); | |
| 1592 | + | } | |
| 1593 | + | let pinned: PinnedReleases = ["gone-already".to_string()].into_iter().collect(); | |
| 1594 | + | gc_local_releases(root, &pinned).await.unwrap(); | |
| 1595 | + | ||
| 1596 | + | let left = std::fs::read_dir(&releases).unwrap().count(); | |
| 1597 | + | assert_eq!(left, RELEASES_TO_KEEP); | |
| 1598 | + | } | |
| 1599 | + | ||
| 1495 | 1600 | #[tokio::test] | |
| 1496 | 1601 | async fn gc_local_releases_noop_when_below_threshold() { | |
| 1497 | 1602 | let tmp = tempfile::tempdir().unwrap(); | |
| @@ -1503,7 +1608,7 @@ | |||
| 1503 | 1608 | .await | |
| 1504 | 1609 | .unwrap(); | |
| 1505 | 1610 | } | |
| 1506 | - | gc_local_releases(root).await.unwrap(); | |
| 1611 | + | gc_local_releases(root, &no_pins()).await.unwrap(); | |
| 1507 | 1612 | for i in 0..3 { | |
| 1508 | 1613 | assert!(releases.join(format!("v{i}")).exists()); | |
| 1509 | 1614 | } | |
| @@ -1512,7 +1617,7 @@ | |||
| 1512 | 1617 | #[tokio::test] | |
| 1513 | 1618 | async fn gc_local_releases_noop_when_releases_dir_missing() { | |
| 1514 | 1619 | let tmp = tempfile::tempdir().unwrap(); | |
| 1515 | - | gc_local_releases(tmp.path()).await.unwrap(); | |
| 1620 | + | gc_local_releases(tmp.path(), &no_pins()).await.unwrap(); | |
| 1516 | 1621 | } | |
| 1517 | 1622 | ||
| 1518 | 1623 | #[tokio::test] |
| @@ -111,10 +111,16 @@ | |||
| 111 | 111 | /// publishing is an atomic same-filesystem rename and a cross-device staging dir | |
| 112 | 112 | /// would silently become a copy. The caller owns getting the bytes there; this | |
| 113 | 113 | /// owns deciding whether they may stay. | |
| 114 | + | /// | |
| 115 | + | /// `pinned` is passed straight through to publishing, which gc's the store: it | |
| 116 | + | /// names the release dirs the deployed state still points at | |
| 117 | + | /// ([`crate::retention::pinned_dirs`]). Passing an empty set is what a caller | |
| 118 | + | /// with no deployed state to protect does, not a shortcut. | |
| 114 | 119 | pub async fn accept( | |
| 115 | 120 | release_root: &Path, | |
| 116 | 121 | staged: &Path, | |
| 117 | 122 | record_json: &str, | |
| 123 | + | pinned: &crate::retention::PinnedReleases, | |
| 118 | 124 | ) -> Result<AcceptedArtifact, IntakeError> { | |
| 119 | 125 | let record = ArtifactRecord::parse(record_json).map_err(IntakeError::BadRecord)?; | |
| 120 | 126 | ||
| @@ -147,9 +153,10 @@ | |||
| 147 | 153 | .await | |
| 148 | 154 | .map_err(|e| IntakeError::Publish(format!("writing MANIFEST: {e}")))?; | |
| 149 | 155 | ||
| 150 | - | let released = crate::deploy::finalize_local_release(release_root, staged, computed.short()) | |
| 151 | - | .await | |
| 152 | - | .map_err(|e| IntakeError::Publish(format!("{e:#}")))?; | |
| 156 | + | let released = | |
| 157 | + | crate::deploy::finalize_local_release(release_root, staged, computed.short(), pinned) | |
| 158 | + | .await | |
| 159 | + | .map_err(|e| IntakeError::Publish(format!("{e:#}")))?; | |
| 153 | 160 | ||
| 154 | 161 | tracing::info!( | |
| 155 | 162 | app = %record.provenance.app, | |
| @@ -189,6 +196,12 @@ | |||
| 189 | 196 | ||
| 190 | 197 | #[cfg(test)] | |
| 191 | 198 | mod tests { | |
| 199 | + | /// No deployed state in a unit test, so nothing is pinned and gc is free to | |
| 200 | + | /// apply the count alone. Retention is exercised in `deploy`'s own tests. | |
| 201 | + | fn no_pins() -> crate::retention::PinnedReleases { | |
| 202 | + | crate::retention::PinnedReleases::none() | |
| 203 | + | } | |
| 204 | + | ||
| 192 | 205 | use super::*; | |
| 193 | 206 | use chrono::{DateTime, Utc}; | |
| 194 | 207 | use ops_artifact::{GateRecord, Manifest, Provenance, Scope, Verdict}; | |
| @@ -265,7 +278,9 @@ | |||
| 265 | 278 | let staged = staged_bundle(dir.path()).await; | |
| 266 | 279 | let json = record_for(&staged, vec![passing_gate()]).await; | |
| 267 | 280 | ||
| 268 | - | let accepted = accept(dir.path(), &staged, &json).await.unwrap(); | |
| 281 | + | let accepted = accept(dir.path(), &staged, &json, &no_pins()) | |
| 282 | + | .await | |
| 283 | + | .unwrap(); | |
| 269 | 284 | assert!(accepted.builder_gates_passed()); | |
| 270 | 285 | assert!( | |
| 271 | 286 | accepted.released.ends_with(accepted.record.digest.short()), | |
| @@ -287,7 +302,9 @@ | |||
| 287 | 302 | let json = record_for(&staged, vec![passing_gate()]).await; | |
| 288 | 303 | write(&staged, "static/app.css", b"tampered").await; | |
| 289 | 304 | ||
| 290 | - | let err = accept(dir.path(), &staged, &json).await.unwrap_err(); | |
| 305 | + | let err = accept(dir.path(), &staged, &json, &no_pins()) | |
| 306 | + | .await | |
| 307 | + | .unwrap_err(); | |
| 291 | 308 | match err { | |
| 292 | 309 | IntakeError::ManifestMismatch { | |
| 293 | 310 | first_difference: Some(p), | |
| @@ -308,7 +325,9 @@ | |||
| 308 | 325 | let json = record_for(&staged, vec![passing_gate()]).await; | |
| 309 | 326 | write(&staged, "companions/unexpected", b"who put this here").await; | |
| 310 | 327 | ||
| 311 | - | let err = accept(dir.path(), &staged, &json).await.unwrap_err(); | |
| 328 | + | let err = accept(dir.path(), &staged, &json, &no_pins()) | |
| 329 | + | .await | |
| 330 | + | .unwrap_err(); | |
| 312 | 331 | match err { | |
| 313 | 332 | IntakeError::ManifestMismatch { | |
| 314 | 333 | first_difference: Some(p), | |
| @@ -322,7 +341,7 @@ | |||
| 322 | 341 | async fn a_record_that_does_not_parse_is_refused_before_anything_is_hashed() { | |
| 323 | 342 | let dir = tempfile::tempdir().unwrap(); | |
| 324 | 343 | let staged = staged_bundle(dir.path()).await; | |
| 325 | - | let err = accept(dir.path(), &staged, "{\"not\": \"a record\"}") | |
| 344 | + | let err = accept(dir.path(), &staged, "{\"not\": \"a record\"}", &no_pins()) | |
| 326 | 345 | .await | |
| 327 | 346 | .unwrap_err(); | |
| 328 | 347 | assert!(matches!(err, IntakeError::BadRecord(_)), "{err}"); | |
| @@ -337,7 +356,9 @@ | |||
| 337 | 356 | let staged = staged_bundle(dir.path()).await; | |
| 338 | 357 | let json = record_for(&staged, vec![passing_gate()]).await; | |
| 339 | 358 | let forged = json.replace(&record_digest(&json), &"f".repeat(64)); | |
| 340 | - | let err = accept(dir.path(), &staged, &forged).await.unwrap_err(); | |
| 359 | + | let err = accept(dir.path(), &staged, &forged, &no_pins()) | |
| 360 | + | .await | |
| 361 | + | .unwrap_err(); | |
| 341 | 362 | assert!(matches!(err, IntakeError::BadRecord(_)), "{err}"); | |
| 342 | 363 | } | |
| 343 | 364 | ||
| @@ -355,7 +376,9 @@ | |||
| 355 | 376 | at(), | |
| 356 | 377 | ); | |
| 357 | 378 | let json = record_for(&staged, vec![failed]).await; | |
| 358 | - | let accepted = accept(dir.path(), &staged, &json).await.unwrap(); | |
| 379 | + | let accepted = accept(dir.path(), &staged, &json, &no_pins()) | |
| 380 | + | .await | |
| 381 | + | .unwrap(); | |
| 359 | 382 | assert!(!accepted.builder_gates_passed()); | |
| 360 | 383 | } | |
| 361 | 384 |
| @@ -29,6 +29,7 @@ | |||
| 29 | 29 | pub mod intake; | |
| 30 | 30 | pub mod outcome; | |
| 31 | 31 | pub mod reconcile; | |
| 32 | + | pub mod retention; | |
| 32 | 33 | pub mod routes; | |
| 33 | 34 | pub mod runs; | |
| 34 | 35 | pub mod state; |
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | use anyhow::Result; | |
| 2 | - | use sando_daemon::{config, db, events, git, reconcile, routes, runs, state, sync, topology}; | |
| 2 | + | use sando_daemon::{ | |
| 3 | + | config, db, events, git, reconcile, retention, routes, runs, state, sync, topology, | |
| 4 | + | }; | |
| 3 | 5 | use std::net::SocketAddr; | |
| 4 | 6 | use std::path::Path; | |
| 5 | 7 | use std::sync::Arc; | |
| @@ -160,6 +162,35 @@ | |||
| 160 | 162 | } | |
| 161 | 163 | } | |
| 162 | 164 | ||
| 165 | + | // Say at startup whether every artifact the deployed state names is still on | |
| 166 | + | // disk. `/state` answers the same question on every poll; this is so the | |
| 167 | + | // journal carries it too, because the two occurrences of the retention bug | |
| 168 | + | // were both discovered by an rsync failing mid-promote rather than by | |
| 169 | + | // anything that had looked. Never fatal: a stranded artifact is a lost | |
| 170 | + | // rollback, not a reason to refuse to start — refusing would take the | |
| 171 | + | // controller down as well. | |
| 172 | + | for (id, app) in apps.iter() { | |
| 173 | + | match retention::tier_refs(&pool, id, app.cfg.primary_bin()).await { | |
| 174 | + | Ok(refs) => { | |
| 175 | + | let gone = retention::missing(&refs).await; | |
| 176 | + | if gone.is_empty() { | |
| 177 | + | tracing::info!(app = %id, referenced = refs.len(), "every referenced release artifact is present"); | |
| 178 | + | } else { | |
| 179 | + | tracing::error!( | |
| 180 | + | app = %id, | |
| 181 | + | missing = gone.len(), | |
| 182 | + | detail = %retention::describe(&gone), | |
| 183 | + | "referenced release artifact(s) missing from the store; promote and rollback \ | |
| 184 | + | through them will fail at the rsync. See /state missing_artifact" | |
| 185 | + | ); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | Err(e) => { | |
| 189 | + | tracing::error!(app = %id, error = %e, "could not check referenced release artifacts"); | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 163 | 194 | // Fail closed on the scratch cluster's privileges before any gate can hit | |
| 164 | 195 | // them, rather than letting the first migration_dry_run discover it as an | |
| 165 | 196 | // opaque "permission denied". Unset scratch_db_url is already a per-gate |
| @@ -195,6 +195,18 @@ | |||
| 195 | 195 | }, | |
| 196 | 196 | ); | |
| 197 | 197 | } | |
| 198 | + | // An artifact the tier still names is gone from the store. The tier is | |
| 199 | + | // serving, so this is not a `consistent` failure — what is lost is the | |
| 200 | + | // ability to redeploy or roll back, which nothing else on this node would | |
| 201 | + | // say. Reported after `consistent` and before the routine conditions. | |
| 202 | + | if let Some(detail) = &tier.missing_artifact { | |
| 203 | + | conditions.push(Condition { | |
| 204 | + | condition_type: "artifact".into(), | |
| 205 | + | status: Status::Degraded, | |
| 206 | + | since: None, | |
| 207 | + | detail: Some(format!("referenced artifact missing: {detail}")), | |
| 208 | + | }); | |
| 209 | + | } | |
| 198 | 210 | if !tier.provisioned { | |
| 199 | 211 | conditions.push(Condition { | |
| 200 | 212 | condition_type: "provisioned".into(), | |
| @@ -266,6 +278,13 @@ | |||
| 266 | 278 | { | |
| 267 | 279 | return Status::Failed; | |
| 268 | 280 | } | |
| 281 | + | // Degraded rather than failed: the tier is serving the version it says it | |
| 282 | + | // is. What is broken is the next promote and every rollback, which is worse | |
| 283 | + | // than routine and less than an outage. Reporting it green is what let two | |
| 284 | + | // occurrences go unnoticed until an operator reached for a rollback. | |
| 285 | + | if tier.missing_artifact.is_some() { | |
| 286 | + | return Status::Degraded; | |
| 287 | + | } | |
| 269 | 288 | // An unrecognized gate status is contract drift between daemon and viewer, | |
| 270 | 289 | // which is worth surfacing rather than smoothing over. The TUI renders it | |
| 271 | 290 | // magenta for the same reason. | |
| @@ -537,6 +556,7 @@ | |||
| 537 | 556 | previous_version: Some("0.10.13".into()), | |
| 538 | 557 | burn_in_started_at: Some("2026-07-21T14:02:00Z".into()), | |
| 539 | 558 | partial_reason: None, | |
| 559 | + | missing_artifact: None, | |
| 540 | 560 | nodes: vec![format!("{name}-1")], | |
| 541 | 561 | gates: Vec::new(), | |
| 542 | 562 | } | |
| @@ -844,4 +864,46 @@ | |||
| 844 | 864 | assert!(node(&p, "tier:b").conditions[0].since.is_none()); | |
| 845 | 865 | assert_eq!(node(&p, "tier:b").status, Status::Ok); | |
| 846 | 866 | } | |
| 867 | + | ||
| 868 | + | #[test] | |
| 869 | + | fn a_tier_missing_a_referenced_artifact_is_degraded_and_says_which() { | |
| 870 | + | // The state that reported green through two occurrences: the tier is | |
| 871 | + | // serving, and the artifact behind its rollback is gone. | |
| 872 | + | let mut t = tier("b"); | |
| 873 | + | t.missing_artifact = Some("b previous 0.11.20 (/srv/sando/releases/ed29/mnw)".into()); | |
| 874 | + | let p = payload(&view(vec![t]), now()); | |
| 875 | + | ||
| 876 | + | let n = node(&p, "tier:b"); | |
| 877 | + | assert_eq!(n.status, Status::Degraded); | |
| 878 | + | let c = n | |
| 879 | + | .conditions | |
| 880 | + | .iter() | |
| 881 | + | .find(|c| c.condition_type == "artifact") | |
| 882 | + | .expect("an artifact condition"); | |
| 883 | + | assert_eq!(c.status, Status::Degraded); | |
| 884 | + | assert!( | |
| 885 | + | c.detail.as_deref().unwrap_or_default().contains("0.11.20"), | |
| 886 | + | "the condition has to name what is gone: {:?}", | |
| 887 | + | c.detail | |
| 888 | + | ); | |
| 889 | + | assert_eq!(p.validate(), Ok(())); | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | #[test] | |
| 893 | + | fn a_partial_tier_stays_failed_even_with_a_missing_artifact() { | |
| 894 | + | // Degraded must not mask the loudest thing Sando can report. | |
| 895 | + | let mut t = tier("b"); | |
| 896 | + | t.partial_reason = Some("mixed versions after a failed promote".into()); | |
| 897 | + | t.missing_artifact = Some("b previous 0.11.20 (/srv/sando/releases/ed29/mnw)".into()); | |
| 898 | + | let p = payload(&view(vec![t]), now()); | |
| 899 | + | ||
| 900 | + | let n = node(&p, "tier:b"); | |
| 901 | + | assert_eq!(n.status, Status::Failed); | |
| 902 | + | assert!(n.conditions.iter().any(|c| c.condition_type == "artifact")); | |
| 903 | + | assert!( | |
| 904 | + | n.conditions | |
| 905 | + | .iter() | |
| 906 | + | .any(|c| c.condition_type == "consistent") | |
| 907 | + | ); | |
| 908 | + | } | |
| 847 | 909 | } |
| @@ -174,6 +174,15 @@ | |||
| 174 | 174 | /// failed promote or rollback whose compensation could not fully restore | |
| 175 | 175 | /// consistency. NULL when the tier is consistent. The TUI flags it red. | |
| 176 | 176 | pub(crate) partial_reason: Option<String>, | |
| 177 | + | /// Non-null when an artifact this tier still names is no longer on disk: | |
| 178 | + | /// the row points at a release dir whose primary binary is gone, so a | |
| 179 | + | /// promote or rollback resolving through it would fail at the rsync. | |
| 180 | + | /// | |
| 181 | + | /// The tier keeps serving — the running process holds its own inode — which | |
| 182 | + | /// is why this was invisible until an operator reached for a rollback that | |
| 183 | + | /// was not there. Read live on every `/state`, so a store repaired by hand | |
| 184 | + | /// clears it on the next poll with nothing to reset. | |
| 185 | + | pub(crate) missing_artifact: Option<String>, | |
| 177 | 186 | pub(crate) nodes: Vec<String>, | |
| 178 | 187 | pub(crate) gates: Vec<GateView>, | |
| 179 | 188 | } | |
| @@ -218,6 +227,12 @@ | |||
| 218 | 227 | .fetch_all(&s.pool) | |
| 219 | 228 | .await?; | |
| 220 | 229 | ||
| 230 | + | // What the deployed state points at, and which of it is actually there. | |
| 231 | + | // One pass for every tier: the read is a handful of stats, and doing it here | |
| 232 | + | // rather than on a timer means `/state` cannot report a stale verdict. | |
| 233 | + | let refs = crate::retention::tier_refs(&s.pool, &s.cfg.id, s.cfg.primary_bin()).await?; | |
| 234 | + | let gone = crate::retention::missing(&refs).await; | |
| 235 | + | ||
| 221 | 236 | let mut tiers: Vec<TierView> = Vec::with_capacity(rows.len()); | |
| 222 | 237 | for r in rows { | |
| 223 | 238 | let name: String = r.get("name"); | |
| @@ -317,6 +332,11 @@ | |||
| 317 | 332 | }) | |
| 318 | 333 | .collect(); | |
| 319 | 334 | ||
| 335 | + | let missing_artifact = { | |
| 336 | + | let mine: Vec<_> = gone.iter().filter(|g| g.tier == name).cloned().collect(); | |
| 337 | + | (!mine.is_empty()).then(|| crate::retention::describe(&mine)) | |
| 338 | + | }; | |
| 339 | + | ||
| 320 | 340 | tiers.push(TierView { | |
| 321 | 341 | name, | |
| 322 | 342 | ord: r.get("ord"), | |
| @@ -326,6 +346,7 @@ | |||
| 326 | 346 | previous_version: r.get("previous_version"), | |
| 327 | 347 | burn_in_started_at: r.get("burn_in_started_at"), | |
| 328 | 348 | partial_reason: r.get("partial_reason"), | |
| 349 | + | missing_artifact, | |
| 329 | 350 | nodes, | |
| 330 | 351 | gates, | |
| 331 | 352 | }); | |
| @@ -1508,6 +1529,63 @@ | |||
| 1508 | 1529 | .unwrap(); | |
| 1509 | 1530 | } | |
| 1510 | 1531 | ||
| 1532 | + | /// `/state` says when a tier's artifact is gone, and stays quiet when it is | |
| 1533 | + | /// not. | |
| 1534 | + | /// | |
| 1535 | + | /// The gap this closes: `versions`/`build_runs` keep naming a path long | |
| 1536 | + | /// after gc removed the bytes, and nothing reconciled the two. Both times it | |
| 1537 | + | /// happened, the tier read green until an rsync failed mid-promote. | |
| 1538 | + | #[tokio::test] | |
| 1539 | + | async fn state_reports_a_referenced_artifact_whose_bytes_are_gone() { | |
| 1540 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1541 | + | let present = tmp.path().join("releases").join("1111111111111111"); | |
| 1542 | + | tokio::fs::create_dir_all(&present).await.unwrap(); | |
| 1543 | + | ||
| 1544 | + | let state = test_state().await; | |
| 1545 | + | let bin = state.cfg.primary_bin().to_string(); | |
| 1546 | + | tokio::fs::write(present.join(&bin), b"bin").await.unwrap(); | |
| 1547 | + | ||
| 1548 | + | seed(&state.pool, "a", "0.11.20").await; | |
| 1549 | + | let build = seed_build( | |
| 1550 | + | &state.pool, | |
| 1551 | + | "2a53c900", | |
| 1552 | + | "0.11.20", | |
| 1553 | + | present.to_string_lossy().as_ref(), | |
| 1554 | + | ) | |
| 1555 | + | .await; | |
| 1556 | + | sqlx::query( | |
| 1557 | + | "UPDATE tier_state SET current_version = ?, current_build_id = ? WHERE tier = 'a'", | |
| 1558 | + | ) | |
| 1559 | + | .bind("0.11.20") | |
| 1560 | + | .bind(build) | |
| 1561 | + | .execute(&state.pool) | |
| 1562 | + | .await | |
| 1563 | + | .unwrap(); | |
| 1564 | + | ||
| 1565 | + | let tier_a = |v: &StateView| { | |
| 1566 | + | v.tiers | |
| 1567 | + | .iter() | |
| 1568 | + | .find(|t| t.name == "a") | |
| 1569 | + | .expect("tier a") | |
| 1570 | + | .missing_artifact | |
| 1571 | + | .clone() | |
| 1572 | + | }; | |
| 1573 | + | ||
| 1574 | + | assert_eq!( | |
| 1575 | + | tier_a(&state_view(&state).await.unwrap()), | |
| 1576 | + | None, | |
| 1577 | + | "the artifact is on disk; nothing to report" | |
| 1578 | + | ); | |
| 1579 | + | ||
| 1580 | + | // gc takes it, as it did twice in production. Nothing in the database | |
| 1581 | + | // changes, which is the whole defect. | |
| 1582 | + | tokio::fs::remove_dir_all(&present).await.unwrap(); | |
| 1583 | + | ||
| 1584 | + | let said = tier_a(&state_view(&state).await.unwrap()).expect("a missing-artifact report"); | |
| 1585 | + | assert!(said.contains("0.11.20"), "{said}"); | |
| 1586 | + | assert!(said.contains("current"), "{said}"); | |
| 1587 | + | } | |
| 1588 | + | ||
| 1511 | 1589 | /// A rebuild at an unchanged version must not move what a tier reports. | |
| 1512 | 1590 | /// | |
| 1513 | 1591 | /// `/state` keyed its gate rows on (tier, version), so two runs of one |
| @@ -1,0 +1,585 @@ | |||
| 1 | + | //! Which release directories the deployed state still points at. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: release-artifact-identity --> | |
| 4 | + | //! | |
| 5 | + | //! Release dirs are content-addressed (`releases/<digest16>`) and trimmed after | |
| 6 | + | //! every publish. Trimming used to be a count alone — keep the 5 newest by | |
| 7 | + | //! mtime — which cannot express "this one is still in production". Three | |
| 8 | + | //! rebuilds of a single version fill three of the five slots, and the artifacts | |
| 9 | + | //! a tier is running and would roll back to fall off the end. That happened | |
| 10 | + | //! twice (2026-08-19, 2026-08-25); the second time neither surviving directory | |
| 11 | + | //! held a runnable binary and prod had nothing to roll back to. | |
| 12 | + | //! | |
| 13 | + | //! So the set below is computed first and set aside, and the count applies only | |
| 14 | + | //! to what is left. The count is a floor on how much history to keep, not a | |
| 15 | + | //! ceiling on what may be retained. | |
| 16 | + | //! | |
| 17 | + | //! # What counts as referenced | |
| 18 | + | //! | |
| 19 | + | //! Everything promote and rollback resolve through, because a dir that is | |
| 20 | + | //! unreachable to them is exactly the one whose absence is discovered by an | |
| 21 | + | //! rsync failing mid-promote: | |
| 22 | + | //! | |
| 23 | + | //! 1. `tier_state.current_build_id` / `previous_build_id` -> `build_runs.staged_path`. | |
| 24 | + | //! The identity path (migration 008), and the tier's own answer to what it is | |
| 25 | + | //! running. | |
| 26 | + | //! 2. The newest green `build_runs` row per (version, platform) for every version | |
| 27 | + | //! a tier names. This is what [`crate::routes::promotion`] resolves a rollback | |
| 28 | + | //! to, and on a two-architecture product it is a different row per node. | |
| 29 | + | //! 3. `versions.artifact_path` for those versions. The pre-identity path, still | |
| 30 | + | //! the first thing a canary rollback reads. | |
| 31 | + | //! | |
| 32 | + | //! The three overlap heavily and are unioned rather than ranked: being reachable | |
| 33 | + | //! by any of them is enough to make a directory load-bearing. | |
| 34 | + | ||
| 35 | + | use crate::domain::AppId; | |
| 36 | + | use anyhow::Result; | |
| 37 | + | use sqlx::{Row, SqlitePool}; | |
| 38 | + | use std::collections::{BTreeSet, HashSet}; | |
| 39 | + | use std::path::{Path, PathBuf}; | |
| 40 | + | ||
| 41 | + | /// The release directories that must survive a gc, by the name gc matches on. | |
| 42 | + | /// | |
| 43 | + | /// A newtype rather than a bare `HashSet<String>` so the thing being passed | |
| 44 | + | /// through publish and into gc says what it is at every hop, and so a caller | |
| 45 | + | /// cannot hand it a set of paths, versions, or digests by accident. | |
| 46 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 47 | + | pub struct PinnedReleases(HashSet<String>); | |
| 48 | + | ||
| 49 | + | impl PinnedReleases { | |
| 50 | + | /// Nothing to protect. What a caller with no deployed state passes — a unit | |
| 51 | + | /// test, or a store that has never served anything. | |
| 52 | + | pub fn none() -> Self { | |
| 53 | + | Self::default() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | pub fn contains(&self, dir_name: &str) -> bool { | |
| 57 | + | self.0.contains(dir_name) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | pub fn len(&self) -> usize { | |
| 61 | + | self.0.len() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | pub fn is_empty(&self) -> bool { | |
| 65 | + | self.0.is_empty() | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | impl FromIterator<String> for PinnedReleases { | |
| 70 | + | fn from_iter<T: IntoIterator<Item = String>>(iter: T) -> Self { | |
| 71 | + | Self(iter.into_iter().collect()) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// Which of a tier's two artifacts a reference is. | |
| 76 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 77 | + | pub enum Role { | |
| 78 | + | /// What the tier is running. | |
| 79 | + | Current, | |
| 80 | + | /// The one step of rollback history `tier_state` keeps. | |
| 81 | + | Previous, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | impl Role { | |
| 85 | + | pub const fn as_str(self) -> &'static str { | |
| 86 | + | match self { | |
| 87 | + | Self::Current => "current", | |
| 88 | + | Self::Previous => "previous", | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | impl std::fmt::Display for Role { | |
| 94 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 95 | + | f.write_str(self.as_str()) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// One artifact a tier still names, and the bytes that have to be present for | |
| 100 | + | /// the reference to be honoured. | |
| 101 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 102 | + | pub struct ArtifactRef { | |
| 103 | + | pub tier: String, | |
| 104 | + | pub role: Role, | |
| 105 | + | /// The version label, when the tier records one. | |
| 106 | + | pub version: Option<String>, | |
| 107 | + | /// The release directory the reference resolves to. | |
| 108 | + | pub dir: PathBuf, | |
| 109 | + | /// The file whose absence makes the reference unusable — the primary binary | |
| 110 | + | /// inside `dir`. A directory holding only a MANIFEST is not a rollback | |
| 111 | + | /// target, and both stranded dirs in the 2026-08-25 measurement were exactly | |
| 112 | + | /// that, so existence of the directory is the wrong question to ask. | |
| 113 | + | pub binary: PathBuf, | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// The directory name gc sees under `releases/`. | |
| 117 | + | /// | |
| 118 | + | /// `None` for a recorded path with no final component, which is a malformed row | |
| 119 | + | /// rather than a reference to the root. | |
| 120 | + | fn dir_name(dir: &Path) -> Option<String> { | |
| 121 | + | dir.file_name() | |
| 122 | + | .and_then(|n| n.to_str()) | |
| 123 | + | .map(ToOwned::to_owned) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Every release directory the deployed state still points at, by the name gc | |
| 127 | + | /// matches on. | |
| 128 | + | /// | |
| 129 | + | /// Scoped to one product because `release_root` is: a second app's tiers name | |
| 130 | + | /// directories in a store this gc never walks. | |
| 131 | + | pub async fn pinned_dirs(pool: &SqlitePool, app: &AppId) -> Result<PinnedReleases> { | |
| 132 | + | let mut pinned = HashSet::new(); | |
| 133 | + | ||
| 134 | + | // (1) + (2): every staged_path reachable from the tier's build ids, and | |
| 135 | + | // every newest-green build per (version, platform) for the versions the | |
| 136 | + | // tiers name. One query: the second set is what a rollback resolves to and | |
| 137 | + | // the first is what the tier is on, and a row can be in both. | |
| 138 | + | let rows = sqlx::query( | |
| 139 | + | "SELECT DISTINCT br.staged_path | |
| 140 | + | FROM build_runs br | |
| 141 | + | WHERE br.app = ?1 | |
| 142 | + | AND br.staged_path IS NOT NULL | |
| 143 | + | AND ( | |
| 144 | + | br.id IN (SELECT current_build_id FROM tier_state WHERE app = ?1 | |
| 145 | + | UNION ALL | |
| 146 | + | SELECT previous_build_id FROM tier_state WHERE app = ?1) | |
| 147 | + | OR (br.result = 'passed' | |
| 148 | + | AND br.version IN (SELECT current_version FROM tier_state WHERE app = ?1 | |
| 149 | + | UNION ALL | |
| 150 | + | SELECT previous_version FROM tier_state WHERE app = ?1) | |
| 151 | + | AND br.id = (SELECT MAX(id) FROM build_runs | |
| 152 | + | WHERE app = ?1 | |
| 153 | + | AND version = br.version | |
| 154 | + | AND platform IS br.platform | |
| 155 | + | AND result = 'passed')) | |
| 156 | + | )", | |
| 157 | + | ) | |
| 158 | + | .bind(app) | |
| 159 | + | .fetch_all(pool) | |
| 160 | + | .await?; | |
| 161 | + | for r in rows { | |
| 162 | + | let path: String = r.get("staged_path"); | |
| 163 | + | if let Some(name) = dir_name(Path::new(&path)) { | |
| 164 | + | pinned.insert(name); | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // (3) The pre-identity path. `artifact_path` names the primary binary, so | |
| 169 | + | // the directory is its parent. | |
| 170 | + | let rows = sqlx::query( | |
| 171 | + | "SELECT DISTINCT v.artifact_path | |
| 172 | + | FROM versions v | |
| 173 | + | WHERE v.app = ?1 | |
| 174 | + | AND v.version IN (SELECT current_version FROM tier_state WHERE app = ?1 | |
| 175 | + | UNION ALL | |
| 176 | + | SELECT previous_version FROM tier_state WHERE app = ?1)", | |
| 177 | + | ) | |
| 178 | + | .bind(app) | |
| 179 | + | .fetch_all(pool) | |
| 180 | + | .await?; | |
| 181 | + | for r in rows { | |
| 182 | + | let path: String = r.get("artifact_path"); | |
| 183 | + | if let Some(name) = Path::new(&path).parent().and_then(dir_name) { | |
| 184 | + | pinned.insert(name); | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | Ok(PinnedReleases(pinned)) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// What each tier is running and what it would roll back to, resolved the way | |
| 192 | + | /// promote and rollback resolve it: the build row's `staged_path` when the tier | |
| 193 | + | /// has an identity, `versions.artifact_path` when it predates one. | |
| 194 | + | /// | |
| 195 | + | /// One entry per (tier, role) that resolves to a path at all. A tier with no | |
| 196 | + | /// previous version contributes one entry, not two. | |
| 197 | + | pub async fn tier_refs( | |
| 198 | + | pool: &SqlitePool, | |
| 199 | + | app: &AppId, | |
| 200 | + | primary_bin: &str, | |
| 201 | + | ) -> Result<Vec<ArtifactRef>> { | |
| 202 | + | let rows = sqlx::query( | |
| 203 | + | "SELECT ts.tier, | |
| 204 | + | ts.current_version, cb.staged_path AS current_dir, cv.artifact_path AS current_bin, | |
| 205 | + | ts.previous_version, pb.staged_path AS previous_dir, pv.artifact_path AS previous_bin | |
| 206 | + | FROM tier_state ts | |
| 207 | + | LEFT JOIN build_runs cb ON cb.id = ts.current_build_id | |
| 208 | + | LEFT JOIN build_runs pb ON pb.id = ts.previous_build_id | |
| 209 | + | LEFT JOIN versions cv ON cv.app = ts.app AND cv.version = ts.current_version | |
| 210 | + | LEFT JOIN versions pv ON pv.app = ts.app AND pv.version = ts.previous_version | |
| 211 | + | WHERE ts.app = ? | |
| 212 | + | ORDER BY ts.tier", | |
| 213 | + | ) | |
| 214 | + | .bind(app) | |
| 215 | + | .fetch_all(pool) | |
| 216 | + | .await?; | |
| 217 | + | ||
| 218 | + | let mut refs = Vec::new(); | |
| 219 | + | for r in rows { | |
| 220 | + | let tier: String = r.get("tier"); | |
| 221 | + | for (role, version, dir_col, bin_col) in [ | |
| 222 | + | ( | |
| 223 | + | Role::Current, | |
| 224 | + | r.get::<Option<String>, _>("current_version"), | |
| 225 | + | r.get::<Option<String>, _>("current_dir"), | |
| 226 | + | r.get::<Option<String>, _>("current_bin"), | |
| 227 | + | ), | |
| 228 | + | ( | |
| 229 | + | Role::Previous, | |
| 230 | + | r.get::<Option<String>, _>("previous_version"), | |
| 231 | + | r.get::<Option<String>, _>("previous_dir"), | |
| 232 | + | r.get::<Option<String>, _>("previous_bin"), | |
| 233 | + | ), | |
| 234 | + | ] { | |
| 235 | + | // The build row wins when there is one: it is the identity, and the | |
| 236 | + | // version label can be shared by several builds. `artifact_path` is | |
| 237 | + | // the fallback for a tier that predates migration 008 — the same | |
| 238 | + | // fallback the gate scope and the canary rollback take. | |
| 239 | + | let resolved = match (dir_col, bin_col) { | |
| 240 | + | (Some(dir), _) => { | |
| 241 | + | let dir = PathBuf::from(dir); | |
| 242 | + | let binary = dir.join(primary_bin); | |
| 243 | + | Some((dir, binary)) | |
| 244 | + | } | |
| 245 | + | (None, Some(bin)) => { | |
| 246 | + | let binary = PathBuf::from(bin); | |
| 247 | + | binary.parent().map(|d| (d.to_path_buf(), binary.clone())) | |
| 248 | + | } | |
| 249 | + | (None, None) => None, | |
| 250 | + | }; | |
| 251 | + | if let Some((dir, binary)) = resolved { | |
| 252 | + | refs.push(ArtifactRef { | |
| 253 | + | tier: tier.clone(), | |
| 254 | + | role, | |
| 255 | + | version, | |
| 256 | + | dir, | |
| 257 | + | binary, | |
| 258 | + | }); | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | Ok(refs) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// The references whose bytes are gone. | |
| 266 | + | /// | |
| 267 | + | /// Deliberately a `stat` per reference rather than a directory walk: there are | |
| 268 | + | /// at most two per tier, and asking about the exact file promote would rsync is | |
| 269 | + | /// the only question whose answer means anything. | |
| 270 | + | pub async fn missing(refs: &[ArtifactRef]) -> Vec<ArtifactRef> { | |
| 271 | + | let mut gone = Vec::new(); | |
| 272 | + | for r in refs { | |
| 273 | + | if !tokio::fs::try_exists(&r.binary).await.unwrap_or(false) { | |
| 274 | + | gone.push(r.clone()); | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | gone | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// One line naming what is gone, for a log or a `/state` condition. | |
| 281 | + | /// | |
| 282 | + | /// Sorted and deduplicated by tier so the sentence is stable across reads — | |
| 283 | + | /// an operator surface that reworded itself every poll would read as churn. | |
| 284 | + | pub fn describe(missing: &[ArtifactRef]) -> String { | |
| 285 | + | let lines: BTreeSet<String> = missing | |
| 286 | + | .iter() | |
| 287 | + | .map(|r| { | |
| 288 | + | let version = r.version.as_deref().unwrap_or("unknown version"); | |
| 289 | + | format!("{} {} {version} ({})", r.tier, r.role, r.binary.display()) | |
| 290 | + | }) | |
| 291 | + | .collect(); | |
| 292 | + | lines.into_iter().collect::<Vec<_>>().join("; ") | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | #[cfg(test)] | |
| 296 | + | mod tests { | |
| 297 | + | use super::*; | |
| 298 | + | use sqlx::sqlite::SqlitePoolOptions; | |
| 299 | + | ||
| 300 | + | async fn fresh_pool() -> SqlitePool { | |
| 301 | + | let pool = SqlitePoolOptions::new() | |
| 302 | + | .max_connections(1) | |
| 303 | + | .connect("sqlite::memory:") | |
| 304 | + | .await | |
| 305 | + | .unwrap(); | |
| 306 | + | sqlx::migrate!("./migrations").run(&pool).await.unwrap(); | |
| 307 | + | pool | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | fn app() -> AppId { | |
| 311 | + | AppId::default() | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | async fn tier(pool: &SqlitePool, name: &str, ord: i64) { | |
| 315 | + | sqlx::query("INSERT INTO tiers (app, name, ord, provisioned) VALUES ('mnw', ?, ?, 1)") | |
| 316 | + | .bind(name) | |
| 317 | + | .bind(ord) | |
| 318 | + | .execute(pool) | |
| 319 | + | .await | |
| 320 | + | .unwrap(); | |
| 321 | + | sqlx::query("INSERT INTO tier_state (app, tier) VALUES ('mnw', ?)") | |
| 322 | + | .bind(name) | |
| 323 | + | .execute(pool) | |
| 324 | + | .await | |
| 325 | + | .unwrap(); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | /// A settled build row with an identity, as `runs::set_identity` leaves it. | |
| 329 | + | async fn build(pool: &SqlitePool, version: &str, dir: &str, platform: Option<&str>) -> i64 { | |
| 330 | + | sqlx::query( | |
| 331 | + | "INSERT INTO build_runs (app, sha, version, result, started_at, bundle_digest, | |
| 332 | + | staged_path, platform) | |
| 333 | + | VALUES ('mnw', 'abc123', ?, 'passed', '2026-08-25T00:00:00Z', ?, ?, ?)", | |
| 334 | + | ) | |
| 335 | + | .bind(version) | |
| 336 | + | .bind(dir) | |
| 337 | + | .bind(format!("/srv/sando/releases/{dir}")) | |
| 338 | + | .bind(platform) | |
| 339 | + | .execute(pool) | |
| 340 | + | .await | |
| 341 | + | .unwrap() | |
| 342 | + | .last_insert_rowid() | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | async fn version_row(pool: &SqlitePool, version: &str, artifact_path: &str) { | |
| 346 | + | sqlx::query( | |
| 347 | + | "INSERT INTO versions (app, version, git_sha, built_at, artifact_path) | |
| 348 | + | VALUES ('mnw', ?, 'abc123', '2026-08-25T00:00:00Z', ?)", | |
| 349 | + | ) | |
| 350 | + | .bind(version) | |
| 351 | + | .bind(artifact_path) | |
| 352 | + | .execute(pool) | |
| 353 | + | .await | |
| 354 | + | .unwrap(); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /// Fixture-only: the SET clause varies per test and every value in it is a | |
| 358 | + | /// literal written here, so `raw_sql` is the honest tool rather than a | |
| 359 | + | /// bound query that cannot take a clause. | |
| 360 | + | async fn set_state(pool: &SqlitePool, tier: &str, sql: &str) { | |
| 361 | + | sqlx::raw_sql(sqlx::AssertSqlSafe(format!( | |
| 362 | + | "UPDATE tier_state SET {sql} WHERE app = 'mnw' AND tier = '{tier}'" | |
| 363 | + | ))) | |
| 364 | + | .execute(pool) | |
| 365 | + | .await | |
| 366 | + | .unwrap(); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[tokio::test] | |
| 370 | + | async fn pins_what_every_tier_is_running_and_would_roll_back_to() { | |
| 371 | + | let pool = fresh_pool().await; | |
| 372 | + | tier(&pool, "host", 0).await; | |
| 373 | + | tier(&pool, "a", 1).await; | |
| 374 | + | ||
| 375 | + | let cur = build(&pool, "0.16.1", "aaaaaaaaaaaaaaaa", None).await; | |
| 376 | + | let prev = build(&pool, "0.11.20", "bbbbbbbbbbbbbbbb", None).await; | |
| 377 | + | set_state( | |
| 378 | + | &pool, | |
| 379 | + | "host", | |
| 380 | + | &format!( | |
| 381 | + | "current_version = '0.16.1', current_build_id = {cur}, | |
| 382 | + | previous_version = '0.11.20', previous_build_id = {prev}" | |
| 383 | + | ), | |
| 384 | + | ) | |
| 385 | + | .await; | |
| 386 | + | ||
| 387 | + | let pinned = pinned_dirs(&pool, &app()).await.unwrap(); | |
| 388 | + | assert!(pinned.contains("aaaaaaaaaaaaaaaa")); | |
| 389 | + | assert!(pinned.contains("bbbbbbbbbbbbbbbb")); | |
| 390 | + | assert_eq!(pinned.len(), 2, "{pinned:?}"); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | /// The exact shape that stranded prod: three builds of one version, the | |
| 394 | + | /// tier on the OLDEST of them. Pinning by version alone would keep the | |
| 395 | + | /// newest rebuild and lose the bytes actually deployed. | |
| 396 | + | #[tokio::test] | |
| 397 | + | async fn pins_the_build_the_tier_is_on_not_the_newest_rebuild_of_its_version() { | |
| 398 | + | let pool = fresh_pool().await; | |
| 399 | + | tier(&pool, "host", 0).await; | |
| 400 | + | ||
| 401 | + | let deployed = build(&pool, "0.16.1", "0000000000000001", None).await; | |
| 402 | + | build(&pool, "0.16.1", "0000000000000002", None).await; | |
| 403 | + | let newest = build(&pool, "0.16.1", "0000000000000003", None).await; | |
| 404 | + | set_state( | |
| 405 | + | &pool, | |
| 406 | + | "host", | |
| 407 | + | &format!("current_version = '0.16.1', current_build_id = {deployed}"), | |
| 408 | + | ) | |
| 409 | + | .await; | |
| 410 | + | ||
| 411 | + | let pinned = pinned_dirs(&pool, &app()).await.unwrap(); | |
| 412 | + | assert!( | |
| 413 | + | pinned.contains("0000000000000001"), | |
| 414 | + | "the deployed build must be pinned: {pinned:?}" | |
| 415 | + | ); | |
| 416 | + | // And the newest green build of that version too: that is the row a | |
| 417 | + | // rollback to 0.16.1 resolves through, so evicting it breaks a path the | |
| 418 | + | // tier can still take. | |
| 419 | + | assert!(pinned.contains("0000000000000003"), "{pinned:?}"); | |
| 420 | + | assert_ne!(deployed, newest); | |
| 421 | + | assert!(!pinned.contains("0000000000000002"), "{pinned:?}"); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | /// Two architectures of one version are two artifacts, and a rollback picks | |
| 425 | + | /// per node. Both have to survive. | |
| 426 | + | #[tokio::test] | |
| 427 | + | async fn pins_every_platform_of_a_referenced_version() { | |
| 428 | + | let pool = fresh_pool().await; | |
| 429 | + | tier(&pool, "host", 0).await; | |
| 430 | + | build(&pool, "0.4.0", "aaaa000000000000", Some("linux/x86_64")).await; | |
| 431 | + | build(&pool, "0.4.0", "bbbb000000000000", Some("linux/aarch64")).await; | |
| 432 | + | set_state(&pool, "host", "current_version = '0.4.0'").await; | |
| 433 | + | ||
| 434 | + | let pinned = pinned_dirs(&pool, &app()).await.unwrap(); | |
| 435 | + | assert!(pinned.contains("aaaa000000000000"), "{pinned:?}"); | |
| 436 | + | assert!(pinned.contains("bbbb000000000000"), "{pinned:?}"); | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /// A pre-identity tier has no build id at all. `versions.artifact_path` is | |
| 440 | + | /// the only handle, and it names the binary inside the dir. | |
| 441 | + | #[tokio::test] | |
| 442 | + | async fn pins_a_pre_identity_tier_through_versions() { | |
| 443 | + | let pool = fresh_pool().await; | |
| 444 | + | tier(&pool, "host", 0).await; | |
| 445 | + | version_row( | |
| 446 | + | &pool, | |
| 447 | + | "0.8.12", | |
| 448 | + | "/srv/sando/releases/cccccccccccccccc/makenotwork", | |
| 449 | + | ) | |
| 450 | + | .await; | |
| 451 | + | set_state(&pool, "host", "current_version = '0.8.12'").await; | |
| 452 | + | ||
| 453 | + | let pinned = pinned_dirs(&pool, &app()).await.unwrap(); | |
| 454 | + | assert!(pinned.contains("cccccccccccccccc"), "{pinned:?}"); | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | /// `release_root` is per product, so another product's references must not | |
| 458 | + | /// leak into this store's keep-set. | |
| 459 | + | #[tokio::test] | |
| 460 | + | async fn another_products_references_are_not_pinned_here() { | |
| 461 | + | let pool = fresh_pool().await; | |
| 462 | + | tier(&pool, "host", 0).await; | |
| 463 | + | sqlx::query("INSERT INTO tiers (app, name, ord, provisioned) VALUES ('pom', 'host', 0, 1)") | |
| 464 | + | .execute(&pool) | |
| 465 | + | .await | |
| 466 | + | .unwrap(); | |
| 467 | + | sqlx::query("INSERT INTO tier_state (app, tier) VALUES ('pom', 'host')") | |
| 468 | + | .execute(&pool) | |
| 469 | + | .await | |
| 470 | + | .unwrap(); | |
| 471 | + | sqlx::query( | |
| 472 | + | "INSERT INTO build_runs (app, sha, version, result, started_at, staged_path) | |
| 473 | + | VALUES ('pom', 'abc', '1.0.0', 'passed', '2026-08-25T00:00:00Z', | |
| 474 | + | '/srv/sando-pom/releases/dddddddddddddddd')", | |
| 475 | + | ) | |
| 476 | + | .execute(&pool) | |
| 477 | + | .await | |
| 478 | + | .unwrap(); | |
| 479 | + | sqlx::query( | |
| 480 | + | "UPDATE tier_state SET current_version = '1.0.0' WHERE app = 'pom' AND tier = 'host'", | |
| 481 | + | ) | |
| 482 | + | .execute(&pool) | |
| 483 | + | .await | |
| 484 | + | .unwrap(); | |
| 485 | + | ||
| 486 | + | let pinned = pinned_dirs(&pool, &app()).await.unwrap(); | |
| 487 | + | assert!(pinned.is_empty(), "{pinned:?}"); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[tokio::test] | |
| 491 | + | async fn a_tier_that_has_never_deployed_pins_nothing() { | |
| 492 | + | let pool = fresh_pool().await; | |
| 493 | + | tier(&pool, "host", 0).await; | |
| 494 | + | assert!(pinned_dirs(&pool, &app()).await.unwrap().is_empty()); | |
| 495 | + | assert!( | |
| 496 | + | tier_refs(&pool, &app(), "makenotwork") | |
| 497 | + | .await | |
| 498 | + | .unwrap() | |
| 499 | + | .is_empty() | |
| 500 | + | ); |
Lines truncated