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}