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
55 /// `.json.tmp.<pid>-<nanos>-<seq>` from a [`ObjectCache::put`] still in flight
56 /// (the third field is a process-wide counter, without which two threads
57 /// choose one temp name), or anything a later format
58 /// puts here. Never shown to `retain` and never deleted — a sweep that
59 /// guesses at a name it does not recognise is a sweep that deletes another
60 /// process's half-written work.
61 pub skipped: usize,
62}
63
64/// A content-addressed store of fact sets on disk.
65pub struct ObjectCache {
66 root: PathBuf,
67}
68
69impl ObjectCache {
70 /// Open (creating if absent) a cache rooted at `root`.
71 ///
72 /// # Errors
73 /// Returns [`CacheError::Io`] if the root directory cannot be created.
74 pub fn open(root: impl Into<PathBuf>) -> Result<Self, CacheError> {
75 let root = root.into();
76 fs::create_dir_all(&root)?;
77 Ok(Self { root })
78 }
79
80 /// The directory this cache stores objects under.
81 #[must_use]
82 pub fn root(&self) -> &Path {
83 &self.root
84 }
85
86 fn path_for(&self, blob_id: &str) -> PathBuf {
87 // Shard by the first two characters, like git's `objects/ab/cdef…`.
88 let (shard, rest) = blob_id.split_at(blob_id.len().min(2));
89 self.root.join(shard).join(format!("{rest}.json"))
90 }
91
92 /// Whether a fact set is cached for `blob_id`.
93 #[must_use]
94 pub fn contains(&self, blob_id: &str) -> bool {
95 self.path_for(blob_id).exists()
96 }
97
98 /// Load the cached fact set for `blob_id`, if present.
99 ///
100 /// # Errors
101 /// Returns [`CacheError::Io`] on read failure or [`CacheError::Json`] if the
102 /// entry cannot be decoded.
103 pub fn get(&self, blob_id: &str) -> Result<Option<FactSet>, CacheError> {
104 let path = self.path_for(blob_id);
105 match fs::read(&path) {
106 Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
107 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
108 Err(e) => Err(e.into()),
109 }
110 }
111
112 /// Store `facts` under `blob_id`, replacing any existing entry. The write is
113 /// atomic (write-to-temp then rename) so a crash never leaves a torn entry.
114 ///
115 /// # Errors
116 /// Returns [`CacheError::Io`] on write failure or [`CacheError::Json`] if
117 /// `facts` cannot be encoded.
118 pub fn put(&self, blob_id: &str, facts: &FactSet) -> Result<(), CacheError> {
119 let path = self.path_for(blob_id);
120 if let Some(parent) = path.parent() {
121 fs::create_dir_all(parent)?;
122 }
123
124 // A temp name no concurrent writer can choose — see [`temp_path`] for why
125 // the counter is part of it and what happened before it was.
126 let tmp = temp_path(
127 &path,
128 std::process::id(),
129 std::time::SystemTime::now()
130 .duration_since(std::time::UNIX_EPOCH)
131 .unwrap_or_default()
132 .as_nanos(),
133 TEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
134 );
135
136 let bytes = serde_json::to_vec(facts)?;
137 fs::write(&tmp, &bytes)?;
138
139 match fs::rename(&tmp, &path) {
140 Ok(()) => Ok(()),
141 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
142 match fs::remove_file(&path) {
143 Ok(()) => {}
144 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
145 Err(e) => return Err(e.into()),
146 }
147 fs::rename(&tmp, &path)?;
148 Ok(())
149 }
150 Err(e) => Err(e.into()),
151 }
152 }
153
154 /// Delete every entry whose key `retain` rejects, returning what the pass did.
155 ///
156 /// **This module deliberately does not know what a key means.** It derives
157 /// none and interprets none — the caller derives the key (see the module
158 /// doc), so the caller is the only thing entitled to say which keys are still
159 /// reachable. `retain` receives the *whole* key, reassembled from the shard
160 /// directory and the file stem, so a policy that reads any part of it reads
161 /// the same string [`Self::put`] was given.
162 ///
163 /// The pass is safe to run while other processes are using the same cache —
164 /// which is not optional, because the root lives under the **common** git dir
165 /// and every worktree shares it:
166 ///
167 /// - Entries are whole files written by atomic rename, and this deletes whole
168 /// files, so no reader can observe a torn one. A reader that had already
169 /// opened a deleted entry keeps reading it (POSIX); a reader that had not
170 /// gets [`Self::get`]'s ordinary `None`, which is a cache miss — and a miss
171 /// costs a re-extraction, never a wrong answer, because the cache is
172 /// derived. That is the whole reason a mistaken `retain` is survivable.
173 /// - Nothing that is not an entry is touched, so a concurrent `put`'s temp
174 /// file survives to be renamed.
175 /// - Shard directories are **not** removed, even when emptied. `put` does
176 /// `create_dir_all` and *then* writes; removing the directory in between
177 /// would fail an unrelated process's write to reclaim four kilobytes.
178 ///
179 /// # Errors
180 /// Returns [`CacheError::Io`] if the root or a shard cannot be listed — an
181 /// unreadable cache is reported, never silently swept as empty. Per-entry
182 /// delete failures are counted in [`ObjectSweep::failed`] instead, so one
183 /// stuck file does not abandon the rest.
184 pub fn sweep(&self, retain: &dyn Fn(&str) -> bool) -> Result<ObjectSweep, CacheError> {
185 let mut report = ObjectSweep::default();
186 for shard in fs::read_dir(&self.root)? {
187 let shard = shard?;
188 // `file_type` on a `DirEntry` does not follow links, so a symlinked
189 // directory is skipped rather than walked out of the cache.
190 if !shard.file_type()?.is_dir() {
191 report.skipped += 1;
192 continue;
193 }
194 let Some(prefix) = shard.file_name().to_str().map(str::to_owned) else {
195 // A shard name that is not UTF-8 cannot be half of a key this
196 // cache wrote, so its contents are not ours to judge.
197 report.skipped += 1;
198 continue;
199 };
200 Self::sweep_shard(&shard.path(), &prefix, retain, &mut report)?;
201 }
202 Ok(report)
203 }
204
205 /// One shard directory of [`Self::sweep`].
206 fn sweep_shard(
207 dir: &Path,
208 prefix: &str,
209 retain: &dyn Fn(&str) -> bool,
210 report: &mut ObjectSweep,
211 ) -> Result<(), CacheError> {
212 for entry in fs::read_dir(dir)? {
213 let entry = entry?;
214 let name = entry.file_name();
215 // An entry is named exactly `<rest>.json`. A temp file is
216 // `<rest>.json.tmp.<unique>` and so fails this test, which is the
217 // point: it belongs to a `put` that has not finished.
218 let Some(rest) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
219 report.skipped += 1;
220 continue;
221 };
222 // `symlink_metadata` does not follow links, so a symlink is never
223 // mistaken for an entry nor followed out of the cache — and it gives
224 // the size in the same call, with one race to handle instead of two.
225 let bytes = match fs::symlink_metadata(entry.path()) {
226 Ok(meta) if meta.is_file() => meta.len(),
227 Ok(_) => {
228 report.skipped += 1;
229 continue;
230 }
231 // Gone between listing and stat: another sweep of this shared
232 // cache got there first. Nothing left to reclaim, nothing wrong.
233 //
234 // **Defensive, and not covered by a test.** Hitting it needs a
235 // delete inside the window between `read_dir` yielding a name and
236 // this stat, which nothing here can open deterministically —
237 // whether a deleted name is still yielded depends on the
238 // platform's directory buffering, so a test for it would be
239 // flaky rather than a test. It is counted exactly as the same
240 // race on `remove_file` below is (`sweep_counts_an_entry_a_
241 // concurrent_sweep_removed_first`), which *is* covered; the
242 // alternative — letting it propagate — would make one sweep of a
243 // shared cache fail because another was doing its job.
244 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
245 report.scanned += 1;
246 report.raced += 1;
247 continue;
248 }
249 Err(e) => return Err(e.into()),
250 };
251
252 report.scanned += 1;
253 // The key as `put` received it: the shard is its first two characters,
254 // not a hash of it, so concatenating recovers the original exactly.
255 let key = format!("{prefix}{rest}");
256 if retain(&key) {
257 report.retained += 1;
258 report.retained_bytes += bytes;
259 continue;
260 }
261 match fs::remove_file(entry.path()) {
262 Ok(()) => {
263 report.removed += 1;
264 report.freed_bytes += bytes;
265 }
266 Err(e) if e.kind() == std::io::ErrorKind::NotFound => report.raced += 1,
267 Err(_) => report.failed += 1,
268 }
269 }
270 Ok(())
271 }
272}
273
274/// The temp file [`ObjectCache::put`] writes before renaming it into place.
275///
276/// **Three components, and the third is not decoration.** `pid` separates
277/// processes; `now_nanos` separates calls within one — but only as finely as the
278/// platform clock, and `SystemTime::now()` is microsecond-granular on macOS and no
279/// better on some virtualised runners. Two threads of one process putting the
280/// *same* key inside one tick then chose the *same* temp path: the first rename
281/// consumed the file, and the second got `ENOENT` — a failed cache write surfacing
282/// as `CacheError::Io`, not as the miss a derived cache is allowed to have. `seq`
283/// is a process-wide counter, so that collision cannot occur at any clock
284/// resolution.
285///
286/// Found by #822, which made the corpus graph tests actually run on CI: three of
287/// them build graphs concurrently over a cold cache, and that is the first thing
288/// here to put one key from two threads at once. Measured before the counter:
289/// 6 failures in 160,000 concurrent same-key puts locally (~1 in 26,000), and one
290/// `no-default-features` job failure on Linux. After it: none in 480,000.
291///
292/// The name stays a sibling of the entry, so the rename is within one directory
293/// and therefore within one filesystem — which is what makes it atomic.
294fn temp_path(path: &Path, pid: u32, now_nanos: u128, seq: u64) -> PathBuf {
295 path.with_extension(format!("json.tmp.{pid}-{now_nanos}-{seq}"))
296}
297
298/// The `seq` above, one per process. `Relaxed` is enough: nothing is ordered
299/// against it — the only property required is that no two reads return the same
300/// value, which `fetch_add` gives at any ordering.
301static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
302
303#[cfg(test)]
304mod tests {
305 use super::ObjectCache;
306 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind};
307
308 /// A cache root unique to `name` and this process, with any stale copy gone.
309 fn fresh(name: &str) -> std::path::PathBuf {
310 let dir = std::env::temp_dir().join(format!("roteiro-cache-{name}-{}", std::process::id()));
311 std::fs::remove_dir_all(&dir).ok();
312 dir
313 }
314
315 fn sample() -> FactSet {
316 FactSet::new()
317 .with_node(Node::new("a", NodeKind::Fn, "a"))
318 .with_node(Node::new("b", NodeKind::Fn, "b"))
319 .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
320 }
321
322 /// **Two puts of one key inside a single clock tick must not name one file.**
323 ///
324 /// The clock is pinned here rather than raced, because the defect this guards
325 /// is *rare* when raced — 6 failures in 160,000 concurrent puts is a test that
326 /// passes 26,000 times out of 26,001 and proves nothing on the run that
327 /// matters. Equal `now_nanos` is the condition the race produces, so asserting
328 /// on it directly is the same claim without the coin toss. Delete `seq` from
329 /// [`super::temp_path`] and this fails; nothing else in the suite does.
330 #[test]
331 fn two_puts_in_one_clock_tick_cannot_choose_one_temp_file() {
332 let entry = std::path::Path::new("/cache/de/adbeef.json");
333 let first = super::temp_path(entry, 7, 42, 0);
334 let second = super::temp_path(entry, 7, 42, 1);
335 assert_ne!(
336 first, second,
337 "same pid and same clock reading must still give different temp files, \
338 or one thread's rename consumes the other's and the loser gets ENOENT"
339 );
340 // A sibling of the entry, so the rename stays inside one directory and is
341 // therefore atomic. A temp file elsewhere would cross a filesystem and
342 // stop being a rename at all.
343 assert_eq!(first.parent(), entry.parent(), "{first:?}");
344 }
345
346 /// **And the counter must be wired into `put`, not merely available to it.**
347 ///
348 /// The test above holds [`super::temp_path`]'s own property, which a future
349 /// edit could satisfy while passing a constant at the call site — or while
350 /// restoring `{pid}-{nanos}` naming there — and the race would be back with
351 /// both tests green. This one reads the names `put` actually writes.
352 ///
353 /// Deterministic, and deliberately not a concurrency test: the entry path is
354 /// pre-occupied by a **directory**, so each `put`'s rename fails and leaves its
355 /// temp file behind to be inspected. Two puts of one key must leave two files
356 /// whose suffix has three fields and whose *third* field differs — the shape
357 /// catches a reverted name, and the difference catches a constant. Racing two
358 /// threads instead would catch both only about one run in 26,000.
359 #[test]
360 fn put_draws_a_fresh_sequence_value_for_each_temp_file() {
361 let dir = fresh("seq-wiring");
362 let cache = ObjectCache::open(&dir).expect("open");
363 let shard = dir.join("de");
364 std::fs::create_dir_all(shard.join("adbeef.json")).expect("occupy the entry path");
365
366 // The rename cannot replace a directory, so each put fails *after* writing
367 // its temp file. That failure is the instrument, not the subject.
368 assert!(
369 cache.put("deadbeef", &sample()).is_err(),
370 "a put whose destination is a directory must fail, or there is no temp \
371 file left to read"
372 );
373 assert!(cache.put("deadbeef", &sample()).is_err());
374
375 let suffixes: Vec<String> = std::fs::read_dir(&shard)
376 .expect("read the shard")
377 .filter_map(|entry| {
378 let name = entry.ok()?.file_name().to_string_lossy().into_owned();
379 let (_, suffix) = name.split_once(".json.tmp.")?;
380 Some(suffix.to_owned())
381 })
382 .collect();
383 assert_eq!(suffixes.len(), 2, "two puts, two temp files: {suffixes:?}");
384
385 let mut seqs = Vec::new();
386 for suffix in &suffixes {
387 let fields: Vec<&str> = suffix.split('-').collect();
388 assert_eq!(
389 fields.len(),
390 3,
391 "a temp name is `<pid>-<nanos>-<seq>`; {suffix:?} has no sequence \
392 field, so two threads can choose one name again"
393 );
394 seqs.push(fields[2].to_owned());
395 }
396 assert_ne!(
397 seqs[0], seqs[1],
398 "both puts used sequence {:?}, so the call site is passing a constant \
399 rather than drawing from the counter",
400 seqs[0]
401 );
402
403 std::fs::remove_dir_all(&dir).ok();
404 }
405
406 #[test]
407 fn put_get_round_trip_and_miss() {
408 let dir = std::env::temp_dir().join(format!("roteiro-cache-{}", std::process::id()));
409 std::fs::remove_dir_all(&dir).ok();
410 let cache = ObjectCache::open(&dir).expect("open");
411
412 assert!(!cache.contains("deadbeef"));
413 assert!(cache.get("deadbeef").expect("get").is_none());
414
415 let facts = sample();
416 cache.put("deadbeef", &facts).expect("put");
417 assert!(cache.contains("deadbeef"));
418 assert_eq!(cache.get("deadbeef").expect("get"), Some(facts));
419
420 std::fs::remove_dir_all(&dir).expect("cleanup");
421 }
422
423 #[test]
424 fn put_overwrites_existing_entry() {
425 let dir =
426 std::env::temp_dir().join(format!("roteiro-cache-overwrite-{}", std::process::id()));
427 std::fs::remove_dir_all(&dir).ok();
428 let cache = ObjectCache::open(&dir).expect("open");
429
430 cache.put("beef", &sample()).expect("first put");
431 // A second put for the same key must atomically replace the entry.
432 let replacement = FactSet::new().with_node(Node::new("only", NodeKind::File, "only"));
433 cache.put("beef", &replacement).expect("overwrite");
434 assert_eq!(cache.get("beef").expect("get"), Some(replacement));
435
436 std::fs::remove_dir_all(&dir).expect("cleanup");
437 }
438
439 /// The predicate decides, and it decides on the **whole** key — not the file
440 /// stem, which is the key minus its shard. Keys differing only in their first
441 /// character land in different shard directories with *identical* stems, so a
442 /// sweep that judged the stem would give both the same verdict. Here one is
443 /// kept and one removed, which only the reassembled key can distinguish.
444 #[test]
445 fn sweep_judges_the_whole_key_not_the_file_stem() {
446 let dir = fresh("sweep-key");
447 let cache = ObjectCache::open(&dir).expect("open");
448 cache.put("aakeep", &sample()).expect("put keep");
449 cache.put("bakeep", &sample()).expect("put drop");
450
451 let swept = cache
452 .sweep(&|key| key.starts_with("aa"))
453 .expect("sweep should read the cache");
454
455 assert_eq!(swept.scanned, 2);
456 assert_eq!(swept.retained, 1, "exactly one key starts with `aa`");
457 assert_eq!(swept.removed, 1);
458 assert!(cache.contains("aakeep"), "the retained entry must survive");
459 assert!(!cache.contains("bakeep"), "the rejected entry must be gone");
460 std::fs::remove_dir_all(&dir).expect("cleanup");
461 }
462
463 /// Bytes are accounted for on both sides. A sweep reporting only what it
464 /// freed cannot be told apart from one that had nothing to free.
465 #[test]
466 fn sweep_accounts_for_both_freed_and_retained_bytes() {
467 let dir = fresh("sweep-bytes");
468 let cache = ObjectCache::open(&dir).expect("open");
469 cache.put("keeper", &sample()).expect("put");
470 cache.put("goner", &sample()).expect("put");
471 let each = std::fs::metadata(dir.join("ke").join("eper.json"))
472 .expect("stat")
473 .len();
474 assert!(each > 0, "an entry with facts in it is not empty");
475
476 let swept = cache.sweep(&|key| key == "keeper").expect("sweep");
477
478 assert_eq!(swept.freed_bytes, each, "the removed entry's size, exactly");
479 assert_eq!(swept.retained_bytes, each);
480 assert_eq!((swept.raced, swept.failed, swept.skipped), (0, 0, 0));
481 std::fs::remove_dir_all(&dir).expect("cleanup");
482 }
483
484 /// A `put` in flight has written its temp file but not yet renamed it. The
485 /// sweep must not see it as an entry, and must not delete it — the rename
486 /// that follows would fail and take an unrelated sync down with it. The
487 /// predicate is `|_| false`, so *everything* it is shown is deleted: only
488 /// never being shown the temp file can save it.
489 #[test]
490 fn sweep_never_touches_a_put_in_flight() {
491 let dir = fresh("sweep-tmp");
492 let cache = ObjectCache::open(&dir).expect("open");
493 cache.put("deadbeef", &sample()).expect("put");
494 let tmp = dir.join("de").join("adbeef.json.tmp.4242-1");
495 std::fs::write(&tmp, b"half-written").expect("stage a temp file");
496
497 let swept = cache.sweep(&|_| false).expect("sweep");
498
499 assert_eq!(swept.scanned, 1, "the temp file is not a cache entry");
500 assert_eq!(swept.removed, 1);
501 assert_eq!(swept.skipped, 1, "and it is reported, not silently ignored");
502 assert!(tmp.exists(), "a `put` in flight must survive the sweep");
503 std::fs::remove_dir_all(&dir).expect("cleanup");
504 }
505
506 /// Emptying a shard must not remove the shard directory. `put` does
507 /// `create_dir_all` and *then* writes into it; a sweep that removed the
508 /// directory in between would fail that write to reclaim an empty inode.
509 #[test]
510 fn sweep_leaves_emptied_shard_directories_in_place() {
511 let dir = fresh("sweep-shard");
512 let cache = ObjectCache::open(&dir).expect("open");
513 cache.put("deadbeef", &sample()).expect("put");
514 let shard = dir.join("de");
515
516 let swept = cache.sweep(&|_| false).expect("sweep");
517
518 assert_eq!(swept.removed, 1);
519 assert!(shard.is_dir(), "the shard directory stays");
520 // And the cache is still usable through it, which is the point.
521 cache.put("deadbeef", &sample()).expect("put after sweep");
522 assert!(cache.contains("deadbeef"));
523 std::fs::remove_dir_all(&dir).expect("cleanup");
524 }
525
526 /// A second sweep of the same shared cache that gets to an entry first is not
527 /// a fault: the file is gone, which is what this pass wanted. It is counted
528 /// as `raced` rather than `removed`, because this pass freed none of those
529 /// bytes and reporting them would double-count the reclaim across the two.
530 ///
531 /// The race is made deterministic by having `retain` delete the entry itself
532 /// before rejecting it — exactly the window a concurrent sweep opens.
533 #[test]
534 fn sweep_counts_an_entry_a_concurrent_sweep_removed_first() {
535 let dir = fresh("sweep-race");
536 let cache = ObjectCache::open(&dir).expect("open");
537 cache.put("deadbeef", &sample()).expect("put");
538
539 let root = dir.clone();
540 let swept = cache
541 .sweep(&move |_| {
542 std::fs::remove_file(root.join("de").join("adbeef.json")).expect("the other sweep");
543 false
544 })
545 .expect("sweep");
546
547 assert_eq!((swept.scanned, swept.raced), (1, 1), "{swept:?}");
548 assert_eq!(
549 (swept.removed, swept.freed_bytes),
550 (0, 0),
551 "this pass freed none of those bytes: {swept:?}",
552 );
553 std::fs::remove_dir_all(&dir).expect("cleanup");
554 }
555
556 /// A *directory* named `<something>.json` is not an entry, whatever it looks
557 /// like. It is skipped rather than judged — the predicate is never shown a
558 /// key that no `put` ever wrote.
559 #[test]
560 fn sweep_skips_a_directory_wearing_an_entry_name() {
561 let dir = fresh("sweep-dir");
562 let cache = ObjectCache::open(&dir).expect("open");
563 cache.put("deadbeef", &sample()).expect("put");
564 let impostor = dir.join("de").join("cafe.json");
565 std::fs::create_dir_all(&impostor).expect("create the impostor");
566
567 let swept = cache.sweep(&|_| false).expect("sweep");
568
569 assert_eq!(swept.scanned, 1, "only the real entry: {swept:?}");
570 assert_eq!(swept.skipped, 1, "{swept:?}");
571 assert!(impostor.is_dir(), "the directory must be left alone");
572 std::fs::remove_dir_all(&dir).expect("cleanup");
573 }
574
575 /// An unreadable cache is an error, never a silent "nothing to sweep". A
576 /// sweep that swallowed the failure would report `removed: 0` — the same
577 /// output a healthy, already-clean cache produces.
578 #[test]
579 fn sweep_of_a_missing_root_is_an_error_not_an_empty_pass() {
580 let dir = fresh("sweep-missing");
581 let cache = ObjectCache::open(&dir).expect("open");
582 std::fs::remove_dir_all(&dir).expect("remove the root out from under it");
583
584 let err = cache
585 .sweep(&|_| true)
586 .expect_err("a root that cannot be listed must be reported");
587 assert!(matches!(err, super::CacheError::Io(_)), "got {err:?}");
588 }
589
590 #[test]
591 fn short_ids_do_not_panic_on_shard() {
592 let dir = std::env::temp_dir().join(format!("roteiro-cache-short-{}", std::process::id()));
593 std::fs::remove_dir_all(&dir).ok();
594 let cache = ObjectCache::open(&dir).expect("open");
595 cache.put("a", &FactSet::new()).expect("put short id");
596 assert_eq!(cache.get("a").expect("get"), Some(FactSet::new()));
597 std::fs::remove_dir_all(&dir).expect("cleanup");
598 }
599}