Skip to main content

max / makenotwork

bento: retention never prunes a still-referenced release retention.rs pruned strictly by newest-N semver dirs per app, consulting only in-flight builds. A slow-adopting release followed by N quicker patches would fall outside the keep window and have its artifacts deleted while clients were still fetching it. Generalize the skip set from ActiveVersions (in-flight only) to ProtectedVersions (in-flight + released), and union the releases table into it in the retention loop. The releases table is the authoritative local record of what is published, so this also covers anything an OTA manifest references. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 00:19 UTC
Commit: 8fe722a09ea522a2521d1996a5bcd5e2241dbc94
Parent: 5eb98c5
2 files changed, +67 insertions, -26 deletions
@@ -100,7 +100,10 @@ async fn main() -> Result<()> {
100 100 };
101 101 // Disk retention: prune logs_root + dist_root to the newest N versions per
102 102 // app, at startup and every 6h, so neither root grows without bound. Skips
103 - // any (app, version) an in-flight build is writing to.
103 + // any (app, version) that must survive: one an in-flight build is writing
104 + // to, or one a published release still points at (so a slow-adopting
105 + // release that has fallen outside the keep window is never deleted out from
106 + // under clients still fetching it).
104 107 {
105 108 let cfg = app_state.cfg.clone();
106 109 let pool = app_state.pool.clone();
@@ -108,9 +111,11 @@ async fn main() -> Result<()> {
108 111 let mut tick = tokio::time::interval(std::time::Duration::from_hours(6));
109 112 loop {
110 113 tick.tick().await; // fires immediately, then every 6h
111 - let active: bento_daemon::retention::ActiveVersions =
114 + let protected: bento_daemon::retention::ProtectedVersions =
112 115 sqlx::query_as::<_, (String, String)>(
113 - "SELECT DISTINCT app, version FROM target_runs WHERE status = 'running'",
116 + "SELECT DISTINCT app, version FROM target_runs WHERE status = 'running' \
117 + UNION \
118 + SELECT DISTINCT app, version FROM releases",
114 119 )
115 120 .fetch_all(&pool)
116 121 .await
@@ -119,7 +124,7 @@ async fn main() -> Result<()> {
119 124 .collect();
120 125 let cfg = cfg.clone();
121 126 let _ = tokio::task::spawn_blocking(move || {
122 - bento_daemon::retention::prune_once(&cfg, &active);
127 + bento_daemon::retention::prune_once(&cfg, &protected);
123 128 })
124 129 .await;
125 130 }
@@ -16,21 +16,30 @@ use std::path::{Path, PathBuf};
16 16 /// rollback/debug depth without unbounded growth (operator decision, Run 2).
17 17 pub const KEEP_VERSIONS_PER_APP: usize = 5;
18 18
19 - /// A `(app, version)` pair an in-flight build is currently writing into. Such a
20 - /// version is never pruned even if it ranks outside the newest N, so retention
21 - /// can't delete a directory out from under a live `collect`/log stream.
22 - pub type ActiveVersions = HashSet<(String, String)>;
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)>;
23 32
24 33 /// Prune both roots to the newest [`KEEP_VERSIONS_PER_APP`] versions per app,
25 - /// skipping any `(app, version)` in `active`. Best-effort and blocking: IO
34 + /// skipping any `(app, version)` in `protected`. Best-effort and blocking: IO
26 35 /// errors are logged and skipped, never fatal. Intended to run inside
27 36 /// `spawn_blocking`.
28 - pub fn prune_once(cfg: &Config, active: &ActiveVersions) {
29 - prune_root(&cfg.dist_root, "dist", active);
30 - prune_root(&cfg.logs_root, "logs", active);
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);
31 40 }
32 41
33 - fn prune_root(root: &Path, label: &str, active: &ActiveVersions) {
42 + fn prune_root(root: &Path, label: &str, protected: &ProtectedVersions) {
34 43 let Ok(apps) = std::fs::read_dir(root) else {
35 44 return;
36 45 };
@@ -38,12 +47,12 @@ fn prune_root(root: &Path, label: &str, active: &ActiveVersions) {
38 47 let app_dir = app.path();
39 48 if app_dir.is_dir() {
40 49 let app_name = app.file_name().to_string_lossy().into_owned();
41 - prune_app_dir(&app_dir, &app_name, label, active);
50 + prune_app_dir(&app_dir, &app_name, label, protected);
42 51 }
43 52 }
44 53 }
45 54
46 - fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, active: &ActiveVersions) {
55 + fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, protected: &ProtectedVersions) {
47 56 let Ok(entries) = std::fs::read_dir(app_dir) else {
48 57 return;
49 58 };
@@ -63,9 +72,10 @@ fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, active: &ActiveVer
63 72 }
64 73 versions.sort_by(|a, b| b.0.cmp(&a.0)); // newest first
65 74 for (ver, name, path) in versions.into_iter().skip(KEEP_VERSIONS_PER_APP) {
66 - // Never delete a version an in-flight build is writing to.
67 - if active.contains(&(app_name.to_string(), name)) {
68 - tracing::debug!(%ver, app = app_name, "retention: skipping in-flight {label} version");
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");
69 79 continue;
70 80 }
71 81 match std::fs::remove_dir_all(&path) {
@@ -102,7 +112,7 @@ mod tests {
102 112 // A non-version dir must be left alone.
103 113 std::fs::create_dir_all(root.join("demo").join("scratch")).unwrap();
104 114
105 - prune_root(&root, "dist", &ActiveVersions::new());
115 + prune_root(&root, "dist", &ProtectedVersions::new());
106 116
107 117 let mut kept: Vec<String> = std::fs::read_dir(root.join("demo"))
108 118 .unwrap()
@@ -124,12 +134,12 @@ mod tests {
124 134 for v in ["0.1.0", "0.2.0"] {
125 135 touch_version(&root, "demo", v);
126 136 }
127 - prune_root(&root, "logs", &ActiveVersions::new());
137 + prune_root(&root, "logs", &ProtectedVersions::new());
128 138 assert_eq!(std::fs::read_dir(root.join("demo")).unwrap().count(), 2);
129 139 }
130 140
131 141 #[test]
132 - fn never_prunes_an_in_flight_version() {
142 + fn never_prunes_a_protected_version() {
133 143 let tmp = tempfile::tempdir().unwrap();
134 144 let root = tmp.path().join("dist");
135 145 // Seven versions, keep 5 -> 0.1.0 and 0.2.0 are beyond the window.
@@ -138,20 +148,46 @@ mod tests {
138 148 ] {
139 149 touch_version(&root, "demo", v);
140 150 }
141 - // 0.1.0 is beyond the window but is being built right now.
142 - let active: ActiveVersions = [("demo".to_string(), "0.1.0".to_string())]
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())]
143 154 .into_iter()
144 155 .collect();
145 - prune_root(&root, "dist", &active);
156 + prune_root(&root, "dist", &protected);
146 157 assert!(
147 158 root.join("demo").join("0.1.0").exists(),
148 - "in-flight version must survive"
159 + "protected version must survive"
149 160 );
150 - // 0.2.0 (also beyond the window, not active) is pruned instead.
161 + // 0.2.0 (also beyond the window, not protected) is pruned instead.
151 162 assert!(!root.join("demo").join("0.2.0").exists());
152 163 assert!(
153 164 root.join("demo").join("0.3.0").exists(),
154 165 "newest 5 are kept"
155 166 );
156 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 + }
157 193 }