metrale_cache/
kv_spill.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Swap files for the `--swap-space-gb` spill of sequence state.
4//!
5//! Callers write and read the file contents; this module creates, opens and
6//! removes the `swap_<id>.bin` files in one directory and counts the bytes
7//! recorded against a budget. `has_space` reports the budget; nothing here
8//! enforces it.
9//!
10//! Owner: cache.
11//! Invariants: file ids are handed out in increasing order from 0, so a
12//! manager never reuses one.
13
14use anyhow::Result;
15use std::fs;
16use std::io::{BufReader, BufWriter};
17use std::path::PathBuf;
18
19/// 2026-09-25: The swap files of one directory and their recorded size.
20pub struct KvSpillManager {
21    spill_dir: PathBuf,
22    next_id: u64,
23    max_bytes: u64,
24    used_bytes: u64,
25}
26
27impl KvSpillManager {
28    /// 2026-09-25: A manager for `spill_dir` with a `max_bytes` budget.
29    /// Creates the directory if it is missing; otherwise deletes the `swap_*`
30    /// files already in it, ignoring failures to delete.
31    pub fn new(spill_dir: PathBuf, max_bytes: u64) -> Result<Self> {
32        if spill_dir.exists() {
33            for entry in fs::read_dir(&spill_dir)? {
34                let entry = entry?;
35                if entry.file_name().to_string_lossy().starts_with("swap_") {
36                    let _ = fs::remove_file(entry.path());
37                }
38            }
39        } else {
40            fs::create_dir_all(&spill_dir)?;
41        }
42        Ok(Self {
43            spill_dir,
44            next_id: 0,
45            max_bytes,
46            used_bytes: 0,
47        })
48    }
49
50    /// 2026-09-25: Create the next swap file; returns its id and a buffered
51    /// writer.
52    pub fn create_file(&mut self) -> Result<(u64, BufWriter<fs::File>)> {
53        let id = self.next_id;
54        self.next_id += 1;
55        let path = self.file_path(id);
56        let file = fs::File::create(&path)?;
57        Ok((id, BufWriter::new(file)))
58    }
59
60    /// 2026-09-25: Open swap file `id` for buffered reading.
61    pub fn open_file(&self, id: u64) -> Result<BufReader<fs::File>> {
62        let path = self.file_path(id);
63        let file = fs::File::open(&path)?;
64        Ok(BufReader::new(file))
65    }
66
67    /// 2026-09-25: Delete swap file `id` and subtract its size from the
68    /// recorded usage.
69    pub fn remove_file(&mut self, id: u64) -> Result<()> {
70        let path = self.file_path(id);
71        let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
72        fs::remove_file(&path)?;
73        self.used_bytes = self.used_bytes.saturating_sub(size);
74        Ok(())
75    }
76
77    /// 2026-09-25: Add file `id`'s current size to the recorded usage. Call
78    /// it once, after the writer is flushed; a missing file adds nothing.
79    pub fn record_usage(&mut self, id: u64) {
80        let path = self.file_path(id);
81        if let Ok(meta) = fs::metadata(&path) {
82            self.used_bytes += meta.len();
83        }
84    }
85
86    /// 2026-09-25: Whether `estimated_bytes` more would stay within the budget.
87    pub fn has_space(&self, estimated_bytes: u64) -> bool {
88        self.used_bytes + estimated_bytes <= self.max_bytes
89    }
90
91    /// 2026-09-25: Recorded usage in bytes.
92    pub fn used_bytes(&self) -> u64 {
93        self.used_bytes
94    }
95
96    fn file_path(&self, id: u64) -> PathBuf {
97        self.spill_dir.join(format!("swap_{id}.bin"))
98    }
99}
100
101impl Drop for KvSpillManager {
102    fn drop(&mut self) {
103        // 2026-09-25: Best effort: delete the `swap_*` files, then the
104        // directory.
105        if let Ok(entries) = fs::read_dir(&self.spill_dir) {
106            for entry in entries.flatten() {
107                if entry.file_name().to_string_lossy().starts_with("swap_") {
108                    let _ = fs::remove_file(entry.path());
109                }
110            }
111        }
112        // 2026-09-25: The server names the directory per process
113        // (`metrale-swap-<pid>`), so leaving it would leave one per run.
114        // `remove_dir` refuses a non-empty directory, so a file that is not
115        // ours survives.
116        let _ = fs::remove_dir(&self.spill_dir);
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::io::Write;
124
125    fn temp_dir() -> PathBuf {
126        std::env::temp_dir().join(format!(
127            "metrale_spill_test_{}",
128            std::time::SystemTime::now()
129                .duration_since(std::time::UNIX_EPOCH)
130                .unwrap()
131                .as_nanos()
132        ))
133    }
134
135    #[test]
136    fn test_create_open_remove_lifecycle() {
137        let dir = temp_dir();
138        let mut mgr = KvSpillManager::new(dir.clone(), 1024 * 1024).unwrap();
139
140        let (id, mut writer) = mgr.create_file().unwrap();
141        writer.write_all(&[1u8; 256]).unwrap();
142        writer.flush().unwrap();
143        drop(writer);
144        mgr.record_usage(id);
145        assert_eq!(mgr.used_bytes(), 256);
146
147        let mut reader = mgr.open_file(id).unwrap();
148        let mut buf = Vec::new();
149        std::io::Read::read_to_end(&mut reader, &mut buf).unwrap();
150        assert_eq!(buf.len(), 256);
151        assert!(buf.iter().all(|&b| b == 1));
152
153        mgr.remove_file(id).unwrap();
154        assert_eq!(mgr.used_bytes(), 0);
155        assert!(!dir.join("swap_0.bin").exists());
156
157        let _ = fs::remove_dir_all(&dir);
158    }
159
160    #[test]
161    fn test_has_space() {
162        let dir = temp_dir();
163        let mut mgr = KvSpillManager::new(dir.clone(), 512).unwrap();
164
165        assert!(mgr.has_space(512));
166        assert!(!mgr.has_space(513));
167
168        let (id, mut writer) = mgr.create_file().unwrap();
169        writer.write_all(&[0u8; 256]).unwrap();
170        writer.flush().unwrap();
171        drop(writer);
172        mgr.record_usage(id);
173
174        assert!(mgr.has_space(256));
175        assert!(!mgr.has_space(257));
176
177        let _ = fs::remove_dir_all(&dir);
178    }
179
180    #[test]
181    fn test_stale_cleanup_on_new() {
182        let dir = temp_dir();
183        fs::create_dir_all(&dir).unwrap();
184
185        fs::write(dir.join("swap_99.bin"), [0u8; 64]).unwrap();
186        assert!(dir.join("swap_99.bin").exists());
187
188        let _mgr = KvSpillManager::new(dir.clone(), 1024).unwrap();
189        assert!(!dir.join("swap_99.bin").exists());
190
191        let _ = fs::remove_dir_all(&dir);
192    }
193
194    #[test]
195    fn test_drop_cleanup() {
196        let dir = temp_dir();
197        {
198            let mut mgr = KvSpillManager::new(dir.clone(), 1024).unwrap();
199            let (_, mut writer) = mgr.create_file().unwrap();
200            writer.write_all(&[0u8; 32]).unwrap();
201            writer.flush().unwrap();
202            drop(writer);
203        }
204        assert!(!dir.join("swap_0.bin").exists());
205        let _ = fs::remove_dir_all(&dir);
206    }
207
208    #[test]
209    fn test_sequential_ids() {
210        let dir = temp_dir();
211        let mut mgr = KvSpillManager::new(dir.clone(), 1024 * 1024).unwrap();
212
213        let (id0, w0) = mgr.create_file().unwrap();
214        drop(w0);
215        let (id1, w1) = mgr.create_file().unwrap();
216        drop(w1);
217
218        assert_eq!(id0, 0);
219        assert_eq!(id1, 1);
220
221        let _ = fs::remove_dir_all(&dir);
222    }
223}
224
225#[cfg(test)]
226mod dir_cleanup_tests {
227    use super::*;
228
229    #[test]
230    fn dropping_the_manager_leaves_no_directory_behind() {
231        // 2026-09-25: Drop removes the directory it created.
232        let dir = std::env::temp_dir().join(format!(
233            "metrale_spill_cleanup_{}_{:?}",
234            std::process::id(),
235            std::thread::current().id()
236        ));
237        {
238            let mgr = KvSpillManager::new(dir.clone(), 1024).expect("constructs");
239            assert!(dir.exists(), "the manager creates its directory");
240            drop(mgr);
241        }
242        assert!(!dir.exists(), "and removes it again");
243    }
244
245    #[test]
246    fn a_directory_holding_someone_elses_file_is_left_alone() {
247        // 2026-09-25: A directory holding a file that is not `swap_*` survives
248        // the drop, and so does the file.
249        let dir = std::env::temp_dir().join(format!(
250            "metrale_spill_shared_{}_{:?}",
251            std::process::id(),
252            std::thread::current().id()
253        ));
254        std::fs::create_dir_all(&dir).expect("mkdir");
255        std::fs::write(dir.join("not-ours.txt"), b"keep me").expect("write");
256        {
257            let _mgr = KvSpillManager::new(dir.clone(), 1024).expect("constructs");
258        }
259        assert!(dir.exists(), "a directory with a foreign file survives");
260        assert!(dir.join("not-ours.txt").exists(), "and so does the file");
261        let _ = std::fs::remove_dir_all(&dir);
262    }
263}