Keeps run/zbanks-window/replay/ under a cap by deleting the OLDEST
whole recordings first, then, if the recording being written is over the
cap on its own, thinning ITS oldest keyframes down to the coarse tier
(crate::keyframes::KeyframeIndex::thin_oldest_span) - never deleting it.
Recordings must not fill the disk (this host's own C: has run as low as
single-digit gigabytes free - ~/.claude/rules/wsl-interop.md), so a
recorder that never stops writing needs a backstop that does not depend
on anyone remembering to clean up by hand.
A conservative default: at 60.0988 fps, apps/replay-probe's measured
log rate alone is a couple of MB/hour; keyframes dominate. A cap this
size holds many hours of play without risking the low-single-digit-GB
floor ~/.claude/rules/wsl-interop.md warns to stop above.
20pub const DEFAULT_CAP_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB
Delete whole recording directories under replay_root, oldest first (by
name - crate::recorder's recording ids are zero-padded timestamps, so
string order is chronological), until the total is at or under
cap_bytes. active, the recording currently being written, is never
deleted; if it alone is still over the cap, its branches' oldest
dense/medium keyframes are thinned a coarse span at a time until it fits
or only coarse keyframes are left, so a long live session stays bounded
by its log plus ~120 coarse keyframes an hour. Returns the recordings
deleted.
44pub fn enforce_cap(replay_root: &Path, cap_bytes: u64, active: &Path) -> io::Result<Vec<PathBuf>> { 45 let mut runs: Vec<PathBuf> = match fs::read_dir(replay_root) { 46 Ok(entries) => entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(), 47 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), 48 Err(e) => return Err(e), 49 }; 50 runs.sort(); 51 let mut pruned = Vec::new(); 52 let mut total: u64 = runs.iter().map(|r| dir_size(r)).sum(); 53 while total > cap_bytes { 54 let Some(pos) = runs.iter().position(|r| r != active) else { break }; 55 let victim = runs.remove(pos); 56 total -= dir_size(&victim); 57 fs::remove_dir_all(&victim)?; 58 pruned.push(victim); 59 } 60 if total > cap_bytes { 61 let indexes: Vec<KeyframeIndex> = match fs::read_dir(active.join("branches")) { 62 Ok(entries) => entries.flatten().map(|e| KeyframeIndex::open(&e.path().join("keyframes"))).collect::<io::Result<_>>()?, 63 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(), 64 Err(e) => return Err(e), 65 }; 66 // Round-robin over branches, one span each, so no branch is 67 // stripped bare while another keeps its dense history. 68 'thinning: while total > cap_bytes { 69 let mut freed_any = false; 70 for index in &indexes { 71 let freed = index.thin_oldest_span()?; 72 freed_any |= freed > 0; 73 total = total.saturating_sub(freed); 74 if total <= cap_bytes { 75 break 'thinning; 76 } 77 } 78 if !freed_any { 79 break; 80 } 81 } 82 } 83 Ok(pruned) 84}
Delete every recording under replay_root whose header is missing,
unreadable, or of a different crate::header::FORMAT_VERSION - they
cannot be seeked by this build (crate::seek::SeekError::Format), so
keeping them only spends the cap. active is never touched: it may not
have written its header yet.
91pub fn remove_other_formats(replay_root: &Path, active: &Path) -> io::Result<Vec<PathBuf>> { 92 let runs: Vec<PathBuf> = match fs::read_dir(replay_root) { 93 Ok(entries) => entries.flatten().map(|e| e.path()).filter(|p| p.is_dir() && p != active).collect(), 94 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), 95 Err(e) => return Err(e), 96 }; 97 let mut removed = Vec::new(); 98 for run in runs { 99 let current = RunHeader::load(&run.join("header.bin")).is_ok_and(|h| h.format_version == FORMAT_VERSION); 100 if !current { 101 fs::remove_dir_all(&run)?; 102 removed.push(run); 103 } 104 } 105 Ok(removed) 106}
108#[cfg(test)] 109mod tests { 110 use super::*; 111 112 fn scratch(tag: &str) -> PathBuf { 113 let dir = std::env::temp_dir().join(format!("jev-replay-storage-test-{tag}-{}", std::process::id())); 114 let _ = fs::remove_dir_all(&dir); 115 fs::create_dir_all(&dir).unwrap(); 116 dir 117 } 118 119 fn make_run(root: &Path, name: &str, bytes: usize) -> PathBuf { 120 let dir = root.join(name); 121 fs::create_dir_all(&dir).unwrap(); 122 fs::write(dir.join("payload.bin"), vec![0u8; bytes]).unwrap(); 123 dir 124 } 125 126 #[test] 127 fn nothing_is_pruned_under_the_cap() { 128 let root = scratch("under-cap"); 129 make_run(&root, "run-1", 100); 130 make_run(&root, "run-2", 100); 131 let pruned = enforce_cap(&root, 10_000, &root.join("nonexistent-active")).unwrap(); 132 assert!(pruned.is_empty()); 133 assert_eq!(fs::read_dir(&root).unwrap().count(), 2); 134 } 135 136 #[test] 137 fn the_oldest_named_runs_go_first_until_under_cap() { 138 let root = scratch("oldest-first"); 139 make_run(&root, "run-1-oldest", 1000); 140 make_run(&root, "run-2-middle", 1000); 141 make_run(&root, "run-3-newest", 1000); 142 // 3000 bytes total, cap 1500: run-1 alone isn't enough (2000 left 143 // after it), so run-2 goes as well, leaving only run-3 (1000 <= 1500). 144 let pruned = enforce_cap(&root, 1500, &root.join("nonexistent-active")).unwrap(); 145 assert_eq!(pruned, vec![root.join("run-1-oldest"), root.join("run-2-middle")]); 146 let remaining: Vec<_> = fs::read_dir(&root).unwrap().map(|e| e.unwrap().file_name()).collect(); 147 assert_eq!(remaining.len(), 1); 148 } 149 150 #[test] 151 fn the_active_recording_is_never_pruned_even_if_it_is_the_oldest_and_alone_over_cap() { 152 let root = scratch("active-survives"); 153 let active = make_run(&root, "run-1-active", 5000); 154 make_run(&root, "run-2-newer", 100); 155 let pruned = enforce_cap(&root, 200, &active).unwrap(); 156 // The newer, small one is deleted first; the active one, however 157 // large, is left alone rather than deleting the run in progress. 158 assert_eq!(pruned, vec![root.join("run-2-newer")]); 159 assert!(active.exists()); 160 } 161 162 #[test] 163 fn a_missing_replay_root_prunes_nothing_rather_than_erroring() { 164 let root = scratch("missing-root"); 165 fs::remove_dir_all(&root).unwrap(); 166 let pruned = enforce_cap(&root, 100, &root.join("active")).unwrap(); 167 assert!(pruned.is_empty()); 168 } 169 170 #[test] 171 fn an_active_recording_over_the_cap_alone_is_thinned_to_its_coarse_keyframes() { 172 use crate::keyframes::{COARSE_FRAMES, DENSE_FRAMES, Tier, tier_of}; 173 let root = scratch("thin-active"); 174 let active = root.join("run-9"); 175 let idx = KeyframeIndex::open(&active.join("branches").join("root").join("keyframes")).unwrap(); 176 let mut f = 0; 177 while f <= COARSE_FRAMES * 4 { 178 // Varied bytes so every keyframe costs real space. 179 let snap: Vec<u8> = (0..2000u32).map(|i| (i.wrapping_mul(f as u32 + 7) >> 3) as u8).collect(); 180 idx.store(f, &snap).unwrap(); 181 f += DENSE_FRAMES; 182 } 183 let pruned = enforce_cap(&root, 1, &active).unwrap(); 184 assert!(pruned.is_empty(), "the active recording is never deleted"); 185 let left = idx.frames(); 186 assert!(left.iter().all(|&f| f == 0 || tier_of(f) == Tier::Coarse), "{left:?}"); 187 assert_eq!(left.len() as u64, 5, "anchor/coarse at 0 plus four coarse spans"); 188 for f in left { 189 idx.load(f).unwrap(); 190 } 191 } 192 193 #[test] 194 fn recordings_of_another_format_are_removed_and_the_active_one_is_not() { 195 let root = scratch("formats"); 196 let old = make_run(&root, "run-1", 10); 197 RunHeader { format_version: FORMAT_VERSION - 1, ..RunHeader::new(vec![], vec![], false, false) }.save(&old.join("header.bin")).unwrap(); 198 let headerless = make_run(&root, "run-2", 10); 199 let current = make_run(&root, "run-3", 10); 200 RunHeader::new(vec![], vec![], false, false).save(¤t.join("header.bin")).unwrap(); 201 let active = make_run(&root, "run-4", 10); 202 let removed = remove_other_formats(&root, &active).unwrap(); 203 assert_eq!(removed.len(), 2); 204 assert!(!old.exists() && !headerless.exists()); 205 assert!(current.exists() && active.exists()); 206 } 207}