jevsnes.git / packages / replay / src / storage.rs

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.

10use std::path::{Path, PathBuf};
11use std::{fs, io};
13use crate::header::{FORMAT_VERSION, RunHeader};
14use crate::keyframes::KeyframeIndex;

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

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