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 mirrors = find_mirrors(&cache_root);
63 mirrors.sort_by_key(|m| m.mtime); 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(); }
88 idx
89 }
90
91 pub fn touch(&self, name: &str) {
95 let _ = self.lock().cache.get(name);
97 }
98
99 pub fn mark_changed(&self, name: &str) {
103 {
104 let mut inner = self.lock();
105 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 pub fn cache_dir(&self, name: &str) -> PathBuf {
118 self.cache_root.join(name)
119 }
120
121 pub fn totals(&self) -> (u64, usize) {
123 let inner = self.lock();
124 (inner.total, inner.cache.len())
125 }
126
127 fn take_dirty(&self) -> Vec<String> {
129 self.lock().dirty.drain().collect()
130 }
131
132 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; };
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 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
174pub async fn run(
178 cache: Arc<GitCache>,
179 index: Arc<CacheIndex>,
180 mut shutdown: watch::Receiver<bool>,
181) {
182 loop {
183 maintain(&cache, &index).await;
187 tokio::select! {
188 biased; _ = shutdown.changed() => break,
190 _ = index.work.notified() => {}
191 }
192 }
193 tracing::debug!("cache evictor stopped");
194}
195
196async 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 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 Err(e) => tracing::warn!(repo = %name, error = %e, "evict failed"),
230 }
231 }
232}
233
234struct Scanned {
236 name: String,
237 size: u64,
238 mtime: SystemTime,
239}
240
241fn 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; }
258 let (size, mtime) = measure(&dir);
259 out.push(Scanned { name, size, mtime });
260 continue; }
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
283fn 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
294pub(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"); 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); 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 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); }
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"); 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 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"); maintain(&cache, &idx).await; 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()); 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 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 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 std::fs::write(root.join("stray.txt"), b"x").unwrap();
464 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 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 GitConfig {
493 git_binary: "git".into(),
494 upstream_auth_header: None,
495 fetch_ttl: Duration::from_secs(10),
496 }
497 }
498
499 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}