Skip to main content

rto_graph/
cache.rs

1//! Content-addressed on-disk cache of per-blob [`FactSet`]s.
2//!
3//! The cache is a simple content-addressed key→[`FactSet`] store; the caller
4//! derives the key (see [`crate::sync`], which keys by blob oid **and** path,
5//! because extraction is a pure function of both). The cache lives under the
6//! repository's *common* git directory (e.g. `<common>/roteiro/objects/`), so
7//! all worktrees and branches that share a key share its extracted facts.
8//! Entries are JSON, sharded by the first two characters of the key (git-style)
9//! to keep directories small.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::FactSet;
15
16/// Errors raised by the object cache.
17#[derive(Debug, thiserror::Error)]
18pub enum CacheError {
19    /// Filesystem failure.
20    #[error("cache io error: {0}")]
21    Io(#[from] std::io::Error),
22    /// A cached entry could not be (de)serialized.
23    #[error("cache json error: {0}")]
24    Json(#[from] serde_json::Error),
25}
26
27/// What one [`ObjectCache::sweep`] pass did.
28///
29/// `scanned` is exactly `retained + removed + raced + failed`, and everything
30/// under the root that is not an entry lands in `skipped` instead — so a sweep
31/// that had nothing to do and a sweep that could not do it do not print the same
32/// line.
33#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
34pub struct ObjectSweep {
35    /// Cache entries examined — every `<shard>/<rest>.json` under the root.
36    pub scanned: usize,
37    /// Entries `retain` kept.
38    pub retained: usize,
39    /// Entries `retain` rejected and this pass deleted.
40    pub removed: usize,
41    /// Bytes still held by the `retained` entries.
42    pub retained_bytes: u64,
43    /// Bytes freed — the size of the `removed` entries before deletion.
44    pub freed_bytes: u64,
45    /// Rejected entries already gone when the delete ran: another process swept
46    /// the same shared cache concurrently. Counted rather than raised, because
47    /// two sweeps agreeing is the expected outcome, not a fault.
48    pub raced: usize,
49    /// Rejected entries that could not be deleted — a permission problem, or a
50    /// platform that refuses to unlink a file another process holds open.
51    /// Counted and reported rather than aborting: a sweep that stops at the first
52    /// stuck file both reclaims less and says nothing about why.
53    pub failed: usize,
54    /// Files under the root that are **not** entries: a `.json.tmp.<pid>-<nanos>`
55    /// from a [`ObjectCache::put`] still in flight, or anything a later format
56    /// puts here. Never shown to `retain` and never deleted — a sweep that
57    /// guesses at a name it does not recognise is a sweep that deletes another
58    /// process's half-written work.
59    pub skipped: usize,
60}
61
62/// A content-addressed store of fact sets on disk.
63pub struct ObjectCache {
64    root: PathBuf,
65}
66
67impl ObjectCache {
68    /// Open (creating if absent) a cache rooted at `root`.
69    ///
70    /// # Errors
71    /// Returns [`CacheError::Io`] if the root directory cannot be created.
72    pub fn open(root: impl Into<PathBuf>) -> Result<Self, CacheError> {
73        let root = root.into();
74        fs::create_dir_all(&root)?;
75        Ok(Self { root })
76    }
77
78    /// The directory this cache stores objects under.
79    #[must_use]
80    pub fn root(&self) -> &Path {
81        &self.root
82    }
83
84    fn path_for(&self, blob_id: &str) -> PathBuf {
85        // Shard by the first two characters, like git's `objects/ab/cdef…`.
86        let (shard, rest) = blob_id.split_at(blob_id.len().min(2));
87        self.root.join(shard).join(format!("{rest}.json"))
88    }
89
90    /// Whether a fact set is cached for `blob_id`.
91    #[must_use]
92    pub fn contains(&self, blob_id: &str) -> bool {
93        self.path_for(blob_id).exists()
94    }
95
96    /// Load the cached fact set for `blob_id`, if present.
97    ///
98    /// # Errors
99    /// Returns [`CacheError::Io`] on read failure or [`CacheError::Json`] if the
100    /// entry cannot be decoded.
101    pub fn get(&self, blob_id: &str) -> Result<Option<FactSet>, CacheError> {
102        let path = self.path_for(blob_id);
103        match fs::read(&path) {
104            Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
105            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
106            Err(e) => Err(e.into()),
107        }
108    }
109
110    /// Store `facts` under `blob_id`, replacing any existing entry. The write is
111    /// atomic (write-to-temp then rename) so a crash never leaves a torn entry.
112    ///
113    /// # Errors
114    /// Returns [`CacheError::Io`] on write failure or [`CacheError::Json`] if
115    /// `facts` cannot be encoded.
116    pub fn put(&self, blob_id: &str, facts: &FactSet) -> Result<(), CacheError> {
117        let path = self.path_for(blob_id);
118        if let Some(parent) = path.parent() {
119            fs::create_dir_all(parent)?;
120        }
121
122        // Use a unique temp file name to avoid cross-process clobbering.
123        let unique = format!(
124            "{}-{}",
125            std::process::id(),
126            std::time::SystemTime::now()
127                .duration_since(std::time::UNIX_EPOCH)
128                .unwrap_or_default()
129                .as_nanos()
130        );
131        let tmp = path.with_extension(format!("json.tmp.{unique}"));
132
133        let bytes = serde_json::to_vec(facts)?;
134        fs::write(&tmp, &bytes)?;
135
136        match fs::rename(&tmp, &path) {
137            Ok(()) => Ok(()),
138            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
139                match fs::remove_file(&path) {
140                    Ok(()) => {}
141                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
142                    Err(e) => return Err(e.into()),
143                }
144                fs::rename(&tmp, &path)?;
145                Ok(())
146            }
147            Err(e) => Err(e.into()),
148        }
149    }
150
151    /// Delete every entry whose key `retain` rejects, returning what the pass did.
152    ///
153    /// **This module deliberately does not know what a key means.** It derives
154    /// none and interprets none — the caller derives the key (see the module
155    /// doc), so the caller is the only thing entitled to say which keys are still
156    /// reachable. `retain` receives the *whole* key, reassembled from the shard
157    /// directory and the file stem, so a policy that reads any part of it reads
158    /// the same string [`Self::put`] was given.
159    ///
160    /// The pass is safe to run while other processes are using the same cache —
161    /// which is not optional, because the root lives under the **common** git dir
162    /// and every worktree shares it:
163    ///
164    /// - Entries are whole files written by atomic rename, and this deletes whole
165    ///   files, so no reader can observe a torn one. A reader that had already
166    ///   opened a deleted entry keeps reading it (POSIX); a reader that had not
167    ///   gets [`Self::get`]'s ordinary `None`, which is a cache miss — and a miss
168    ///   costs a re-extraction, never a wrong answer, because the cache is
169    ///   derived. That is the whole reason a mistaken `retain` is survivable.
170    /// - Nothing that is not an entry is touched, so a concurrent `put`'s temp
171    ///   file survives to be renamed.
172    /// - Shard directories are **not** removed, even when emptied. `put` does
173    ///   `create_dir_all` and *then* writes; removing the directory in between
174    ///   would fail an unrelated process's write to reclaim four kilobytes.
175    ///
176    /// # Errors
177    /// Returns [`CacheError::Io`] if the root or a shard cannot be listed — an
178    /// unreadable cache is reported, never silently swept as empty. Per-entry
179    /// delete failures are counted in [`ObjectSweep::failed`] instead, so one
180    /// stuck file does not abandon the rest.
181    pub fn sweep(&self, retain: &dyn Fn(&str) -> bool) -> Result<ObjectSweep, CacheError> {
182        let mut report = ObjectSweep::default();
183        for shard in fs::read_dir(&self.root)? {
184            let shard = shard?;
185            // `file_type` on a `DirEntry` does not follow links, so a symlinked
186            // directory is skipped rather than walked out of the cache.
187            if !shard.file_type()?.is_dir() {
188                report.skipped += 1;
189                continue;
190            }
191            let Some(prefix) = shard.file_name().to_str().map(str::to_owned) else {
192                // A shard name that is not UTF-8 cannot be half of a key this
193                // cache wrote, so its contents are not ours to judge.
194                report.skipped += 1;
195                continue;
196            };
197            Self::sweep_shard(&shard.path(), &prefix, retain, &mut report)?;
198        }
199        Ok(report)
200    }
201
202    /// One shard directory of [`Self::sweep`].
203    fn sweep_shard(
204        dir: &Path,
205        prefix: &str,
206        retain: &dyn Fn(&str) -> bool,
207        report: &mut ObjectSweep,
208    ) -> Result<(), CacheError> {
209        for entry in fs::read_dir(dir)? {
210            let entry = entry?;
211            let name = entry.file_name();
212            // An entry is named exactly `<rest>.json`. A temp file is
213            // `<rest>.json.tmp.<unique>` and so fails this test, which is the
214            // point: it belongs to a `put` that has not finished.
215            let Some(rest) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
216                report.skipped += 1;
217                continue;
218            };
219            // `symlink_metadata` does not follow links, so a symlink is never
220            // mistaken for an entry nor followed out of the cache — and it gives
221            // the size in the same call, with one race to handle instead of two.
222            let bytes = match fs::symlink_metadata(entry.path()) {
223                Ok(meta) if meta.is_file() => meta.len(),
224                Ok(_) => {
225                    report.skipped += 1;
226                    continue;
227                }
228                // Gone between listing and stat: another sweep of this shared
229                // cache got there first. Nothing left to reclaim, nothing wrong.
230                //
231                // **Defensive, and not covered by a test.** Hitting it needs a
232                // delete inside the window between `read_dir` yielding a name and
233                // this stat, which nothing here can open deterministically —
234                // whether a deleted name is still yielded depends on the
235                // platform's directory buffering, so a test for it would be
236                // flaky rather than a test. It is counted exactly as the same
237                // race on `remove_file` below is (`sweep_counts_an_entry_a_
238                // concurrent_sweep_removed_first`), which *is* covered; the
239                // alternative — letting it propagate — would make one sweep of a
240                // shared cache fail because another was doing its job.
241                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
242                    report.scanned += 1;
243                    report.raced += 1;
244                    continue;
245                }
246                Err(e) => return Err(e.into()),
247            };
248
249            report.scanned += 1;
250            // The key as `put` received it: the shard is its first two characters,
251            // not a hash of it, so concatenating recovers the original exactly.
252            let key = format!("{prefix}{rest}");
253            if retain(&key) {
254                report.retained += 1;
255                report.retained_bytes += bytes;
256                continue;
257            }
258            match fs::remove_file(entry.path()) {
259                Ok(()) => {
260                    report.removed += 1;
261                    report.freed_bytes += bytes;
262                }
263                Err(e) if e.kind() == std::io::ErrorKind::NotFound => report.raced += 1,
264                Err(_) => report.failed += 1,
265            }
266        }
267        Ok(())
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::ObjectCache;
274    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind};
275
276    /// A cache root unique to `name` and this process, with any stale copy gone.
277    fn fresh(name: &str) -> std::path::PathBuf {
278        let dir = std::env::temp_dir().join(format!("roteiro-cache-{name}-{}", std::process::id()));
279        std::fs::remove_dir_all(&dir).ok();
280        dir
281    }
282
283    fn sample() -> FactSet {
284        FactSet::new()
285            .with_node(Node::new("a", NodeKind::Fn, "a"))
286            .with_node(Node::new("b", NodeKind::Fn, "b"))
287            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
288    }
289
290    #[test]
291    fn put_get_round_trip_and_miss() {
292        let dir = std::env::temp_dir().join(format!("roteiro-cache-{}", std::process::id()));
293        std::fs::remove_dir_all(&dir).ok();
294        let cache = ObjectCache::open(&dir).expect("open");
295
296        assert!(!cache.contains("deadbeef"));
297        assert!(cache.get("deadbeef").expect("get").is_none());
298
299        let facts = sample();
300        cache.put("deadbeef", &facts).expect("put");
301        assert!(cache.contains("deadbeef"));
302        assert_eq!(cache.get("deadbeef").expect("get"), Some(facts));
303
304        std::fs::remove_dir_all(&dir).expect("cleanup");
305    }
306
307    #[test]
308    fn put_overwrites_existing_entry() {
309        let dir =
310            std::env::temp_dir().join(format!("roteiro-cache-overwrite-{}", std::process::id()));
311        std::fs::remove_dir_all(&dir).ok();
312        let cache = ObjectCache::open(&dir).expect("open");
313
314        cache.put("beef", &sample()).expect("first put");
315        // A second put for the same key must atomically replace the entry.
316        let replacement = FactSet::new().with_node(Node::new("only", NodeKind::File, "only"));
317        cache.put("beef", &replacement).expect("overwrite");
318        assert_eq!(cache.get("beef").expect("get"), Some(replacement));
319
320        std::fs::remove_dir_all(&dir).expect("cleanup");
321    }
322
323    /// The predicate decides, and it decides on the **whole** key — not the file
324    /// stem, which is the key minus its shard. Keys differing only in their first
325    /// character land in different shard directories with *identical* stems, so a
326    /// sweep that judged the stem would give both the same verdict. Here one is
327    /// kept and one removed, which only the reassembled key can distinguish.
328    #[test]
329    fn sweep_judges_the_whole_key_not_the_file_stem() {
330        let dir = fresh("sweep-key");
331        let cache = ObjectCache::open(&dir).expect("open");
332        cache.put("aakeep", &sample()).expect("put keep");
333        cache.put("bakeep", &sample()).expect("put drop");
334
335        let swept = cache
336            .sweep(&|key| key.starts_with("aa"))
337            .expect("sweep should read the cache");
338
339        assert_eq!(swept.scanned, 2);
340        assert_eq!(swept.retained, 1, "exactly one key starts with `aa`");
341        assert_eq!(swept.removed, 1);
342        assert!(cache.contains("aakeep"), "the retained entry must survive");
343        assert!(!cache.contains("bakeep"), "the rejected entry must be gone");
344        std::fs::remove_dir_all(&dir).expect("cleanup");
345    }
346
347    /// Bytes are accounted for on both sides. A sweep reporting only what it
348    /// freed cannot be told apart from one that had nothing to free.
349    #[test]
350    fn sweep_accounts_for_both_freed_and_retained_bytes() {
351        let dir = fresh("sweep-bytes");
352        let cache = ObjectCache::open(&dir).expect("open");
353        cache.put("keeper", &sample()).expect("put");
354        cache.put("goner", &sample()).expect("put");
355        let each = std::fs::metadata(dir.join("ke").join("eper.json"))
356            .expect("stat")
357            .len();
358        assert!(each > 0, "an entry with facts in it is not empty");
359
360        let swept = cache.sweep(&|key| key == "keeper").expect("sweep");
361
362        assert_eq!(swept.freed_bytes, each, "the removed entry's size, exactly");
363        assert_eq!(swept.retained_bytes, each);
364        assert_eq!((swept.raced, swept.failed, swept.skipped), (0, 0, 0));
365        std::fs::remove_dir_all(&dir).expect("cleanup");
366    }
367
368    /// A `put` in flight has written its temp file but not yet renamed it. The
369    /// sweep must not see it as an entry, and must not delete it — the rename
370    /// that follows would fail and take an unrelated sync down with it. The
371    /// predicate is `|_| false`, so *everything* it is shown is deleted: only
372    /// never being shown the temp file can save it.
373    #[test]
374    fn sweep_never_touches_a_put_in_flight() {
375        let dir = fresh("sweep-tmp");
376        let cache = ObjectCache::open(&dir).expect("open");
377        cache.put("deadbeef", &sample()).expect("put");
378        let tmp = dir.join("de").join("adbeef.json.tmp.4242-1");
379        std::fs::write(&tmp, b"half-written").expect("stage a temp file");
380
381        let swept = cache.sweep(&|_| false).expect("sweep");
382
383        assert_eq!(swept.scanned, 1, "the temp file is not a cache entry");
384        assert_eq!(swept.removed, 1);
385        assert_eq!(swept.skipped, 1, "and it is reported, not silently ignored");
386        assert!(tmp.exists(), "a `put` in flight must survive the sweep");
387        std::fs::remove_dir_all(&dir).expect("cleanup");
388    }
389
390    /// Emptying a shard must not remove the shard directory. `put` does
391    /// `create_dir_all` and *then* writes into it; a sweep that removed the
392    /// directory in between would fail that write to reclaim an empty inode.
393    #[test]
394    fn sweep_leaves_emptied_shard_directories_in_place() {
395        let dir = fresh("sweep-shard");
396        let cache = ObjectCache::open(&dir).expect("open");
397        cache.put("deadbeef", &sample()).expect("put");
398        let shard = dir.join("de");
399
400        let swept = cache.sweep(&|_| false).expect("sweep");
401
402        assert_eq!(swept.removed, 1);
403        assert!(shard.is_dir(), "the shard directory stays");
404        // And the cache is still usable through it, which is the point.
405        cache.put("deadbeef", &sample()).expect("put after sweep");
406        assert!(cache.contains("deadbeef"));
407        std::fs::remove_dir_all(&dir).expect("cleanup");
408    }
409
410    /// A second sweep of the same shared cache that gets to an entry first is not
411    /// a fault: the file is gone, which is what this pass wanted. It is counted
412    /// as `raced` rather than `removed`, because this pass freed none of those
413    /// bytes and reporting them would double-count the reclaim across the two.
414    ///
415    /// The race is made deterministic by having `retain` delete the entry itself
416    /// before rejecting it — exactly the window a concurrent sweep opens.
417    #[test]
418    fn sweep_counts_an_entry_a_concurrent_sweep_removed_first() {
419        let dir = fresh("sweep-race");
420        let cache = ObjectCache::open(&dir).expect("open");
421        cache.put("deadbeef", &sample()).expect("put");
422
423        let root = dir.clone();
424        let swept = cache
425            .sweep(&move |_| {
426                std::fs::remove_file(root.join("de").join("adbeef.json")).expect("the other sweep");
427                false
428            })
429            .expect("sweep");
430
431        assert_eq!((swept.scanned, swept.raced), (1, 1), "{swept:?}");
432        assert_eq!(
433            (swept.removed, swept.freed_bytes),
434            (0, 0),
435            "this pass freed none of those bytes: {swept:?}",
436        );
437        std::fs::remove_dir_all(&dir).expect("cleanup");
438    }
439
440    /// A *directory* named `<something>.json` is not an entry, whatever it looks
441    /// like. It is skipped rather than judged — the predicate is never shown a
442    /// key that no `put` ever wrote.
443    #[test]
444    fn sweep_skips_a_directory_wearing_an_entry_name() {
445        let dir = fresh("sweep-dir");
446        let cache = ObjectCache::open(&dir).expect("open");
447        cache.put("deadbeef", &sample()).expect("put");
448        let impostor = dir.join("de").join("cafe.json");
449        std::fs::create_dir_all(&impostor).expect("create the impostor");
450
451        let swept = cache.sweep(&|_| false).expect("sweep");
452
453        assert_eq!(swept.scanned, 1, "only the real entry: {swept:?}");
454        assert_eq!(swept.skipped, 1, "{swept:?}");
455        assert!(impostor.is_dir(), "the directory must be left alone");
456        std::fs::remove_dir_all(&dir).expect("cleanup");
457    }
458
459    /// An unreadable cache is an error, never a silent "nothing to sweep". A
460    /// sweep that swallowed the failure would report `removed: 0` — the same
461    /// output a healthy, already-clean cache produces.
462    #[test]
463    fn sweep_of_a_missing_root_is_an_error_not_an_empty_pass() {
464        let dir = fresh("sweep-missing");
465        let cache = ObjectCache::open(&dir).expect("open");
466        std::fs::remove_dir_all(&dir).expect("remove the root out from under it");
467
468        let err = cache
469            .sweep(&|_| true)
470            .expect_err("a root that cannot be listed must be reported");
471        assert!(matches!(err, super::CacheError::Io(_)), "got {err:?}");
472    }
473
474    #[test]
475    fn short_ids_do_not_panic_on_shard() {
476        let dir = std::env::temp_dir().join(format!("roteiro-cache-short-{}", std::process::id()));
477        std::fs::remove_dir_all(&dir).ok();
478        let cache = ObjectCache::open(&dir).expect("open");
479        cache.put("a", &FactSet::new()).expect("put short id");
480        assert_eq!(cache.get("a").expect("get"), Some(FactSet::new()));
481        std::fs::remove_dir_all(&dir).expect("cleanup");
482    }
483}