Skip to main content

aptu_coder_core/graph/
store.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Disk-backed structural graph cache with versioned postcard encoding, fs2
4//! per-shard locking, atomic writes via NamedTempFile::persist, and size-capped
5//! LRU eviction by file mtime. All I/O errors degrade silently via tracing::warn!.
6//!
7//! `GraphDiskStore` itself and its public API compile on every target,
8//! including `wasm32-unknown-unknown`, so callers never need their own
9//! `cfg` gates. Only the actual disk I/O (which depends on `fs2` and
10//! `tempfile`, neither WASM-safe) is gated: on `wasm32`, `get` always misses
11//! and `put` is a no-op, matching aptu's `cache.rs` WASM stub pattern.
12
13use super::structural::StructuralGraph;
14use blake3;
15#[cfg(not(target_arch = "wasm32"))]
16use fs2::FileExt;
17#[cfg(not(target_arch = "wasm32"))]
18use std::io::Write;
19use std::path::{Path, PathBuf};
20#[cfg(not(target_arch = "wasm32"))]
21use tempfile::NamedTempFile;
22#[cfg(not(target_arch = "wasm32"))]
23use tracing::warn;
24
25#[cfg(not(target_arch = "wasm32"))]
26const FORMAT_VERSION: u32 = 2;
27pub const DEFAULT_MAX_DISK_CACHE_BYTES: u64 = 512 * 1024 * 1024;
28
29#[cfg(not(target_arch = "wasm32"))]
30struct ShardLockGuard {
31    _file: std::fs::File,
32}
33/// `.lock` files are 0-byte advisory control files, never written to.
34/// Shard count is bounded at 256 by the 2-hex-char blake3 key prefix (`&key[..2]`).
35#[cfg(not(target_arch = "wasm32"))]
36fn lock_shard_shared(shard_dir: &Path) -> Option<ShardLockGuard> {
37    let lock_path = shard_dir.join(".lock");
38    let file = std::fs::OpenOptions::new()
39        .create(true)
40        .write(true)
41        .truncate(false)
42        .open(&lock_path)
43        .ok()?;
44    file.lock_shared().map_err(|e| {
45        warn!(error = %e, lock_path = %lock_path.display(), "graph store: shared lock failed")
46    }).ok()?;
47    Some(ShardLockGuard { _file: file })
48}
49
50/// `.lock` files are 0-byte advisory control files, never written to.
51/// Shard count is bounded at 256 by the 2-hex-char blake3 key prefix (`&key[..2]`).
52#[cfg(not(target_arch = "wasm32"))]
53fn lock_shard_exclusive(shard_dir: &Path) -> Result<ShardLockGuard, std::io::Error> {
54    let lock_path = shard_dir.join(".lock");
55    let file = std::fs::OpenOptions::new()
56        .create(true)
57        .write(true)
58        .truncate(false)
59        .open(&lock_path)?;
60    file.lock_exclusive()?;
61    Ok(ShardLockGuard { _file: file })
62}
63
64#[cfg(not(target_arch = "wasm32"))]
65fn write_entry_atomically(dir: &Path, path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
66    let _lock = lock_shard_exclusive(dir)?;
67    let mut tmp = NamedTempFile::new_in(dir)?;
68    tmp.write_all(data)?;
69    tmp.persist(path).map(|_| ()).map_err(|e| e.error)
70}
71
72#[cfg(not(target_arch = "wasm32"))]
73fn evict_lru_if_over_budget(base_dir: &Path, max_bytes: u64) {
74    // List all .bin files in all shard subdirectories
75    let mut entries = Vec::new();
76
77    match std::fs::read_dir(base_dir) {
78        Ok(shards) => {
79            for shard_entry in shards.flatten() {
80                let shard_path = shard_entry.path();
81                if !shard_path.is_dir() {
82                    continue;
83                }
84
85                // List .bin files in this shard
86                if let Ok(files) = std::fs::read_dir(&shard_path) {
87                    for file_entry in files.flatten() {
88                        let file_path = file_entry.path();
89                        if file_path.extension().and_then(|e| e.to_str()) != Some("bin") {
90                            continue;
91                        }
92
93                        if let Ok(metadata) = file_entry.metadata() {
94                            entries.push((file_path, metadata.len(), metadata.modified().ok()));
95                        }
96                    }
97                }
98            }
99        }
100        Err(e) => {
101            warn!(path = %base_dir.display(), error = %e, "graph store: failed to read base dir for eviction");
102            return;
103        }
104    }
105
106    // Sum total size
107    let total_size: u64 = entries.iter().map(|(_, len, _)| len).sum();
108    if total_size <= max_bytes {
109        return;
110    }
111
112    // Sort by mtime ascending (oldest first)
113    entries.sort_by(|a, b| {
114        // Files without mtime go to the front (oldest)
115        match (&a.2, &b.2) {
116            (None, None) => std::cmp::Ordering::Equal,
117            (None, Some(_)) => std::cmp::Ordering::Less,
118            (Some(_), None) => std::cmp::Ordering::Greater,
119            (Some(at), Some(bt)) => at.cmp(bt),
120        }
121    });
122
123    // Delete entries until under budget
124    let mut current_size = total_size;
125    for (path, size, _) in entries {
126        if current_size <= max_bytes {
127            break;
128        }
129
130        // Lock the shard directory
131        let shard_dir = path.parent().unwrap_or(base_dir);
132        match lock_shard_exclusive(shard_dir) {
133            Ok(_lock) => {
134                if let Err(e) = std::fs::remove_file(&path) {
135                    warn!(key = %path.display(), error = %e, "graph store: eviction remove_file failed");
136                } else {
137                    current_size = current_size.saturating_sub(size);
138                }
139            }
140            Err(e) => {
141                warn!(shard = %shard_dir.display(), error = %e, "graph store: eviction lock failed");
142                continue;
143            }
144        }
145    }
146}
147
148pub struct GraphDiskStore {
149    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
150    base_dir: PathBuf,
151    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
152    max_bytes: u64,
153}
154
155impl GraphDiskStore {
156    pub fn new(base_dir: PathBuf) -> Self {
157        Self::new_with_max_bytes(base_dir, DEFAULT_MAX_DISK_CACHE_BYTES)
158    }
159
160    #[cfg(not(target_arch = "wasm32"))]
161    pub fn new_with_max_bytes(base_dir: PathBuf, max_bytes: u64) -> Self {
162        if let Err(e) = std::fs::create_dir_all(&base_dir) {
163            warn!(path = %base_dir.display(), error = %e, "graph store: failed to create base dir");
164        }
165        GraphDiskStore {
166            base_dir,
167            max_bytes,
168        }
169    }
170
171    /// WASM stub: no directory creation, no disk I/O.
172    #[cfg(target_arch = "wasm32")]
173    pub fn new_with_max_bytes(base_dir: PathBuf, max_bytes: u64) -> Self {
174        GraphDiskStore {
175            base_dir,
176            max_bytes,
177        }
178    }
179
180    pub fn cache_key(root: &Path, file_hashes: &[(PathBuf, blake3::Hash)]) -> String {
181        let mut hasher = blake3::Hasher::new();
182        hasher.update(root.to_string_lossy().as_bytes());
183        let mut sorted: Vec<&(PathBuf, blake3::Hash)> = file_hashes.iter().collect();
184        sorted.sort_by(|a, b| a.0.cmp(&b.0));
185        for (path, hash) in &sorted {
186            hasher.update(path.to_string_lossy().as_bytes());
187            hasher.update(hash.as_bytes());
188        }
189        hasher.finalize().to_string()
190    }
191
192    #[cfg(not(target_arch = "wasm32"))]
193    fn entry_path(&self, key: &str) -> PathBuf {
194        self.base_dir.join(&key[..2]).join(format!("{}.bin", key))
195    }
196
197    #[cfg(not(target_arch = "wasm32"))]
198    pub fn get(&self, key: &str) -> Option<StructuralGraph> {
199        let path = self.entry_path(key);
200        let dir = path.parent()?;
201        let _lock = lock_shard_shared(dir)?;
202        let data = std::fs::read(&path).ok()?;
203        if data.len() < 4 {
204            return None;
205        }
206        let (hdr, payload) = data.split_at(4);
207        if u32::from_le_bytes(<[u8; 4]>::try_from(hdr).ok()?) != FORMAT_VERSION {
208            warn!(key, "graph store: format version mismatch");
209            return None;
210        }
211        let mut graph: StructuralGraph = postcard::from_bytes(payload).ok()?;
212        graph.rebuild_symbol_index();
213
214        // Touch file mtime to mark as recently used (best-effort)
215        if let Ok(file) = std::fs::File::options().write(true).open(&path)
216            && let Err(e) = file.set_modified(std::time::SystemTime::now())
217        {
218            warn!(key, error = %e, "graph store: failed to touch mtime on read");
219        }
220
221        Some(graph)
222    }
223
224    /// WASM stub: the cache always misses, no disk I/O.
225    #[cfg(target_arch = "wasm32")]
226    pub fn get(&self, _key: &str) -> Option<StructuralGraph> {
227        None
228    }
229
230    #[cfg(not(target_arch = "wasm32"))]
231    pub fn put(&self, key: &str, graph: &StructuralGraph) {
232        let payload = match postcard::to_allocvec(graph) {
233            Ok(p) => p,
234            Err(e) => {
235                warn!(key, error = %e, "graph store: serialize failed");
236                return;
237            }
238        };
239        let mut data = Vec::with_capacity(4 + payload.len());
240        data.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
241        data.extend_from_slice(&payload);
242        let path = self.entry_path(key);
243        let Some(dir) = path.parent().map(|d| d.to_path_buf()) else {
244            return;
245        };
246        if let Err(e) = std::fs::create_dir_all(&dir) {
247            warn!(key, error = %e, "graph store: mkdir failed");
248            return;
249        }
250        if let Err(e) = write_entry_atomically(&dir, &path, &data) {
251            warn!(key, error = %e, "graph store: write failed");
252            return;
253        }
254
255        // Evict old entries if over budget (after successful write)
256        evict_lru_if_over_budget(&self.base_dir, self.max_bytes);
257    }
258
259    /// WASM stub: no-op, no disk I/O.
260    #[cfg(target_arch = "wasm32")]
261    pub fn put(&self, _key: &str, _graph: &StructuralGraph) {}
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use tempfile::TempDir;
268
269    fn make_test_graph() -> StructuralGraph {
270        use crate::graph::structural::Node;
271        let mut g = petgraph::graph::DiGraph::new();
272        g.add_node(Node::File {
273            path: "t.rs".into(),
274        });
275        StructuralGraph::from_graph(g)
276    }
277
278    #[test]
279    fn test_put_and_get_roundtrip() {
280        let tmp = TempDir::new().expect("temp dir");
281        let store = GraphDiskStore::new(tmp.path().to_path_buf());
282        let graph = make_test_graph();
283        store.put("key1", &graph);
284        let got = store.get("key1");
285        assert!(got.is_some());
286        assert_eq!(got.unwrap().graph.node_count(), 1);
287    }
288
289    #[test]
290    fn test_get_version_mismatch_returns_none() {
291        let tmp = TempDir::new().expect("temp dir");
292        let store = GraphDiskStore::new(tmp.path().to_path_buf());
293        let key = "vm";
294        let dir = tmp.path().join(&key[..2]);
295        let path = dir.join(format!("{}.bin", key));
296        std::fs::create_dir_all(&dir).ok();
297        let mut data = 99u32.to_le_bytes().to_vec();
298        data.extend_from_slice(b"x");
299        std::fs::write(&path, &data).ok();
300        assert!(store.get(key).is_none());
301    }
302
303    #[test]
304    fn test_eviction_by_lru_mtime() {
305        let tmp = TempDir::new().expect("temp dir");
306        let graph = make_test_graph();
307
308        // Measure one entry's on-disk size so the budget can be sized tightly
309        // enough to actually force eviction (a budget far above real entry
310        // sizes would let every entry survive, making the test vacuous).
311        let probe_store = GraphDiskStore::new(tmp.path().join("probe"));
312        probe_store.put("probe_key", &graph);
313        let entry_size = std::fs::metadata(probe_store.entry_path("probe_key"))
314            .expect("probe entry metadata")
315            .len();
316
317        // Budget fits exactly one entry, so each new put must evict the oldest.
318        let store = GraphDiskStore::new_with_max_bytes(tmp.path().join("cache"), entry_size + 1);
319
320        store.put("aaa_key1", &graph);
321        std::thread::sleep(std::time::Duration::from_millis(20));
322        store.put("aaa_key2", &graph);
323        std::thread::sleep(std::time::Duration::from_millis(20));
324        store.put("aaa_key3", &graph);
325
326        assert!(
327            store.get("aaa_key1").is_none(),
328            "oldest entry should have been evicted"
329        );
330        assert!(
331            store.get("aaa_key2").is_none(),
332            "middle entry should have been evicted"
333        );
334        assert!(
335            store.get("aaa_key3").is_some(),
336            "newest entry should survive eviction"
337        );
338    }
339
340    #[test]
341    fn test_get_touches_mtime() {
342        let tmp = TempDir::new().expect("temp dir");
343        let store = GraphDiskStore::new(tmp.path().to_path_buf());
344        let graph = make_test_graph();
345
346        store.put("touch_key", &graph);
347        let path = store.entry_path("touch_key");
348
349        // Get initial mtime
350        let initial_mtime = std::fs::metadata(&path)
351            .ok()
352            .and_then(|m| m.modified().ok());
353
354        // Sleep a tiny bit to ensure time difference is measurable
355        std::thread::sleep(std::time::Duration::from_millis(10));
356
357        // Read the entry
358        let _graph = store.get("touch_key");
359
360        // Get new mtime
361        let new_mtime = std::fs::metadata(&path)
362            .ok()
363            .and_then(|m| m.modified().ok());
364
365        // New mtime should be >= initial mtime (should be newer due to touch)
366        if let (Some(im), Some(nm)) = (initial_mtime, new_mtime) {
367            assert!(nm >= im, "mtime should advance or stay same on read");
368        } // Can't test if metadata fails
369    }
370}