1use 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 cache: LruCache<String, u64>,
35 dirty: HashSet<String>,
37 total: u64,
40}
41
42pub struct CacheIndex {
46 cache_root: PathBuf,
47 max_bytes: u64,
48 metrics: Arc<Metrics>,
49 work: Notify,
53 state: Mutex<Inner>,
54}
55
56impl CacheIndex {
57 pub fn new(cache_root: PathBuf, max_bytes: u64, metrics: Arc<Metrics>) -> Arc<Self> {
62 let mut entries = find_mirrors(&cache_root);
64 entries.extend(find_lfs_blobs(&cache_root));
65 entries.sort_by_key(|m| m.mtime); let mut cache: LruCache<String, u64> = LruCache::unbounded();
68 let mut total = 0u64;
69 for m in entries {
70 total += m.size;
71 cache.put(m.name, m.size);
72 }
73 let inner = Inner {
74 cache,
75 dirty: HashSet::new(),
76 total,
77 };
78 metrics.set_cache_size(total, inner.cache.len());
79 let over = total > max_bytes;
80 let idx = Arc::new(Self {
81 cache_root,
82 max_bytes,
83 metrics,
84 work: Notify::new(),
85 state: Mutex::new(inner),
86 });
87 if over {
88 idx.work.notify_one(); }
90 idx
91 }
92
93 pub fn touch(&self, name: &str) {
97 let _ = self.lock().cache.get(name);
99 }
100
101 pub fn record_blob(&self, key: &str, size: u64) {
106 {
107 let mut inner = self.lock();
108 let old = inner.cache.put(key.to_string(), size); inner.total = inner.total - old.unwrap_or(0) + size;
110 self.set_gauges(&inner);
111 }
112 self.work.notify_one();
113 }
114
115 pub fn mark_changed(&self, name: &str) {
119 {
120 let mut inner = self.lock();
121 if inner.cache.get(name).is_none() {
124 inner.cache.put(name.to_string(), 0);
125 }
126 inner.dirty.insert(name.to_string());
127 self.set_gauges(&inner);
128 }
129 self.work.notify_one();
130 }
131
132 pub fn cache_dir(&self, name: &str) -> PathBuf {
134 self.cache_root.join(name)
135 }
136
137 pub fn totals(&self) -> (u64, usize) {
139 let inner = self.lock();
140 (inner.total, inner.cache.len())
141 }
142
143 fn take_dirty(&self) -> Vec<String> {
145 self.lock().dirty.drain().collect()
146 }
147
148 fn set_size(&self, name: &str, size: u64) {
152 let mut inner = self.lock();
153 let Some(old) = inner.cache.peek(name).copied() else {
154 return; };
156 inner.total = inner.total - old + size;
157 if let Some(v) = inner.cache.peek_mut(name) {
158 *v = size;
159 }
160 self.set_gauges(&inner);
161 }
162
163 fn take_victims(&self) -> Vec<(String, PathBuf)> {
167 let mut inner = self.lock();
168 let mut victims = Vec::new();
169 while inner.total > self.max_bytes {
170 let Some((name, size)) = inner.cache.pop_lru() else {
171 break;
172 };
173 inner.total -= size;
174 let dir = self.cache_root.join(&name);
175 victims.push((name, dir));
176 }
177 self.set_gauges(&inner);
178 victims
179 }
180
181 fn lock(&self) -> MutexGuard<'_, Inner> {
182 self.state.lock().expect("cache index lock")
183 }
184
185 fn set_gauges(&self, inner: &Inner) {
186 self.metrics.set_cache_size(inner.total, inner.cache.len());
187 }
188}
189
190pub async fn run(
194 cache: Arc<GitCache>,
195 index: Arc<CacheIndex>,
196 mut shutdown: watch::Receiver<bool>,
197) {
198 loop {
199 maintain(&cache, &index).await;
203 tokio::select! {
204 biased; _ = shutdown.changed() => break,
206 _ = index.work.notified() => {}
207 }
208 }
209 tracing::debug!("cache evictor stopped");
210}
211
212async fn maintain(cache: &GitCache, index: &CacheIndex) {
215 let dirty = index.take_dirty();
216 if !dirty.is_empty() {
217 let dirs: Vec<(String, PathBuf)> = dirty
218 .into_iter()
219 .map(|n| {
220 let dir = index.cache_dir(&n);
221 (n, dir)
222 })
223 .collect();
224 let measured = tokio::task::spawn_blocking(move || {
226 dirs.into_iter()
227 .map(|(name, dir)| (name, measure(&dir).0))
228 .collect::<Vec<_>>()
229 })
230 .await
231 .unwrap_or_default();
232 for (name, size) in measured {
233 index.set_size(&name, size);
234 }
235 }
236
237 for (name, dir) in index.take_victims() {
238 let result = if is_lfs_blob(&name) {
243 match tokio::fs::remove_file(&dir).await {
244 Ok(()) => Ok(()),
245 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
246 Err(e) => Err(anyhow::Error::from(e)),
247 }
248 } else {
249 cache.evict(&name, &dir).await
250 };
251 match result {
252 Ok(()) => {
253 index.metrics.record_eviction();
254 tracing::info!(entry = %name, "evicted idle cache entry");
255 }
256 Err(e) => tracing::warn!(entry = %name, error = %e, "evict failed"),
259 }
260 }
261}
262
263fn is_lfs_blob(name: &str) -> bool {
266 name.split('/').next() == Some(crate::repo::LFS_OBJECTS_DIR)
267}
268
269struct Scanned {
271 name: String,
272 size: u64,
273 mtime: SystemTime,
274}
275
276fn find_mirrors(cache_root: &Path) -> Vec<Scanned> {
285 let mut out = Vec::new();
286 let mut stack = vec![cache_root.to_path_buf()];
287 while let Some(dir) = stack.pop() {
288 if dir.join("HEAD").is_file() {
289 let name = rel_name(cache_root, &dir);
290 if name.is_empty() {
291 continue; }
293 let (size, mtime) = measure(&dir);
294 out.push(Scanned { name, size, mtime });
295 continue; }
297 let Ok(entries) = std::fs::read_dir(&dir) else {
298 continue;
299 };
300 for entry in entries.flatten() {
301 let Ok(ft) = entry.file_type() else { continue };
302 if !ft.is_dir() {
303 continue;
304 }
305 let fname = entry.file_name();
306 let fname = fname.to_string_lossy();
307 if fname.ends_with(crate::repo::INCOMING_SUFFIX)
308 || fname.ends_with(crate::repo::EVICTING_SUFFIX)
309 || fname == crate::repo::LFS_OBJECTS_DIR
310 {
311 continue; }
313 stack.push(entry.path());
314 }
315 }
316 out
317}
318
319fn find_lfs_blobs(cache_root: &Path) -> Vec<Scanned> {
324 let mut out = Vec::new();
325 let lfs_root = cache_root.join(crate::repo::LFS_OBJECTS_DIR);
326 let Ok(shards) = std::fs::read_dir(&lfs_root) else {
327 return out; };
329 for shard in shards.flatten() {
330 let Ok(ft) = shard.file_type() else { continue };
331 if !ft.is_dir() || shard.file_name().to_string_lossy() == crate::lfs::INCOMING_DIR {
332 continue;
333 }
334 let shard_name = shard.file_name().to_string_lossy().into_owned();
335 let Ok(objects) = std::fs::read_dir(shard.path()) else {
336 continue;
337 };
338 for object in objects.flatten() {
339 let Ok(md) = object.metadata() else { continue };
340 if !md.is_file() {
341 continue;
342 }
343 let oid = object.file_name().to_string_lossy().into_owned();
344 out.push(Scanned {
345 name: format!("{}/{shard_name}/{oid}", crate::repo::LFS_OBJECTS_DIR),
346 size: md.len(),
347 mtime: md.modified().unwrap_or(SystemTime::UNIX_EPOCH),
348 });
349 }
350 }
351 out
352}
353
354fn rel_name(root: &Path, dir: &Path) -> String {
357 dir.strip_prefix(root)
358 .unwrap_or(dir)
359 .components()
360 .map(|c| c.as_os_str().to_string_lossy())
361 .collect::<Vec<_>>()
362 .join("/")
363}
364
365pub(crate) fn measure(dir: &Path) -> (u64, SystemTime) {
369 let mut size = 0u64;
370 let mut mtime = SystemTime::UNIX_EPOCH;
371 let mut stack = vec![dir.to_path_buf()];
372 while let Some(d) = stack.pop() {
373 let Ok(entries) = std::fs::read_dir(&d) else {
374 continue;
375 };
376 for entry in entries.flatten() {
377 let Ok(md) = entry.metadata() else { continue };
378 if md.is_dir() {
379 stack.push(entry.path());
380 } else {
381 size += md.len();
382 if let Ok(mt) = md.modified()
383 && mt > mtime
384 {
385 mtime = mt;
386 }
387 }
388 }
389 }
390 (size, mtime)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use std::io::Write;
397 use std::time::Duration;
398
399 use tokio::sync::watch;
400
401 use crate::git::{GitCache, GitConfig};
402
403 #[test]
404 fn size_accounting_tracks_the_total() {
405 let tmp = tempfile::tempdir().unwrap();
406 let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
407 idx.mark_changed("a"); idx.set_size("a", 100);
409 idx.mark_changed("b");
410 idx.set_size("b", 50);
411 assert_eq!(idx.totals(), (150, 2));
412 idx.set_size("a", 200); assert_eq!(idx.totals(), (250, 2));
414 }
415
416 #[test]
417 fn victims_pop_oldest_first_until_under_cap() {
418 let tmp = tempfile::tempdir().unwrap();
419 let root = tmp.path();
420 let now = SystemTime::now();
421 make_mirror(
422 &root.join("old.git"),
423 4096,
424 Some(now - Duration::from_secs(120)),
425 );
426 make_mirror(
427 &root.join("mid.git"),
428 4096,
429 Some(now - Duration::from_secs(60)),
430 );
431 make_mirror(&root.join("new.git"), 4096, Some(now));
432
433 let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
436 let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
437 assert_eq!(names, vec!["old.git".to_string(), "mid.git".to_string()]);
438 assert_eq!(idx.totals().1, 1); }
440
441 #[test]
442 fn touch_promotes_and_spares_from_eviction() {
443 let tmp = tempfile::tempdir().unwrap();
444 let root = tmp.path();
445 let now = SystemTime::now();
446 make_mirror(
447 &root.join("old.git"),
448 4096,
449 Some(now - Duration::from_secs(120)),
450 );
451 make_mirror(
452 &root.join("mid.git"),
453 4096,
454 Some(now - Duration::from_secs(60)),
455 );
456 make_mirror(&root.join("new.git"), 4096, Some(now));
457
458 let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
459 idx.touch("old.git"); let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
461 assert_eq!(names, vec!["mid.git".to_string(), "new.git".to_string()]);
462 }
463
464 #[tokio::test]
465 async fn maintain_measures_then_evicts_on_disk() {
466 let tmp = tempfile::tempdir().unwrap();
467 let root = tmp.path();
468 make_mirror(&root.join("big.git"), 8192, None);
470
471 let metrics = Arc::new(Metrics::new());
472 let idx = CacheIndex::new(root.to_path_buf(), 4096, metrics.clone());
473 let cache = GitCache::new(dummy_cfg(), metrics.clone(), Some(idx.clone()));
474 idx.mark_changed("big.git"); maintain(&cache, &idx).await; assert!(
479 !root.join("big.git").exists(),
480 "over-cap mirror should be evicted"
481 );
482 assert_eq!(idx.totals(), (0, 0));
483 assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
484 }
485
486 #[tokio::test]
487 async fn run_evicts_over_cap_then_stops_on_shutdown() {
488 let tmp = tempfile::tempdir().unwrap();
489 let root = tmp.path();
490 let now = SystemTime::now();
491 make_mirror(
492 &root.join("old.git"),
493 4096,
494 Some(now - Duration::from_secs(120)),
495 );
496 make_mirror(&root.join("new.git"), 4096, None);
497
498 let metrics = Arc::new(Metrics::new());
499 let idx = CacheIndex::new(root.to_path_buf(), 6000, metrics.clone()); let cache = Arc::new(GitCache::new(
501 dummy_cfg(),
502 metrics.clone(),
503 Some(idx.clone()),
504 ));
505
506 let (shutdown_tx, shutdown_rx) = watch::channel(false);
507 let handle = tokio::spawn(run(cache, idx.clone(), shutdown_rx));
508 shutdown_tx.send(true).unwrap();
512 handle.await.unwrap();
513
514 assert!(!root.join("old.git").exists(), "oldest mirror evicted");
515 assert!(root.join("new.git").exists(), "newest mirror kept");
516 assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
517 }
518
519 #[test]
520 fn set_size_ignores_an_untracked_mirror() {
521 let tmp = tempfile::tempdir().unwrap();
522 let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
523 idx.set_size("never-tracked", 999);
525 assert_eq!(idx.totals(), (0, 0));
526 }
527
528 #[test]
529 fn scan_skips_stray_files_and_reserved_dirs() {
530 let tmp = tempfile::tempdir().unwrap();
531 let root = tmp.path();
532 make_mirror(&root.join("good.git"), 1024, None);
533 std::fs::write(root.join("stray.txt"), b"x").unwrap();
535 make_mirror(
537 &root.join(format!("wip.git{}", crate::repo::INCOMING_SUFFIX)),
538 1024,
539 None,
540 );
541 make_mirror(
542 &root.join(format!("gone.git{}", crate::repo::EVICTING_SUFFIX)),
543 1024,
544 None,
545 );
546
547 let idx = CacheIndex::new(root.to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
548 assert_eq!(idx.totals().1, 1, "only the real mirror is tracked");
549 }
550
551 #[tokio::test]
552 async fn evict_is_a_noop_when_the_mirror_is_already_gone() {
553 let tmp = tempfile::tempdir().unwrap();
554 let cache = GitCache::new(dummy_cfg(), Arc::new(Metrics::new()), None);
555 let dir = tmp.path().join("absent.git");
557 cache.evict("absent.git", &dir).await.unwrap();
558 assert!(!dir.exists());
559 }
560
561 #[test]
562 fn record_blob_tracks_exact_size_in_place() {
563 let tmp = tempfile::tempdir().unwrap();
564 let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
565 let key = format!("{}/ab/oid", crate::repo::LFS_OBJECTS_DIR);
566 idx.record_blob(&key, 500);
567 assert_eq!(idx.totals(), (500, 1));
568 idx.record_blob(&key, 700);
570 assert_eq!(idx.totals(), (700, 1));
571 }
572
573 #[tokio::test]
574 async fn lfs_blobs_are_scanned_at_startup_and_evicted_over_cap() {
575 let tmp = tempfile::tempdir().unwrap();
576 let root = tmp.path();
577 let oid = format!("ab{}", "c".repeat(62));
579 let blob = root
580 .join(crate::repo::LFS_OBJECTS_DIR)
581 .join("ab")
582 .join(&oid);
583 std::fs::create_dir_all(blob.parent().unwrap()).unwrap();
584 let incoming = root
586 .join(crate::repo::LFS_OBJECTS_DIR)
587 .join(crate::lfs::INCOMING_DIR);
588 std::fs::create_dir_all(&incoming).unwrap();
589 write_file(&incoming.join("half"), &[b'x'; 10], None);
590 write_file(&blob, &vec![b'x'; 4096], None);
591
592 let metrics = Arc::new(Metrics::new());
593 let idx = CacheIndex::new(root.to_path_buf(), 1000, metrics.clone());
595 assert_eq!(
596 idx.totals(),
597 (4096, 1),
598 "only the blob is tracked, not the in-flight file"
599 );
600
601 let cache = GitCache::new(dummy_cfg(), metrics.clone(), Some(idx.clone()));
602 maintain(&cache, &idx).await; assert!(!blob.exists(), "over-cap LFS blob should be evicted");
604 assert_eq!(idx.totals(), (0, 0));
605 assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
606 }
607
608 fn dummy_cfg() -> GitConfig {
609 GitConfig {
611 git_binary: "git".into(),
612 upstream_auth_header: None,
613 fetch_ttl: Duration::from_secs(10),
614 }
615 }
616
617 fn make_mirror(dir: &Path, data_bytes: usize, mtime: Option<SystemTime>) {
621 std::fs::create_dir_all(dir.join("objects")).unwrap();
622 write_file(&dir.join("HEAD"), b"ref: refs/heads/main\n", mtime);
623 write_file(
624 &dir.join("objects/pack.data"),
625 &vec![b'x'; data_bytes],
626 mtime,
627 );
628 }
629
630 fn write_file(path: &Path, bytes: &[u8], mtime: Option<SystemTime>) {
631 let mut f = std::fs::File::create(path).unwrap();
632 f.write_all(bytes).unwrap();
633 if let Some(t) = mtime {
634 f.set_modified(t).unwrap();
635 }
636 }
637}