spark_runtime/
kv_spill.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! File-backed KV cache spill manager.
4//!
5//! Manages swap files for sequence-level KV cache + SSM state overflow.
6//! The serialization format is owned by the Model; this module handles
7//! file lifecycle and disk space enforcement.
8
9use anyhow::Result;
10use std::fs;
11use std::io::{BufReader, BufWriter};
12use std::path::PathBuf;
13
14/// Manages swap file creation, opening, deletion, and space limits.
15pub struct KvSpillManager {
16    spill_dir: PathBuf,
17    next_id: u64,
18    max_bytes: u64,
19    used_bytes: u64,
20}
21
22impl KvSpillManager {
23    /// Create a new spill manager rooted at `spill_dir` with a byte budget.
24    ///
25    /// Creates the directory if it doesn't exist. Removes any stale files
26    /// from prior runs.
27    pub fn new(spill_dir: PathBuf, max_bytes: u64) -> Result<Self> {
28        if spill_dir.exists() {
29            // Clean stale swap files from prior runs.
30            for entry in fs::read_dir(&spill_dir)? {
31                let entry = entry?;
32                if entry.file_name().to_string_lossy().starts_with("swap_") {
33                    let _ = fs::remove_file(entry.path());
34                }
35            }
36        } else {
37            fs::create_dir_all(&spill_dir)?;
38        }
39        Ok(Self {
40            spill_dir,
41            next_id: 0,
42            max_bytes,
43            used_bytes: 0,
44        })
45    }
46
47    /// Create a new swap file. Returns `(id, buffered_writer)`.
48    pub fn create_file(&mut self) -> Result<(u64, BufWriter<fs::File>)> {
49        let id = self.next_id;
50        self.next_id += 1;
51        let path = self.file_path(id);
52        let file = fs::File::create(&path)?;
53        Ok((id, BufWriter::new(file)))
54    }
55
56    /// Open an existing swap file for reading.
57    pub fn open_file(&self, id: u64) -> Result<BufReader<fs::File>> {
58        let path = self.file_path(id);
59        let file = fs::File::open(&path)?;
60        Ok(BufReader::new(file))
61    }
62
63    /// Remove a swap file and reclaim its disk usage.
64    pub fn remove_file(&mut self, id: u64) -> Result<()> {
65        let path = self.file_path(id);
66        let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
67        fs::remove_file(&path)?;
68        self.used_bytes = self.used_bytes.saturating_sub(size);
69        Ok(())
70    }
71
72    /// Record bytes written to a swap file (call after flush).
73    pub fn record_usage(&mut self, id: u64) {
74        let path = self.file_path(id);
75        if let Ok(meta) = fs::metadata(&path) {
76            self.used_bytes += meta.len();
77        }
78    }
79
80    /// Check if `estimated_bytes` can fit within the space budget.
81    pub fn has_space(&self, estimated_bytes: u64) -> bool {
82        self.used_bytes + estimated_bytes <= self.max_bytes
83    }
84
85    /// Current disk usage in bytes.
86    pub fn used_bytes(&self) -> u64 {
87        self.used_bytes
88    }
89
90    fn file_path(&self, id: u64) -> PathBuf {
91        self.spill_dir.join(format!("swap_{id}.bin"))
92    }
93}
94
95impl Drop for KvSpillManager {
96    fn drop(&mut self) {
97        // Best-effort cleanup of remaining swap files.
98        if let Ok(entries) = fs::read_dir(&self.spill_dir) {
99            for entry in entries.flatten() {
100                if entry.file_name().to_string_lossy().starts_with("swap_") {
101                    let _ = fs::remove_file(entry.path());
102                }
103            }
104        }
105        // And the directory itself. It is named per-PID, so a server that
106        // clears its files but leaves the directory strands one empty
107        // directory per start, forever — 28 of them had collected on the test
108        // box. The shared path this replaced could not accumulate, so the
109        // per-PID fix for cross-process wipes brought this with it.
110        //
111        // `remove_dir` refuses a non-empty directory, which is the behaviour
112        // to want: anything left in there is not ours to delete.
113        let _ = fs::remove_dir(&self.spill_dir);
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::io::Write;
121
122    fn temp_dir() -> PathBuf {
123        std::env::temp_dir().join(format!(
124            "atlas_spill_test_{}",
125            std::time::SystemTime::now()
126                .duration_since(std::time::UNIX_EPOCH)
127                .unwrap()
128                .as_nanos()
129        ))
130    }
131
132    #[test]
133    fn test_create_open_remove_lifecycle() {
134        let dir = temp_dir();
135        let mut mgr = KvSpillManager::new(dir.clone(), 1024 * 1024).unwrap();
136
137        // Create and write data.
138        let (id, mut writer) = mgr.create_file().unwrap();
139        writer.write_all(&[1u8; 256]).unwrap();
140        writer.flush().unwrap();
141        drop(writer);
142        mgr.record_usage(id);
143        assert_eq!(mgr.used_bytes(), 256);
144
145        // Open and read back.
146        let mut reader = mgr.open_file(id).unwrap();
147        let mut buf = Vec::new();
148        std::io::Read::read_to_end(&mut reader, &mut buf).unwrap();
149        assert_eq!(buf.len(), 256);
150        assert!(buf.iter().all(|&b| b == 1));
151
152        // Remove.
153        mgr.remove_file(id).unwrap();
154        assert_eq!(mgr.used_bytes(), 0);
155        assert!(!dir.join("swap_0.bin").exists());
156
157        // Cleanup test dir.
158        let _ = fs::remove_dir_all(&dir);
159    }
160
161    #[test]
162    fn test_has_space() {
163        let dir = temp_dir();
164        let mut mgr = KvSpillManager::new(dir.clone(), 512).unwrap();
165
166        assert!(mgr.has_space(512));
167        assert!(!mgr.has_space(513));
168
169        let (id, mut writer) = mgr.create_file().unwrap();
170        writer.write_all(&[0u8; 256]).unwrap();
171        writer.flush().unwrap();
172        drop(writer);
173        mgr.record_usage(id);
174
175        assert!(mgr.has_space(256));
176        assert!(!mgr.has_space(257));
177
178        let _ = fs::remove_dir_all(&dir);
179    }
180
181    #[test]
182    fn test_stale_cleanup_on_new() {
183        let dir = temp_dir();
184        fs::create_dir_all(&dir).unwrap();
185
186        // Create a stale swap file.
187        fs::write(dir.join("swap_99.bin"), [0u8; 64]).unwrap();
188        assert!(dir.join("swap_99.bin").exists());
189
190        // Creating a new manager should clean it up.
191        let _mgr = KvSpillManager::new(dir.clone(), 1024).unwrap();
192        assert!(!dir.join("swap_99.bin").exists());
193
194        let _ = fs::remove_dir_all(&dir);
195    }
196
197    #[test]
198    fn test_drop_cleanup() {
199        let dir = temp_dir();
200        {
201            let mut mgr = KvSpillManager::new(dir.clone(), 1024).unwrap();
202            let (_, mut writer) = mgr.create_file().unwrap();
203            writer.write_all(&[0u8; 32]).unwrap();
204            writer.flush().unwrap();
205            drop(writer);
206            // mgr drops here.
207        }
208        // File should be cleaned up by Drop.
209        assert!(!dir.join("swap_0.bin").exists());
210        let _ = fs::remove_dir_all(&dir);
211    }
212
213    #[test]
214    fn test_sequential_ids() {
215        let dir = temp_dir();
216        let mut mgr = KvSpillManager::new(dir.clone(), 1024 * 1024).unwrap();
217
218        let (id0, w0) = mgr.create_file().unwrap();
219        drop(w0);
220        let (id1, w1) = mgr.create_file().unwrap();
221        drop(w1);
222
223        assert_eq!(id0, 0);
224        assert_eq!(id1, 1);
225
226        let _ = fs::remove_dir_all(&dir);
227    }
228}
229
230#[cfg(test)]
231mod dir_cleanup_tests {
232    use super::*;
233
234    #[test]
235    fn dropping_the_manager_leaves_no_directory_behind() {
236        // The path is per-PID, so clearing the files but keeping the directory
237        // strands one empty directory per server start, forever. 28 had
238        // collected on the test box before this was noticed.
239        let dir = std::env::temp_dir().join(format!(
240            "atlas_spill_cleanup_{}_{:?}",
241            std::process::id(),
242            std::thread::current().id()
243        ));
244        {
245            let mgr = KvSpillManager::new(dir.clone(), 1024).expect("constructs");
246            assert!(dir.exists(), "the manager creates its directory");
247            drop(mgr);
248        }
249        assert!(!dir.exists(), "and removes it again");
250    }
251
252    #[test]
253    fn a_directory_holding_someone_elses_file_is_left_alone() {
254        // `remove_dir` refuses a non-empty directory, which is the behaviour to
255        // want: anything not ours is not ours to delete.
256        let dir = std::env::temp_dir().join(format!(
257            "atlas_spill_shared_{}_{:?}",
258            std::process::id(),
259            std::thread::current().id()
260        ));
261        std::fs::create_dir_all(&dir).expect("mkdir");
262        std::fs::write(dir.join("not-ours.txt"), b"keep me").expect("write");
263        {
264            let _mgr = KvSpillManager::new(dir.clone(), 1024).expect("constructs");
265        }
266        assert!(dir.exists(), "a directory with a foreign file survives");
267        assert!(dir.join("not-ours.txt").exists(), "and so does the file");
268        let _ = std::fs::remove_dir_all(&dir);
269    }
270}