| 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 |
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 |
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 |
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 |
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 |
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 |
|
}
|