Skip to content

Commit d3900b8

Browse files
committed
Rework Cache to evict when untouched for 4 months
With a secondary condition of a maximum of 1000 cache entries
1 parent 176d1c8 commit d3900b8

3 files changed

Lines changed: 130 additions & 60 deletions

File tree

crates/oak_cache/src/lib.rs

Lines changed: 124 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
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
1014
mod file_lock;
1115

1216
use std::path::Path;
1317
use std::path::PathBuf;
18+
use std::time::Duration;
1419
use std::time::SystemTime;
1520

1621
use 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.
2025
const 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`].
3044
const 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
}

crates/oak_source/src/lib.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,6 @@ use oak_cache::Cache;
2222
/// Cache version
2323
const CACHE_VERSION: &str = "v1";
2424

25-
/// LRU capacity for cached CRAN package source trees
26-
const CRAN_CAPACITY: usize = 200;
27-
28-
/// LRU capacity for cached R version source trees, kept small because each is large
29-
const R_CAPACITY: usize = 5;
30-
3125
/// Downloads and caches whole package / R version source trees
3226
///
3327
/// Each cache holds its shared root lock for the life of this `SourceCache`, so any path
@@ -41,17 +35,17 @@ pub struct SourceCache {
4135
impl SourceCache {
4236
pub fn open() -> anyhow::Result<Self> {
4337
Ok(Self {
44-
cran: Cache::open(&format!("source/{CACHE_VERSION}/cran"), CRAN_CAPACITY)?,
45-
r: Cache::open(&format!("source/{CACHE_VERSION}/r"), R_CAPACITY)?,
38+
cran: Cache::open(&format!("source/{CACHE_VERSION}/cran"))?,
39+
r: Cache::open(&format!("source/{CACHE_VERSION}/r"))?,
4640
})
4741
}
4842

4943
/// Like [`SourceCache::open`], but rooted at an explicit `root` rather than the
5044
/// shared cache directory. Only useful for testing against a temp directory.
5145
pub fn open_in(root: PathBuf) -> anyhow::Result<Self> {
5246
Ok(Self {
53-
cran: Cache::open_in(root.join("cran"), CRAN_CAPACITY)?,
54-
r: Cache::open_in(root.join("r"), R_CAPACITY)?,
47+
cran: Cache::open_in(root.join("cran"))?,
48+
r: Cache::open_in(root.join("r"))?,
5549
})
5650
}
5751

crates/oak_srcref/src/lib.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ use sha2::Sha256;
1616
/// Cache version
1717
const CACHE_VERSION: &str = "v1";
1818

19-
/// LRU capacity for the cache
20-
const CACHE_CAPACITY: usize = 200;
21-
2219
/// Recovers and caches an installed package's sources from `srcref` metadata
2320
#[derive(Debug)]
2421
pub struct SrcrefCache {
@@ -31,7 +28,7 @@ impl SrcrefCache {
3128
pub fn open(r: PathBuf) -> anyhow::Result<Self> {
3229
Ok(Self {
3330
r,
34-
cache: Cache::open(&format!("srcref/{CACHE_VERSION}"), CACHE_CAPACITY)?,
31+
cache: Cache::open(&format!("srcref/{CACHE_VERSION}"))?,
3532
})
3633
}
3734

@@ -41,7 +38,7 @@ impl SrcrefCache {
4138
pub fn open_in(root: PathBuf, r: PathBuf) -> anyhow::Result<Self> {
4239
Ok(Self {
4340
r,
44-
cache: Cache::open_in(root, CACHE_CAPACITY)?,
41+
cache: Cache::open_in(root)?,
4542
})
4643
}
4744

0 commit comments

Comments
 (0)