Skip to main content

git_cache_proxy/
evict.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Bounding the on-disk cache with LRU eviction of idle mirrors.
3//!
4//! When a byte cap is configured (`--cache-max-mb`), a [`CacheIndex`] tracks every
5//! mirror's size and access order in memory. The request path only ever does O(1)
6//! bookkeeping against it - never disk IO:
7//!   - `touch` on every request (a served cache hit counts as use),
8//!   - `mark_changed` after a clone/fetch, flagging the mirror for (re)measurement.
9//!
10//! All disk work - measuring a changed mirror's size, and evicting mirrors - runs
11//! in the background [`run`] task, off the critical path, so a client's clone/fetch
12//! is never blocked by cache maintenance. Access order lives in an `lru::LruCache`
13//! (a hashmap plus an intrusive list), so `touch` promotes in O(1) and eviction
14//! pops the least-recently-used tail until back under the cap - no re-sorting. An
15//! evicted mirror is transparently re-cloned on its next request, so eviction is a
16//! cache-management concern only, never a correctness one. With no cap set, no index
17//! is built and the default path keeps its current zero-overhead unbounded-growth
18//! behaviour.
19
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex, MutexGuard};
23use std::time::SystemTime;
24
25use lru::LruCache;
26use tokio::sync::{Notify, watch};
27
28use crate::git::GitCache;
29use crate::metrics::Metrics;
30
31struct Inner {
32    /// Mirror name -> size, kept in access order (front = most-recently-used, back =
33    /// least). The `lru` crate does the O(1) promote-on-use and tail eviction.
34    cache: LruCache<String, u64>,
35    /// Mirrors whose size changed and needs (re)measuring by the background task.
36    dirty: HashSet<String>,
37    /// Sum of the sizes in `cache`, maintained incrementally so the cap check is
38    /// O(1). The byte total, not `LruCache`'s item count, is what bounds the cache.
39    total: u64,
40}
41
42/// In-memory record of the on-disk mirror cache, plus the eviction trigger. Shared
43/// (via `Arc`) between the `GitCache` that mutates it on clone/fetch/serve and the
44/// background task in [`run`] that measures and evicts.
45pub struct CacheIndex {
46    cache_root: PathBuf,
47    max_bytes: u64,
48    metrics: Arc<Metrics>,
49    /// Woken when a mirror changes or the cache may be over cap. `notify_one`
50    /// coalesces a burst into a single maintenance pass and stores a permit if the
51    /// task is mid-pass, so no wakeup is lost.
52    work: Notify,
53    state: Mutex<Inner>,
54}
55
56impl CacheIndex {
57    /// Build the index by scanning `cache_root` once. Seeds access order from each
58    /// mirror's newest file mtime (oldest first, so the oldest lands at the LRU
59    /// tail), so recency roughly survives a restart. Blocking, but runs at startup
60    /// before the server binds.
61    pub fn new(cache_root: PathBuf, max_bytes: u64, metrics: Arc<Metrics>) -> Arc<Self> {
62        let mut mirrors = find_mirrors(&cache_root);
63        mirrors.sort_by_key(|m| m.mtime); // oldest first -> pushed to the LRU tail first
64        // Unbounded: the cap is enforced by byte total, not `LruCache`'s item count.
65        let mut cache: LruCache<String, u64> = LruCache::unbounded();
66        let mut total = 0u64;
67        for m in mirrors {
68            total += m.size;
69            cache.put(m.name, m.size);
70        }
71        let inner = Inner {
72            cache,
73            dirty: HashSet::new(),
74            total,
75        };
76        metrics.set_cache_size(total, inner.cache.len());
77        let over = total > max_bytes;
78        let idx = Arc::new(Self {
79            cache_root,
80            max_bytes,
81            metrics,
82            work: Notify::new(),
83            state: Mutex::new(inner),
84        });
85        if over {
86            idx.work.notify_one(); // a previous run may have left the cache over-cap
87        }
88        idx
89    }
90
91    /// Mark a repo used. Serving does not grow the cache, so this never wakes the
92    /// evictor; it only keeps the access order honest. No-op for a repo not yet
93    /// tracked (its first clone tracks it via `mark_changed`).
94    pub fn touch(&self, name: &str) {
95        // `get` promotes to most-recently-used; the value itself is unused.
96        let _ = self.lock().cache.get(name);
97    }
98
99    /// Flag a mirror as changed after a clone/fetch: promote it (it was just used),
100    /// schedule it for measurement, and wake the background task. O(1) bookkeeping
101    /// only - the size walk happens off the request path.
102    pub fn mark_changed(&self, name: &str) {
103        {
104            let mut inner = self.lock();
105            // `get` promotes an existing entry; otherwise track it with a placeholder
106            // size until the background pass measures it.
107            if inner.cache.get(name).is_none() {
108                inner.cache.put(name.to_string(), 0);
109            }
110            inner.dirty.insert(name.to_string());
111            self.set_gauges(&inner);
112        }
113        self.work.notify_one();
114    }
115
116    /// On-disk path of a tracked mirror.
117    pub fn cache_dir(&self, name: &str) -> PathBuf {
118        self.cache_root.join(name)
119    }
120
121    /// Current `(total_bytes, mirror_count)` - the values mirrored to the gauges.
122    pub fn totals(&self) -> (u64, usize) {
123        let inner = self.lock();
124        (inner.total, inner.cache.len())
125    }
126
127    /// Take the set of mirrors needing (re)measurement, clearing it.
128    fn take_dirty(&self) -> Vec<String> {
129        self.lock().dirty.drain().collect()
130    }
131
132    /// Set a mirror's measured size, adjusting the running total. Called by the
133    /// background task after walking the mirror. Uses `peek`/`peek_mut`, which leave
134    /// recency untouched - a background measurement is not an access.
135    fn set_size(&self, name: &str, size: u64) {
136        let mut inner = self.lock();
137        let Some(old) = inner.cache.peek(name).copied() else {
138            return; // evicted between mark and measure
139        };
140        inner.total = inner.total - old + size;
141        if let Some(v) = inner.cache.peek_mut(name) {
142            *v = size;
143        }
144        self.set_gauges(&inner);
145    }
146
147    /// Pop least-recently-used mirrors off the tail until the total would be back
148    /// under the cap, removing them from the index. Returns `(name, dir)` for each
149    /// so the caller can delete it from disk. Empty when already under cap.
150    fn take_victims(&self) -> Vec<(String, PathBuf)> {
151        let mut inner = self.lock();
152        let mut victims = Vec::new();
153        while inner.total > self.max_bytes {
154            let Some((name, size)) = inner.cache.pop_lru() else {
155                break;
156            };
157            inner.total -= size;
158            let dir = self.cache_root.join(&name);
159            victims.push((name, dir));
160        }
161        self.set_gauges(&inner);
162        victims
163    }
164
165    fn lock(&self) -> MutexGuard<'_, Inner> {
166        self.state.lock().expect("cache index lock")
167    }
168
169    fn set_gauges(&self, inner: &Inner) {
170        self.metrics.set_cache_size(inner.total, inner.cache.len());
171    }
172}
173
174/// Background maintenance task. Measures changed mirrors and evicts the LRU tail
175/// whenever the index signals work, and exits cleanly when `shutdown` fires (or its
176/// sender drops).
177pub async fn run(
178    cache: Arc<GitCache>,
179    index: Arc<CacheIndex>,
180    mut shutdown: watch::Receiver<bool>,
181) {
182    loop {
183        // Run first: covers the startup-over-cap permit and any signal that arrived
184        // while the previous pass ran (a stored `notify_one` permit makes the next
185        // wait return immediately, so no work is missed).
186        maintain(&cache, &index).await;
187        tokio::select! {
188            biased; // prefer shutdown over another pass when both are ready
189            _ = shutdown.changed() => break,
190            _ = index.work.notified() => {}
191        }
192    }
193    tracing::debug!("cache evictor stopped");
194}
195
196/// One maintenance pass: (re)measure changed mirrors, then evict the LRU tail until
197/// under the cap. All disk IO lives here, off the request path.
198async fn maintain(cache: &GitCache, index: &CacheIndex) {
199    let dirty = index.take_dirty();
200    if !dirty.is_empty() {
201        let dirs: Vec<(String, PathBuf)> = dirty
202            .into_iter()
203            .map(|n| {
204                let dir = index.cache_dir(&n);
205                (n, dir)
206            })
207            .collect();
208        // The walk is blocking; keep it off the runtime.
209        let measured = tokio::task::spawn_blocking(move || {
210            dirs.into_iter()
211                .map(|(name, dir)| (name, measure(&dir).0))
212                .collect::<Vec<_>>()
213        })
214        .await
215        .unwrap_or_default();
216        for (name, size) in measured {
217            index.set_size(&name, size);
218        }
219    }
220
221    for (name, dir) in index.take_victims() {
222        match cache.evict(&name, &dir).await {
223            Ok(()) => {
224                index.metrics.record_eviction();
225                tracing::info!(repo = %name, "evicted idle mirror");
226            }
227            // The entry is already out of the index; a failed unlink just leaves an
228            // untracked dir on disk, which the next startup scan picks back up.
229            Err(e) => tracing::warn!(repo = %name, error = %e, "evict failed"),
230        }
231    }
232}
233
234/// A mirror found on disk during the startup scan.
235struct Scanned {
236    name: String,
237    size: u64,
238    mtime: SystemTime,
239}
240
241/// Discover every mirror under the cache root. Mirrors live at arbitrary depth
242/// (`resolve` maps `group/team/foo.git` onto nested dirs), so this descends the
243/// namespace dirs - via an explicit stack, not recursion - and stops at each mirror
244/// root (a dir with a top-level `HEAD` file, the same "initialised mirror" marker
245/// `ensure_fresh` uses). Reserved staging/trash dirs are skipped so they never
246/// count toward the budget. Startup-only; steady state is the in-memory index. It
247/// reads only the namespace dirs here; `measure` reads inside each mirror, so the
248/// two never traverse the same directory twice.
249fn find_mirrors(cache_root: &Path) -> Vec<Scanned> {
250    let mut out = Vec::new();
251    let mut stack = vec![cache_root.to_path_buf()];
252    while let Some(dir) = stack.pop() {
253        if dir.join("HEAD").is_file() {
254            let name = rel_name(cache_root, &dir);
255            if name.is_empty() {
256                continue; // the cache root itself is not a mirror
257            }
258            let (size, mtime) = measure(&dir);
259            out.push(Scanned { name, size, mtime });
260            continue; // a mirror's subdirs are not themselves mirrors
261        }
262        let Ok(entries) = std::fs::read_dir(&dir) else {
263            continue;
264        };
265        for entry in entries.flatten() {
266            let Ok(ft) = entry.file_type() else { continue };
267            if !ft.is_dir() {
268                continue;
269            }
270            let fname = entry.file_name();
271            let fname = fname.to_string_lossy();
272            if fname.ends_with(crate::repo::INCOMING_SUFFIX)
273                || fname.ends_with(crate::repo::EVICTING_SUFFIX)
274            {
275                continue;
276            }
277            stack.push(entry.path());
278        }
279    }
280    out
281}
282
283/// A mirror's cache-key name: its path relative to the root, `/`-joined so it
284/// matches the key `resolve` produces regardless of the platform separator.
285fn rel_name(root: &Path, dir: &Path) -> String {
286    dir.strip_prefix(root)
287        .unwrap_or(dir)
288        .components()
289        .map(|c| c.as_os_str().to_string_lossy())
290        .collect::<Vec<_>>()
291        .join("/")
292}
293
294/// Total byte size of a mirror and the newest mtime among its files. Reused for the
295/// startup scan and the background size refresh. A bare mirror is a handful of
296/// (mostly packed) files, so the walk cost tracks file count, not bytes.
297pub(crate) fn measure(dir: &Path) -> (u64, SystemTime) {
298    let mut size = 0u64;
299    let mut mtime = SystemTime::UNIX_EPOCH;
300    let mut stack = vec![dir.to_path_buf()];
301    while let Some(d) = stack.pop() {
302        let Ok(entries) = std::fs::read_dir(&d) else {
303            continue;
304        };
305        for entry in entries.flatten() {
306            let Ok(md) = entry.metadata() else { continue };
307            if md.is_dir() {
308                stack.push(entry.path());
309            } else {
310                size += md.len();
311                if let Ok(mt) = md.modified()
312                    && mt > mtime
313                {
314                    mtime = mt;
315                }
316            }
317        }
318    }
319    (size, mtime)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::io::Write;
326    use std::time::Duration;
327
328    use tokio::sync::watch;
329
330    use crate::git::{GitCache, GitConfig};
331
332    #[test]
333    fn size_accounting_tracks_the_total() {
334        let tmp = tempfile::tempdir().unwrap();
335        let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
336        idx.mark_changed("a"); // placeholder, size 0
337        idx.set_size("a", 100);
338        idx.mark_changed("b");
339        idx.set_size("b", 50);
340        assert_eq!(idx.totals(), (150, 2));
341        idx.set_size("a", 200); // re-measure in place, not a new entry
342        assert_eq!(idx.totals(), (250, 2));
343    }
344
345    #[test]
346    fn victims_pop_oldest_first_until_under_cap() {
347        let tmp = tempfile::tempdir().unwrap();
348        let root = tmp.path();
349        let now = SystemTime::now();
350        make_mirror(
351            &root.join("old.git"),
352            4096,
353            Some(now - Duration::from_secs(120)),
354        );
355        make_mirror(
356            &root.join("mid.git"),
357            4096,
358            Some(now - Duration::from_secs(60)),
359        );
360        make_mirror(&root.join("new.git"), 4096, Some(now));
361
362        // Each mirror is ~4 KiB; a 6000-byte cap leaves room for one, so the two
363        // oldest are popped, oldest first.
364        let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
365        let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
366        assert_eq!(names, vec!["old.git".to_string(), "mid.git".to_string()]);
367        assert_eq!(idx.totals().1, 1); // one mirror left in the index
368    }
369
370    #[test]
371    fn touch_promotes_and_spares_from_eviction() {
372        let tmp = tempfile::tempdir().unwrap();
373        let root = tmp.path();
374        let now = SystemTime::now();
375        make_mirror(
376            &root.join("old.git"),
377            4096,
378            Some(now - Duration::from_secs(120)),
379        );
380        make_mirror(
381            &root.join("mid.git"),
382            4096,
383            Some(now - Duration::from_secs(60)),
384        );
385        make_mirror(&root.join("new.git"), 4096, Some(now));
386
387        let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
388        idx.touch("old.git"); // now the most-recently-used, must be spared
389        let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
390        assert_eq!(names, vec!["mid.git".to_string(), "new.git".to_string()]);
391    }
392
393    #[tokio::test]
394    async fn maintain_measures_then_evicts_on_disk() {
395        let tmp = tempfile::tempdir().unwrap();
396        let root = tmp.path();
397        // Empty index, tiny cap; a single mirror appears on disk and is flagged.
398        make_mirror(&root.join("big.git"), 8192, None);
399
400        let metrics = Arc::new(Metrics::new());
401        let idx = CacheIndex::new(root.to_path_buf(), 4096, metrics.clone());
402        let cache = GitCache::new(dummy_cfg(), metrics.clone(), Some(idx.clone()));
403        idx.mark_changed("big.git"); // request path would do this after a clone
404
405        maintain(&cache, &idx).await; // measures big.git (>cap) then evicts it
406
407        assert!(
408            !root.join("big.git").exists(),
409            "over-cap mirror should be evicted"
410        );
411        assert_eq!(idx.totals(), (0, 0));
412        assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
413    }
414
415    #[tokio::test]
416    async fn run_evicts_over_cap_then_stops_on_shutdown() {
417        let tmp = tempfile::tempdir().unwrap();
418        let root = tmp.path();
419        let now = SystemTime::now();
420        make_mirror(
421            &root.join("old.git"),
422            4096,
423            Some(now - Duration::from_secs(120)),
424        );
425        make_mirror(&root.join("new.git"), 4096, None);
426
427        let metrics = Arc::new(Metrics::new());
428        let idx = CacheIndex::new(root.to_path_buf(), 6000, metrics.clone()); // over cap
429        let cache = Arc::new(GitCache::new(
430            dummy_cfg(),
431            metrics.clone(),
432            Some(idx.clone()),
433        ));
434
435        let (shutdown_tx, shutdown_rx) = watch::channel(false);
436        let handle = tokio::spawn(run(cache, idx.clone(), shutdown_rx));
437        // `run` drains once before its first `select`, so the eviction completes
438        // before the task can observe shutdown; awaiting the handle after signalling
439        // guarantees the drain ran and the loop exited cleanly.
440        shutdown_tx.send(true).unwrap();
441        handle.await.unwrap();
442
443        assert!(!root.join("old.git").exists(), "oldest mirror evicted");
444        assert!(root.join("new.git").exists(), "newest mirror kept");
445        assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
446    }
447
448    #[test]
449    fn set_size_ignores_an_untracked_mirror() {
450        let tmp = tempfile::tempdir().unwrap();
451        let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
452        // No entry for this name (e.g. evicted between mark and measure): no-op.
453        idx.set_size("never-tracked", 999);
454        assert_eq!(idx.totals(), (0, 0));
455    }
456
457    #[test]
458    fn scan_skips_stray_files_and_reserved_dirs() {
459        let tmp = tempfile::tempdir().unwrap();
460        let root = tmp.path();
461        make_mirror(&root.join("good.git"), 1024, None);
462        // A non-directory entry at the root must be ignored by the walk.
463        std::fs::write(root.join("stray.txt"), b"x").unwrap();
464        // Crashed clone/eviction leftovers must not be scanned as mirrors.
465        make_mirror(
466            &root.join(format!("wip.git{}", crate::repo::INCOMING_SUFFIX)),
467            1024,
468            None,
469        );
470        make_mirror(
471            &root.join(format!("gone.git{}", crate::repo::EVICTING_SUFFIX)),
472            1024,
473            None,
474        );
475
476        let idx = CacheIndex::new(root.to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
477        assert_eq!(idx.totals().1, 1, "only the real mirror is tracked");
478    }
479
480    #[tokio::test]
481    async fn evict_is_a_noop_when_the_mirror_is_already_gone() {
482        let tmp = tempfile::tempdir().unwrap();
483        let cache = GitCache::new(dummy_cfg(), Arc::new(Metrics::new()), None);
484        // No `HEAD` at this path, so `evict` returns early without touching disk.
485        let dir = tmp.path().join("absent.git");
486        cache.evict("absent.git", &dir).await.unwrap();
487        assert!(!dir.exists());
488    }
489
490    fn dummy_cfg() -> GitConfig {
491        // Eviction never shells out to git, so the binary is irrelevant.
492        GitConfig {
493            git_binary: "git".into(),
494            upstream_auth_header: None,
495            fetch_ttl: Duration::from_secs(10),
496        }
497    }
498
499    /// A fake bare mirror: `HEAD` plus a data file summing to at least `data_bytes`.
500    /// When `mtime` is set, both files are stamped with it so the mirror's last-used
501    /// signal is deterministic.
502    fn make_mirror(dir: &Path, data_bytes: usize, mtime: Option<SystemTime>) {
503        std::fs::create_dir_all(dir.join("objects")).unwrap();
504        write_file(&dir.join("HEAD"), b"ref: refs/heads/main\n", mtime);
505        write_file(
506            &dir.join("objects/pack.data"),
507            &vec![b'x'; data_bytes],
508            mtime,
509        );
510    }
511
512    fn write_file(path: &Path, bytes: &[u8], mtime: Option<SystemTime>) {
513        let mut f = std::fs::File::create(path).unwrap();
514        f.write_all(bytes).unwrap();
515        if let Some(t) = mtime {
516            f.set_modified(t).unwrap();
517        }
518    }
519}