Skip to main content

max / makenotwork

7.6 KB · 194 lines History Blame Raw
1 //! Disk retention.
2 //!
3 //! Build logs (`logs_root`) and collected artifacts (`dist_root`) are written
4 //! per `(app, version)` and never removed, so both roots grow without bound —
5 //! a build host accumulates every historical version forever until the disk
6 //! fills (which then surfaces as silent SQLite write failures). This module
7 //! keeps only the most recent [`KEEP_VERSIONS_PER_APP`] versions per app under
8 //! each root; `main` runs it at startup and on a periodic timer.
9
10 use crate::config::Config;
11 use crate::domain::Version;
12 use std::collections::HashSet;
13 use std::path::{Path, PathBuf};
14
15 /// How many of the newest versions to keep per app, per root. Chosen for
16 /// rollback/debug depth without unbounded growth (operator decision, Run 2).
17 pub const KEEP_VERSIONS_PER_APP: usize = 5;
18
19 /// A set of `(app, version)` pairs retention must never delete, even when they
20 /// rank outside the newest N. Two classes belong here:
21 ///
22 /// - **In-flight** — a build is currently writing into the version's dir, so
23 /// pruning it would pull a directory out from under a live `collect`/log
24 /// stream.
25 /// - **Released** — a published release (an OTA manifest / the `releases` table)
26 /// still points at the version's artifacts. A slow-adopting `0.5.0` followed
27 /// by five quick patch releases would otherwise fall outside the window and be
28 /// deleted while clients are still fetching it.
29 ///
30 /// The caller assembles the set; retention only honours it.
31 pub type ProtectedVersions = HashSet<(String, String)>;
32
33 /// Prune both roots to the newest [`KEEP_VERSIONS_PER_APP`] versions per app,
34 /// skipping any `(app, version)` in `protected`. Best-effort and blocking: IO
35 /// errors are logged and skipped, never fatal. Intended to run inside
36 /// `spawn_blocking`.
37 pub fn prune_once(cfg: &Config, protected: &ProtectedVersions) {
38 prune_root(&cfg.dist_root, "dist", protected);
39 prune_root(&cfg.logs_root, "logs", protected);
40 }
41
42 fn prune_root(root: &Path, label: &str, protected: &ProtectedVersions) {
43 let Ok(apps) = std::fs::read_dir(root) else {
44 return;
45 };
46 for app in apps.filter_map(std::result::Result::ok) {
47 let app_dir = app.path();
48 if app_dir.is_dir() {
49 let app_name = app.file_name().to_string_lossy().into_owned();
50 prune_app_dir(&app_dir, &app_name, label, protected);
51 }
52 }
53 }
54
55 fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, protected: &ProtectedVersions) {
56 let Ok(entries) = std::fs::read_dir(app_dir) else {
57 return;
58 };
59 // Only consider subdirectories whose name parses as a semver — anything
60 // else (a stray file, a non-version dir) is left untouched.
61 let mut versions: Vec<(Version, String, PathBuf)> = entries
62 .filter_map(std::result::Result::ok)
63 .filter(|e| e.path().is_dir())
64 .filter_map(|e| {
65 let name = e.file_name().to_string_lossy().into_owned();
66 let v = Version::parse(&name).ok()?;
67 Some((v, name, e.path()))
68 })
69 .collect();
70 if versions.len() <= KEEP_VERSIONS_PER_APP {
71 return;
72 }
73 versions.sort_by(|a, b| b.0.cmp(&a.0)); // newest first
74 for (ver, name, path) in versions.into_iter().skip(KEEP_VERSIONS_PER_APP) {
75 // Never delete a version an in-flight build is writing to, or one a
76 // published release still points at.
77 if protected.contains(&(app_name.to_string(), name)) {
78 tracing::debug!(%ver, app = app_name, "retention: skipping protected {label} version");
79 continue;
80 }
81 match std::fs::remove_dir_all(&path) {
82 Ok(()) => {
83 tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version");
84 }
85 Err(e) => {
86 tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir");
87 }
88 }
89 }
90 }
91
92 #[cfg(test)]
93 mod tests {
94 use super::*;
95
96 fn touch_version(root: &Path, app: &str, ver: &str) {
97 let d = root.join(app).join(ver);
98 std::fs::create_dir_all(&d).unwrap();
99 std::fs::write(d.join("artifact.bin"), b"x").unwrap();
100 }
101
102 #[test]
103 fn keeps_newest_n_and_prunes_older_by_semver() {
104 let tmp = tempfile::tempdir().unwrap();
105 let root = tmp.path().join("dist");
106 // Seven versions; lexical order would mis-rank 0.4.10 vs 0.4.9.
107 for v in [
108 "0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1",
109 ] {
110 touch_version(&root, "demo", v);
111 }
112 // A non-version dir must be left alone.
113 std::fs::create_dir_all(root.join("demo").join("scratch")).unwrap();
114
115 prune_root(&root, "dist", &ProtectedVersions::new());
116
117 let mut kept: Vec<String> = std::fs::read_dir(root.join("demo"))
118 .unwrap()
119 .filter_map(std::result::Result::ok)
120 .map(|e| e.file_name().to_string_lossy().into_owned())
121 .collect();
122 kept.sort();
123 // Newest 5 by semver: 0.5.1, 0.5.0, 0.4.10, 0.4.9, 0.4.2 (+ the non-version dir).
124 assert_eq!(
125 kept,
126 vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"]
127 );
128 }
129
130 #[test]
131 fn no_op_when_under_limit() {
132 let tmp = tempfile::tempdir().unwrap();
133 let root = tmp.path().join("logs");
134 for v in ["0.1.0", "0.2.0"] {
135 touch_version(&root, "demo", v);
136 }
137 prune_root(&root, "logs", &ProtectedVersions::new());
138 assert_eq!(std::fs::read_dir(root.join("demo")).unwrap().count(), 2);
139 }
140
141 #[test]
142 fn never_prunes_a_protected_version() {
143 let tmp = tempfile::tempdir().unwrap();
144 let root = tmp.path().join("dist");
145 // Seven versions, keep 5 -> 0.1.0 and 0.2.0 are beyond the window.
146 for v in [
147 "0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0",
148 ] {
149 touch_version(&root, "demo", v);
150 }
151 // 0.1.0 is beyond the window but is protected (in-flight build, or a
152 // still-referenced release).
153 let protected: ProtectedVersions = [("demo".to_string(), "0.1.0".to_string())]
154 .into_iter()
155 .collect();
156 prune_root(&root, "dist", &protected);
157 assert!(
158 root.join("demo").join("0.1.0").exists(),
159 "protected version must survive"
160 );
161 // 0.2.0 (also beyond the window, not protected) is pruned instead.
162 assert!(!root.join("demo").join("0.2.0").exists());
163 assert!(
164 root.join("demo").join("0.3.0").exists(),
165 "newest 5 are kept"
166 );
167 }
168
169 #[test]
170 fn protects_a_released_version_far_outside_the_window() {
171 let tmp = tempfile::tempdir().unwrap();
172 let root = tmp.path().join("dist");
173 // A slow-adopting 0.5.0 followed by six newer patch releases: 0.5.0 is
174 // seventh-newest, two slots beyond the keep window.
175 for v in [
176 "0.5.0", "0.5.1", "0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6",
177 ] {
178 touch_version(&root, "demo", v);
179 }
180 // A live release still points at 0.5.0 (the caller unions the releases
181 // table into the protected set).
182 let protected: ProtectedVersions = [("demo".to_string(), "0.5.0".to_string())]
183 .into_iter()
184 .collect();
185 prune_root(&root, "dist", &protected);
186 assert!(
187 root.join("demo").join("0.5.0").exists(),
188 "a released version must survive even far outside the keep window"
189 );
190 // The next-oldest non-released version (0.5.1) is pruned in its place.
191 assert!(!root.join("demo").join("0.5.1").exists());
192 }
193 }
194