11//! A generic, content-agnostic on disk directory cache
22//!
3- //! A [`Cache`] is rooted at a single subfolder of the shared cache directory and
4- //! holds a fixed number of entries (an LRU `capacity`). Each entry is a directory
5- //! keyed by a caller-supplied string and filled by a caller-supplied `populate`
6- //! closure. The cache knows nothing about what lives inside an entry. The lock,
7- //! completion-sentinel, last-access, and LRU-eviction machinery all live here so
3+ //! A [`Cache`] is rooted at a single subfolder of the shared cache directory. Each entry
4+ //! is a directory keyed by a caller-supplied string and filled by a caller-supplied
5+ //! `populate` closure. The cache knows nothing about what lives inside an entry. The
6+ //! lock, completion-sentinel, last-access, and eviction machinery all live here so
87//! callers don't reimplement it.
8+ //!
9+ //! There are two primary ways to evict an entry:
10+ //! - An entry untouched for [`DEFAULT_AGE`] is dropped.
11+ //! - When the total number of entries exceeds [`DEFAULT_CAPACITY`], the least recently
12+ //! touched entries are dropped.
913
1014mod file_lock;
1115
1216use std:: path:: Path ;
1317use std:: path:: PathBuf ;
18+ use std:: time:: Duration ;
1419use std:: time:: SystemTime ;
1520
1621use crate :: file_lock:: FileLock ;
@@ -19,14 +24,23 @@ use crate::file_lock::Filesystem;
1924/// Name of the root lock file and the per-key lock file.
2025const LOCK_FILENAME : & str = ".lock" ;
2126
27+ /// Default max age for a cache entry
28+ ///
29+ /// Roughly 4 months in seconds, with 30 days per month
30+ const DEFAULT_AGE : Duration = Duration :: from_secs ( 4 * 30 * 24 * 60 * 60 ) ;
31+
32+ /// Default max number of cache entries
33+ const DEFAULT_CAPACITY : usize = 1000 ;
34+
2235/// Name of the completion sentinel, written last in each cache entry.
2336///
2437/// An entry without it is a crashed partial and is removed by [`Cache::clean`].
2538///
26- /// Its mtime doubles as the entry's last-access time. [`Cache::get`] bumps it on every
27- /// retrieval (this is an atomic operation so we allow it even though we'll only hold a
28- /// shared lock on this key's folder) and [`Cache::clean`] evicts the entries with the
29- /// oldest mtime first.
39+ /// Its mtime doubles as the entry's last-access time. [`Cache::get`] bumps it
40+ /// on every retrieval (this is an atomic operation so we allow it even though
41+ /// we'll only hold a shared lock on this key's folder). [`Cache::clean`] uses
42+ /// the mtime both to evict entries older than [`DEFAULT_AGE`] and to evict the
43+ /// oldest first when the total number of entries exceeds [`DEFAULT_CAPACITY`].
3044const COMPLETE_FILENAME : & str = ".complete" ;
3145
3246/// A generic on disk directory cache
@@ -83,19 +97,25 @@ impl Cache {
8397 /// Runs a best-effort [`Cache::clean`] under the exclusive root lock (skipped if
8498 /// another session holds the shared lock), then holds the shared root lock for the
8599 /// life of the returned `Cache` so handed-out paths stay valid.
86- pub fn open ( root : & str , capacity : usize ) -> anyhow:: Result < Self > {
87- Self :: open_in ( cache_dir ( ) ?. join ( root) , capacity )
100+ pub fn open ( root : & str ) -> anyhow:: Result < Self > {
101+ Self :: open_in ( cache_dir ( ) ?. join ( root) )
88102 }
89103
90104 /// Like [`Cache::open`], but rooted at an explicit `root` rather than a subfolder of
91105 /// the shared cache directory. Only useful for testing against a temp directory.
92- pub fn open_in ( root : PathBuf , capacity : usize ) -> anyhow:: Result < Self > {
106+ pub fn open_in ( root : PathBuf ) -> anyhow:: Result < Self > {
107+ Self :: open_with_options ( root, DEFAULT_AGE , DEFAULT_CAPACITY )
108+ }
109+
110+ /// Like [`Cache::open_in`], but with explicit eviction thresholds rather than
111+ /// [`DEFAULT_AGE`] and [`DEFAULT_CAPACITY`]. Useful for tests.
112+ fn open_with_options ( root : PathBuf , age : Duration , capacity : usize ) -> anyhow:: Result < Self > {
93113 let root = Filesystem :: new ( root) ;
94114 root. create_dir ( ) ?;
95115
96116 // Try to clean. Only possible if no other session holds the shared root lock.
97117 if let Some ( root_lock) = root. try_open_rw_exclusive_create ( LOCK_FILENAME ) ? {
98- if let Err ( err) = clean ( & root_lock, capacity) {
118+ if let Err ( err) = clean ( & root_lock, age , capacity) {
99119 log:: warn!(
100120 "Failed to clean cache at {root}: {err:?}" ,
101121 root = root. display( )
@@ -171,16 +191,14 @@ impl Cache {
171191 }
172192}
173193
174- /// Removes crashed partials and trims the cache down to `capacity`
194+ /// Removes crashed partials, evicts entries older than `age`, then trims to `capacity`
175195///
176196/// The caller must hold the root exclusive lock, which no one can take while a live
177197/// session holds the shared lock, so eviction can never race a reader.
178- ///
179- /// First removes any entry missing its `.complete` sentinel. Then, if the surviving
180- /// entries exceed `capacity`, removes the least-recently-accessed ones until `capacity`
181- /// remain.
182- fn clean ( root_lock : & FileLock , capacity : usize ) -> anyhow:: Result < ( ) > {
198+ fn clean ( root_lock : & FileLock , age : Duration , capacity : usize ) -> anyhow:: Result < ( ) > {
199+ let now = SystemTime :: now ( ) ;
183200 let root = root_lock. parent ( ) ;
201+
184202 let mut entries = Vec :: new ( ) ;
185203
186204 for entry in std:: fs:: read_dir ( root) ? {
@@ -207,19 +225,34 @@ fn clean(root_lock: &FileLock, capacity: usize) -> anyhow::Result<()> {
207225 continue ;
208226 }
209227
210- entries. push ( ( path, last_access ( & complete) ) ) ;
211- }
228+ let accessed = last_access ( & complete) ;
212229
213- if entries. len ( ) <= capacity {
214- return Ok ( ( ) ) ;
230+ // Evict entries not accessed within `age`. Treat pathological future `accessed`
231+ // times as stale.
232+ let stale = now
233+ . duration_since ( accessed)
234+ . map_or ( true , |duration_since| duration_since > age) ;
235+
236+ if stale {
237+ log:: trace!( "Evicting stale cache entry {}" , path. display( ) ) ;
238+ remove_dir_all_or_warn ( & path) ;
239+ continue ;
240+ }
241+
242+ entries. push ( ( path, accessed) ) ;
215243 }
216244
217- // Oldest first, then evict down to `capacity`
218- entries. sort_by_key ( |( _, accessed) | * accessed) ;
219- let excess = entries. len ( ) - capacity;
220- for ( path, _) in entries. into_iter ( ) . take ( excess) {
221- log:: trace!( "Evicting old cache entry {}" , path. display( ) ) ;
222- remove_dir_all_or_warn ( & path) ;
245+ // Evict the oldest until `capacity` remain
246+ if entries. len ( ) > capacity {
247+ entries. sort_by_key ( |( _, accessed) | * accessed) ;
248+ let excess = entries. len ( ) - capacity;
249+ for ( path, _) in entries. into_iter ( ) . take ( excess) {
250+ log:: trace!(
251+ "Evicting cache entry exceeding cache capacity {}" ,
252+ path. display( )
253+ ) ;
254+ remove_dir_all_or_warn ( & path) ;
255+ }
223256 }
224257
225258 Ok ( ( ) )
@@ -290,12 +323,14 @@ mod tests {
290323
291324 use crate :: Cache ;
292325 use crate :: COMPLETE_FILENAME ;
326+ use crate :: DEFAULT_AGE ;
327+ use crate :: DEFAULT_CAPACITY ;
293328
294329 /// Creates a cache rooted in a fresh temp dir, returning both so the temp dir stays
295330 /// alive for the test.
296- fn new ( capacity : usize ) -> ( TempDir , Cache ) {
331+ fn new ( ) -> ( TempDir , Cache ) {
297332 let dir = TempDir :: new ( ) . unwrap ( ) ;
298- let cache = Cache :: open_in ( dir. path ( ) . join ( "subfolder" ) , capacity ) . unwrap ( ) ;
333+ let cache = Cache :: open_in ( dir. path ( ) . join ( "subfolder" ) ) . unwrap ( ) ;
299334 ( dir, cache)
300335 }
301336
@@ -326,13 +361,13 @@ mod tests {
326361
327362 #[ test]
328363 fn test_get_miss ( ) {
329- let ( _dir, cache) = new ( 10 ) ;
364+ let ( _dir, cache) = new ( ) ;
330365 assert_eq ! ( cache. get( "absent" ) , None ) ;
331366 }
332367
333368 #[ test]
334369 fn test_insert_then_get_round_trip ( ) {
335- let ( _dir, cache) = new ( 10 ) ;
370+ let ( _dir, cache) = new ( ) ;
336371
337372 let inserted = cache. insert ( "key" , write_file ( "hello" ) ) . unwrap ( ) . unwrap ( ) ;
338373 let got = cache. get ( "key" ) . unwrap ( ) ;
@@ -345,7 +380,7 @@ mod tests {
345380
346381 #[ test]
347382 fn test_insert_unavailable_yields_no_entry ( ) {
348- let ( _dir, cache) = new ( 10 ) ;
383+ let ( _dir, cache) = new ( ) ;
349384
350385 let result = cache. insert ( "key" , |_dir| Ok ( false ) ) . unwrap ( ) ;
351386 assert_eq ! ( result, None ) ;
@@ -359,15 +394,15 @@ mod tests {
359394
360395 // Populate one good entry, then forge a crashed partial (no `.complete`).
361396 {
362- let cache = Cache :: open_in ( root. clone ( ) , 10 ) . unwrap ( ) ;
397+ let cache = Cache :: open_in ( root. clone ( ) ) . unwrap ( ) ;
363398 cache. insert ( "good" , write_file ( "ok" ) ) . unwrap ( ) ;
364399 }
365400 let partial = root. join ( "partial" ) ;
366401 std:: fs:: create_dir ( & partial) . unwrap ( ) ;
367402 std:: fs:: write ( partial. join ( "content.txt" ) , "junk" ) . unwrap ( ) ;
368403
369404 // Reopening runs `clean`, which removes the partial but keeps the good entry.
370- let cache = Cache :: open_in ( root, 10 ) . unwrap ( ) ;
405+ let cache = Cache :: open_in ( root) . unwrap ( ) ;
371406 assert ! ( !partial. exists( ) ) ;
372407 assert ! ( cache. get( "good" ) . is_some( ) ) ;
373408 }
@@ -378,7 +413,7 @@ mod tests {
378413 let root = dir. path ( ) . join ( "subfolder" ) ;
379414
380415 {
381- let cache = Cache :: open_in ( root. clone ( ) , 10 ) . unwrap ( ) ;
416+ let cache = Cache :: open_in ( root. clone ( ) ) . unwrap ( ) ;
382417 cache. insert ( "good" , write_file ( "ok" ) ) . unwrap ( ) ;
383418 }
384419 // A stray file in the cache root that doesn't belong to any entry.
@@ -387,7 +422,7 @@ mod tests {
387422
388423 // Reopening runs `clean`, which removes the stray file but keeps our root
389424 // `.lock` and the good entry.
390- let cache = Cache :: open_in ( root. clone ( ) , 10 ) . unwrap ( ) ;
425+ let cache = Cache :: open_in ( root. clone ( ) ) . unwrap ( ) ;
391426 assert ! ( !stray. exists( ) ) ;
392427 assert ! ( root. join( ".lock" ) . exists( ) ) ;
393428 assert ! ( cache. get( "good" ) . is_some( ) ) ;
@@ -399,27 +434,71 @@ mod tests {
399434 let root = dir. path ( ) . join ( "subfolder" ) ;
400435 let now = SystemTime :: now ( ) ;
401436
402- // Insert four entries under a capacity that won't evict, then stamp their
403- // access times so the ordering is deterministic.
437+ // Insert four entries, then stamp their access times so the ordering is
438+ // deterministic
404439 {
405- let cache = Cache :: open_in ( root. clone ( ) , 10 ) . unwrap ( ) ;
440+ let cache = Cache :: open_in ( root. clone ( ) ) . unwrap ( ) ;
406441 for ( index, key) in [ "oldest" , "older" , "newer" , "newest" ] . iter ( ) . enumerate ( ) {
407442 let entry = cache. insert ( key, write_file ( key) ) . unwrap ( ) . unwrap ( ) ;
408- set_accessed ( & entry, now + Duration :: from_secs ( index as u64 ) ) ;
443+ set_accessed ( & entry, now - Duration :: from_secs ( 4 - index as u64 ) ) ;
409444 }
410445 }
411446
412447 // Reopening with capacity 2 evicts the two least-recently-accessed.
413- let cache = Cache :: open_in ( root, 2 ) . unwrap ( ) ;
448+ let cache = Cache :: open_with_options ( root, DEFAULT_AGE , 2 ) . unwrap ( ) ;
414449 assert_eq ! ( cache. get( "oldest" ) , None ) ;
415450 assert_eq ! ( cache. get( "older" ) , None ) ;
416451 assert ! ( cache. get( "newer" ) . is_some( ) ) ;
417452 assert ! ( cache. get( "newest" ) . is_some( ) ) ;
418453 }
419454
455+ #[ test]
456+ fn test_clean_evicts_stale_entries ( ) {
457+ let dir = TempDir :: new ( ) . unwrap ( ) ;
458+ let root = dir. path ( ) . join ( "subfolder" ) ;
459+ let now = SystemTime :: now ( ) ;
460+
461+ // A fresh entry and an entry last accessed an hour ago
462+ {
463+ let cache = Cache :: open_in ( root. clone ( ) ) . unwrap ( ) ;
464+ let fresh = cache. insert ( "fresh" , write_file ( "fresh" ) ) . unwrap ( ) . unwrap ( ) ;
465+ set_accessed ( & fresh, now) ;
466+ let stale = cache. insert ( "stale" , write_file ( "stale" ) ) . unwrap ( ) . unwrap ( ) ;
467+ set_accessed ( & stale, now - Duration :: from_secs ( 3600 ) ) ;
468+ }
469+
470+ // Reopening with a half-hour max age evicts the stale entry but keeps the fresh
471+ // one
472+ let cache =
473+ Cache :: open_with_options ( root, Duration :: from_secs ( 1800 ) , DEFAULT_CAPACITY ) . unwrap ( ) ;
474+ assert ! ( cache. get( "fresh" ) . is_some( ) ) ;
475+ assert_eq ! ( cache. get( "stale" ) , None ) ;
476+ }
477+
478+ #[ test]
479+ fn test_clean_evicts_future_mtime ( ) {
480+ let dir = TempDir :: new ( ) . unwrap ( ) ;
481+ let root = dir. path ( ) . join ( "subfolder" ) ;
482+
483+ // Forge an entry whose access time is in the future, as if the wall clock was
484+ // reset. It has no meaningful age, so we treat it as broken.
485+ {
486+ let cache = Cache :: open_in ( root. clone ( ) ) . unwrap ( ) ;
487+ let entry = cache
488+ . insert ( "future" , write_file ( "future" ) )
489+ . unwrap ( )
490+ . unwrap ( ) ;
491+ set_accessed ( & entry, SystemTime :: now ( ) + Duration :: from_secs ( 3600 ) ) ;
492+ }
493+
494+ // Reopening evicts it
495+ let cache = Cache :: open_with_options ( root, DEFAULT_AGE , DEFAULT_CAPACITY ) . unwrap ( ) ;
496+ assert_eq ! ( cache. get( "future" ) , None ) ;
497+ }
498+
420499 #[ test]
421500 fn test_get_refreshes_accessed ( ) {
422- let ( _dir, cache) = new ( 10 ) ;
501+ let ( _dir, cache) = new ( ) ;
423502
424503 let entry = cache. insert ( "key" , write_file ( "hello" ) ) . unwrap ( ) . unwrap ( ) ;
425504
@@ -434,7 +513,7 @@ mod tests {
434513
435514 #[ test]
436515 fn test_insert_is_complete_after_populate ( ) {
437- let ( _dir, cache) = new ( 10 ) ;
516+ let ( _dir, cache) = new ( ) ;
438517 let entry = cache. insert ( "key" , write_file ( "hello" ) ) . unwrap ( ) . unwrap ( ) ;
439518 assert ! ( entry. join( COMPLETE_FILENAME ) . exists( ) ) ;
440519 }
0 commit comments