use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::SystemTime;
use lru::LruCache;
use tokio::sync::{Notify, watch};
use crate::git::GitCache;
use crate::metrics::Metrics;
struct Inner {
cache: LruCache<String, u64>,
dirty: HashSet<String>,
total: u64,
}
pub struct CacheIndex {
cache_root: PathBuf,
max_bytes: u64,
metrics: Arc<Metrics>,
work: Notify,
state: Mutex<Inner>,
}
impl CacheIndex {
pub fn new(cache_root: PathBuf, max_bytes: u64, metrics: Arc<Metrics>) -> Arc<Self> {
let mut mirrors = find_mirrors(&cache_root);
mirrors.sort_by_key(|m| m.mtime); let mut cache: LruCache<String, u64> = LruCache::unbounded();
let mut total = 0u64;
for m in mirrors {
total += m.size;
cache.put(m.name, m.size);
}
let inner = Inner {
cache,
dirty: HashSet::new(),
total,
};
metrics.set_cache_size(total, inner.cache.len());
let over = total > max_bytes;
let idx = Arc::new(Self {
cache_root,
max_bytes,
metrics,
work: Notify::new(),
state: Mutex::new(inner),
});
if over {
idx.work.notify_one(); }
idx
}
pub fn touch(&self, name: &str) {
let _ = self.lock().cache.get(name);
}
pub fn mark_changed(&self, name: &str) {
{
let mut inner = self.lock();
if inner.cache.get(name).is_none() {
inner.cache.put(name.to_string(), 0);
}
inner.dirty.insert(name.to_string());
self.set_gauges(&inner);
}
self.work.notify_one();
}
pub fn cache_dir(&self, name: &str) -> PathBuf {
self.cache_root.join(name)
}
pub fn totals(&self) -> (u64, usize) {
let inner = self.lock();
(inner.total, inner.cache.len())
}
fn take_dirty(&self) -> Vec<String> {
self.lock().dirty.drain().collect()
}
fn set_size(&self, name: &str, size: u64) {
let mut inner = self.lock();
let Some(old) = inner.cache.peek(name).copied() else {
return; };
inner.total = inner.total - old + size;
if let Some(v) = inner.cache.peek_mut(name) {
*v = size;
}
self.set_gauges(&inner);
}
fn take_victims(&self) -> Vec<(String, PathBuf)> {
let mut inner = self.lock();
let mut victims = Vec::new();
while inner.total > self.max_bytes {
let Some((name, size)) = inner.cache.pop_lru() else {
break;
};
inner.total -= size;
let dir = self.cache_root.join(&name);
victims.push((name, dir));
}
self.set_gauges(&inner);
victims
}
fn lock(&self) -> MutexGuard<'_, Inner> {
self.state.lock().expect("cache index lock")
}
fn set_gauges(&self, inner: &Inner) {
self.metrics.set_cache_size(inner.total, inner.cache.len());
}
}
pub async fn run(
cache: Arc<GitCache>,
index: Arc<CacheIndex>,
mut shutdown: watch::Receiver<bool>,
) {
loop {
maintain(&cache, &index).await;
tokio::select! {
biased; _ = shutdown.changed() => break,
_ = index.work.notified() => {}
}
}
tracing::debug!("cache evictor stopped");
}
async fn maintain(cache: &GitCache, index: &CacheIndex) {
let dirty = index.take_dirty();
if !dirty.is_empty() {
let dirs: Vec<(String, PathBuf)> = dirty
.into_iter()
.map(|n| {
let dir = index.cache_dir(&n);
(n, dir)
})
.collect();
let measured = tokio::task::spawn_blocking(move || {
dirs.into_iter()
.map(|(name, dir)| (name, measure(&dir).0))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
for (name, size) in measured {
index.set_size(&name, size);
}
}
for (name, dir) in index.take_victims() {
match cache.evict(&name, &dir).await {
Ok(()) => {
index.metrics.record_eviction();
tracing::info!(repo = %name, "evicted idle mirror");
}
Err(e) => tracing::warn!(repo = %name, error = %e, "evict failed"),
}
}
}
struct Scanned {
name: String,
size: u64,
mtime: SystemTime,
}
fn find_mirrors(cache_root: &Path) -> Vec<Scanned> {
let mut out = Vec::new();
let mut stack = vec![cache_root.to_path_buf()];
while let Some(dir) = stack.pop() {
if dir.join("HEAD").is_file() {
let name = rel_name(cache_root, &dir);
if name.is_empty() {
continue; }
let (size, mtime) = measure(&dir);
out.push(Scanned { name, size, mtime });
continue; }
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_dir() {
continue;
}
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if fname.ends_with(crate::repo::INCOMING_SUFFIX)
|| fname.ends_with(crate::repo::EVICTING_SUFFIX)
{
continue;
}
stack.push(entry.path());
}
}
out
}
fn rel_name(root: &Path, dir: &Path) -> String {
dir.strip_prefix(root)
.unwrap_or(dir)
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
pub(crate) fn measure(dir: &Path) -> (u64, SystemTime) {
let mut size = 0u64;
let mut mtime = SystemTime::UNIX_EPOCH;
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for entry in entries.flatten() {
let Ok(md) = entry.metadata() else { continue };
if md.is_dir() {
stack.push(entry.path());
} else {
size += md.len();
if let Ok(mt) = md.modified()
&& mt > mtime
{
mtime = mt;
}
}
}
}
(size, mtime)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::time::Duration;
use tokio::sync::watch;
use crate::git::{GitCache, GitConfig};
#[test]
fn size_accounting_tracks_the_total() {
let tmp = tempfile::tempdir().unwrap();
let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
idx.mark_changed("a"); idx.set_size("a", 100);
idx.mark_changed("b");
idx.set_size("b", 50);
assert_eq!(idx.totals(), (150, 2));
idx.set_size("a", 200); assert_eq!(idx.totals(), (250, 2));
}
#[test]
fn victims_pop_oldest_first_until_under_cap() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let now = SystemTime::now();
make_mirror(
&root.join("old.git"),
4096,
Some(now - Duration::from_secs(120)),
);
make_mirror(
&root.join("mid.git"),
4096,
Some(now - Duration::from_secs(60)),
);
make_mirror(&root.join("new.git"), 4096, Some(now));
let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
assert_eq!(names, vec!["old.git".to_string(), "mid.git".to_string()]);
assert_eq!(idx.totals().1, 1); }
#[test]
fn touch_promotes_and_spares_from_eviction() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let now = SystemTime::now();
make_mirror(
&root.join("old.git"),
4096,
Some(now - Duration::from_secs(120)),
);
make_mirror(
&root.join("mid.git"),
4096,
Some(now - Duration::from_secs(60)),
);
make_mirror(&root.join("new.git"), 4096, Some(now));
let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
idx.touch("old.git"); let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
assert_eq!(names, vec!["mid.git".to_string(), "new.git".to_string()]);
}
#[tokio::test]
async fn maintain_measures_then_evicts_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
make_mirror(&root.join("big.git"), 8192, None);
let metrics = Arc::new(Metrics::new());
let idx = CacheIndex::new(root.to_path_buf(), 4096, metrics.clone());
let cache = GitCache::new(dummy_cfg(), metrics.clone(), Some(idx.clone()));
idx.mark_changed("big.git");
maintain(&cache, &idx).await;
assert!(
!root.join("big.git").exists(),
"over-cap mirror should be evicted"
);
assert_eq!(idx.totals(), (0, 0));
assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
}
#[tokio::test]
async fn run_evicts_over_cap_then_stops_on_shutdown() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let now = SystemTime::now();
make_mirror(
&root.join("old.git"),
4096,
Some(now - Duration::from_secs(120)),
);
make_mirror(&root.join("new.git"), 4096, None);
let metrics = Arc::new(Metrics::new());
let idx = CacheIndex::new(root.to_path_buf(), 6000, metrics.clone()); let cache = Arc::new(GitCache::new(
dummy_cfg(),
metrics.clone(),
Some(idx.clone()),
));
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(run(cache, idx.clone(), shutdown_rx));
shutdown_tx.send(true).unwrap();
handle.await.unwrap();
assert!(!root.join("old.git").exists(), "oldest mirror evicted");
assert!(root.join("new.git").exists(), "newest mirror kept");
assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
}
#[test]
fn set_size_ignores_an_untracked_mirror() {
let tmp = tempfile::tempdir().unwrap();
let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
idx.set_size("never-tracked", 999);
assert_eq!(idx.totals(), (0, 0));
}
#[test]
fn scan_skips_stray_files_and_reserved_dirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
make_mirror(&root.join("good.git"), 1024, None);
std::fs::write(root.join("stray.txt"), b"x").unwrap();
make_mirror(
&root.join(format!("wip.git{}", crate::repo::INCOMING_SUFFIX)),
1024,
None,
);
make_mirror(
&root.join(format!("gone.git{}", crate::repo::EVICTING_SUFFIX)),
1024,
None,
);
let idx = CacheIndex::new(root.to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
assert_eq!(idx.totals().1, 1, "only the real mirror is tracked");
}
#[tokio::test]
async fn evict_is_a_noop_when_the_mirror_is_already_gone() {
let tmp = tempfile::tempdir().unwrap();
let cache = GitCache::new(dummy_cfg(), Arc::new(Metrics::new()), None);
let dir = tmp.path().join("absent.git");
cache.evict("absent.git", &dir).await.unwrap();
assert!(!dir.exists());
}
fn dummy_cfg() -> GitConfig {
GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
fetch_ttl: Duration::from_secs(10),
}
}
fn make_mirror(dir: &Path, data_bytes: usize, mtime: Option<SystemTime>) {
std::fs::create_dir_all(dir.join("objects")).unwrap();
write_file(&dir.join("HEAD"), b"ref: refs/heads/main\n", mtime);
write_file(
&dir.join("objects/pack.data"),
&vec![b'x'; data_bytes],
mtime,
);
}
fn write_file(path: &Path, bytes: &[u8], mtime: Option<SystemTime>) {
let mut f = std::fs::File::create(path).unwrap();
f.write_all(bytes).unwrap();
if let Some(t) = mtime {
f.set_modified(t).unwrap();
}
}
}