1//! Keeps `run/zbanks-window/replay/` under a cap by deleting the OLDEST 2//! whole recordings first, then, if the recording being written is over the 3//! cap on its own, thinning ITS oldest keyframes down to the coarse tier 4//! (`crate::keyframes::KeyframeIndex::thin_oldest_span`) - never deleting it. 5//! Recordings must not fill the disk (this host's own C: has run as low as 6//! single-digit gigabytes free - `~/.claude/rules/wsl-interop.md`), so a 7//! recorder that never stops writing needs a backstop that does not depend 8//! on anyone remembering to clean up by hand. 9 10use std::path::{Path, PathBuf}; 11use std::{fs, io}; 12 13use crate::header::{FORMAT_VERSION, RunHeader}; 14use crate::keyframes::KeyframeIndex; 15 16/// A conservative default: at 60.0988 fps, `apps/replay-probe`'s measured 17/// log rate alone is a couple of MB/hour; keyframes dominate. A cap this 18/// size holds many hours of play without risking the low-single-digit-GB 19/// floor `~/.claude/rules/wsl-interop.md` warns to stop above. 20pub const DEFAULT_CAP_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB 21 22fn dir_size(path: &Path) -> u64 { 23 let mut total = 0; 24 if let Ok(entries) = fs::read_dir(path) { 25 for entry in entries.flatten() { 26 let p = entry.path(); 27 if let Ok(meta) = entry.metadata() { 28 total += if meta.is_dir() { dir_size(&p) } else { meta.len() }; 29 } 30 } 31 } 32 total 33} 34 35/// Delete whole recording directories under `replay_root`, oldest first (by 36/// name - `crate::recorder`'s recording ids are zero-padded timestamps, so 37/// string order is chronological), until the total is at or under 38/// `cap_bytes`. `active`, the recording currently being written, is never 39/// deleted; if it alone is still over the cap, its branches' oldest 40/// dense/medium keyframes are thinned a coarse span at a time until it fits 41/// or only coarse keyframes are left, so a long live session stays bounded 42/// by its log plus ~120 coarse keyframes an hour. Returns the recordings 43/// 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} 85 86/// Delete every recording under `replay_root` whose header is missing, 87/// unreadable, or of a different `crate::header::FORMAT_VERSION` - they 88/// cannot be seeked by this build (`crate::seek::SeekError::Format`), so 89/// keeping them only spends the cap. `active` is never touched: it may not 90/// 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} 107 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}