jevsnes.git / packages / replay / src / storage.rs
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(&current.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}