//! Disk retention. //! //! Build logs (`logs_root`) and collected artifacts (`dist_root`) are written //! per `(app, version)` and never removed, so both roots grow without bound — //! a build host accumulates every historical version forever until the disk //! fills (which then surfaces as silent SQLite write failures). This module //! keeps only the most recent [`KEEP_VERSIONS_PER_APP`] versions per app under //! each root; `main` runs it at startup and on a periodic timer. use crate::config::Config; use crate::domain::Version; use std::collections::HashSet; use std::path::{Path, PathBuf}; /// How many of the newest versions to keep per app, per root. Chosen for /// rollback/debug depth without unbounded growth (operator decision, Run 2). pub const KEEP_VERSIONS_PER_APP: usize = 5; /// A set of `(app, version)` pairs retention must never delete, even when they /// rank outside the newest N. Two classes belong here: /// /// - **In-flight** — a build is currently writing into the version's dir, so /// pruning it would pull a directory out from under a live `collect`/log /// stream. /// - **Released** — a published release (an OTA manifest / the `releases` table) /// still points at the version's artifacts. A slow-adopting `0.5.0` followed /// by five quick patch releases would otherwise fall outside the window and be /// deleted while clients are still fetching it. /// /// The caller assembles the set; retention only honours it. pub type ProtectedVersions = HashSet<(String, String)>; /// Prune both roots to the newest [`KEEP_VERSIONS_PER_APP`] versions per app, /// skipping any `(app, version)` in `protected`. Best-effort and blocking: IO /// errors are logged and skipped, never fatal. Intended to run inside /// `spawn_blocking`. pub fn prune_once(cfg: &Config, protected: &ProtectedVersions) { prune_root(&cfg.dist_root, "dist", protected); prune_root(&cfg.logs_root, "logs", protected); } fn prune_root(root: &Path, label: &str, protected: &ProtectedVersions) { let Ok(apps) = std::fs::read_dir(root) else { return; }; for app in apps.filter_map(std::result::Result::ok) { let app_dir = app.path(); if app_dir.is_dir() { let app_name = app.file_name().to_string_lossy().into_owned(); prune_app_dir(&app_dir, &app_name, label, protected); } } } fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, protected: &ProtectedVersions) { let Ok(entries) = std::fs::read_dir(app_dir) else { return; }; // Only consider subdirectories whose name parses as a semver — anything // else (a stray file, a non-version dir) is left untouched. let mut versions: Vec<(Version, String, PathBuf)> = entries .filter_map(std::result::Result::ok) .filter(|e| e.path().is_dir()) .filter_map(|e| { let name = e.file_name().to_string_lossy().into_owned(); let v = Version::parse(&name).ok()?; Some((v, name, e.path())) }) .collect(); if versions.len() <= KEEP_VERSIONS_PER_APP { return; } versions.sort_by(|a, b| b.0.cmp(&a.0)); // newest first for (ver, name, path) in versions.into_iter().skip(KEEP_VERSIONS_PER_APP) { // Never delete a version an in-flight build is writing to, or one a // published release still points at. if protected.contains(&(app_name.to_string(), name)) { tracing::debug!(%ver, app = app_name, "retention: skipping protected {label} version"); continue; } match std::fs::remove_dir_all(&path) { Ok(()) => { tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version"); } Err(e) => { tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir"); } } } } #[cfg(test)] mod tests { use super::*; fn touch_version(root: &Path, app: &str, ver: &str) { let d = root.join(app).join(ver); std::fs::create_dir_all(&d).unwrap(); std::fs::write(d.join("artifact.bin"), b"x").unwrap(); } #[test] fn keeps_newest_n_and_prunes_older_by_semver() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("dist"); // Seven versions; lexical order would mis-rank 0.4.10 vs 0.4.9. for v in [ "0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1", ] { touch_version(&root, "demo", v); } // A non-version dir must be left alone. std::fs::create_dir_all(root.join("demo").join("scratch")).unwrap(); prune_root(&root, "dist", &ProtectedVersions::new()); let mut kept: Vec = std::fs::read_dir(root.join("demo")) .unwrap() .filter_map(std::result::Result::ok) .map(|e| e.file_name().to_string_lossy().into_owned()) .collect(); kept.sort(); // Newest 5 by semver: 0.5.1, 0.5.0, 0.4.10, 0.4.9, 0.4.2 (+ the non-version dir). assert_eq!( kept, vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"] ); } #[test] fn no_op_when_under_limit() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("logs"); for v in ["0.1.0", "0.2.0"] { touch_version(&root, "demo", v); } prune_root(&root, "logs", &ProtectedVersions::new()); assert_eq!(std::fs::read_dir(root.join("demo")).unwrap().count(), 2); } #[test] fn never_prunes_a_protected_version() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("dist"); // Seven versions, keep 5 -> 0.1.0 and 0.2.0 are beyond the window. for v in [ "0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", ] { touch_version(&root, "demo", v); } // 0.1.0 is beyond the window but is protected (in-flight build, or a // still-referenced release). let protected: ProtectedVersions = [("demo".to_string(), "0.1.0".to_string())] .into_iter() .collect(); prune_root(&root, "dist", &protected); assert!( root.join("demo").join("0.1.0").exists(), "protected version must survive" ); // 0.2.0 (also beyond the window, not protected) is pruned instead. assert!(!root.join("demo").join("0.2.0").exists()); assert!( root.join("demo").join("0.3.0").exists(), "newest 5 are kept" ); } #[test] fn protects_a_released_version_far_outside_the_window() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("dist"); // A slow-adopting 0.5.0 followed by six newer patch releases: 0.5.0 is // seventh-newest, two slots beyond the keep window. for v in [ "0.5.0", "0.5.1", "0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6", ] { touch_version(&root, "demo", v); } // A live release still points at 0.5.0 (the caller unions the releases // table into the protected set). let protected: ProtectedVersions = [("demo".to_string(), "0.5.0".to_string())] .into_iter() .collect(); prune_root(&root, "dist", &protected); assert!( root.join("demo").join("0.5.0").exists(), "a released version must survive even far outside the keep window" ); // The next-oldest non-released version (0.5.1) is pruned in its place. assert!(!root.join("demo").join("0.5.1").exists()); } }