use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use lru::LruCache;
use tracing::debug;
use crate::proto::grpc::file::FileInfo;
#[derive(Clone)]
struct CachedFileInfo {
info: Arc<FileInfo>,
inserted_at: Instant,
}
#[derive(Debug, Default)]
pub struct FileInfoCacheStats {
pub hits: u64,
pub misses: u64,
pub expired: u64,
pub invalidations: u64,
}
pub struct FileInfoCache {
inner: Mutex<Inner>,
ttl: Duration,
}
struct Inner {
lru: LruCache<Arc<str>, CachedFileInfo>,
hits: u64,
misses: u64,
expired: u64,
invalidations: u64,
}
impl FileInfoCache {
pub fn maybe_new(ttl: Duration, capacity: usize) -> Option<Arc<Self>> {
if ttl.is_zero() {
return None;
}
let cap = NonZeroUsize::new(capacity.max(1)).expect("capacity clamped to >=1");
Some(Arc::new(Self {
inner: Mutex::new(Inner {
lru: LruCache::new(cap),
hits: 0,
misses: 0,
expired: 0,
invalidations: 0,
}),
ttl,
}))
}
pub fn get(&self, path: &str) -> Option<Arc<FileInfo>> {
let mut inner = self.inner.lock().expect("FileInfoCache mutex poisoned");
let expired = match inner.lru.peek(path) {
Some(entry) => entry.inserted_at.elapsed() >= self.ttl,
None => {
inner.misses += 1;
return None;
}
};
if expired {
inner.lru.pop(path);
inner.expired += 1;
inner.misses += 1;
return None;
}
let info = inner.lru.get(path).map(|e| Arc::clone(&e.info));
if info.is_some() {
inner.hits += 1;
}
info
}
pub fn insert(&self, path: &str, info: FileInfo) {
self.insert_arc(path, Arc::new(info));
}
pub fn insert_arc(&self, path: &str, info: Arc<FileInfo>) {
let key: Arc<str> = Arc::from(path);
let entry = CachedFileInfo {
info,
inserted_at: Instant::now(),
};
let mut inner = self.inner.lock().expect("FileInfoCache mutex poisoned");
inner.lru.put(key, entry);
}
pub fn invalidate(&self, path: &str) {
let mut inner = self.inner.lock().expect("FileInfoCache mutex poisoned");
if inner.lru.pop(path).is_some() {
inner.invalidations += 1;
debug!(path = %path, "FileInfoCache: invalidated entry after write/delete/rename");
}
}
pub fn clear(&self) {
let mut inner = self.inner.lock().expect("FileInfoCache mutex poisoned");
let n = inner.lru.len() as u64;
inner.lru.clear();
inner.invalidations += n;
}
pub fn stats(&self) -> FileInfoCacheStats {
let inner = self.inner.lock().expect("FileInfoCache mutex poisoned");
FileInfoCacheStats {
hits: inner.hits,
misses: inner.misses,
expired: inner.expired,
invalidations: inner.invalidations,
}
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.inner
.lock()
.expect("FileInfoCache mutex poisoned")
.lru
.len()
}
pub fn ttl(&self) -> Duration {
self.ttl
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info_of_length(len: i64) -> FileInfo {
FileInfo {
length: Some(len),
..Default::default()
}
}
#[test]
fn maybe_new_returns_none_when_disabled() {
assert!(FileInfoCache::maybe_new(Duration::ZERO, 1024).is_none());
}
#[test]
fn maybe_new_clamps_capacity_to_one() {
let cache = FileInfoCache::maybe_new(Duration::from_secs(1), 0)
.expect("cache should be enabled with non-zero ttl");
cache.insert("/a", info_of_length(1));
cache.insert("/b", info_of_length(2)); assert!(cache.get("/a").is_none(), "LRU cap = 1 must evict older");
assert!(cache.get("/b").is_some());
}
#[test]
fn hit_and_miss_counters_and_lookup_work() {
let cache = FileInfoCache::maybe_new(Duration::from_secs(60), 128).unwrap();
assert!(cache.get("/x").is_none()); cache.insert("/x", info_of_length(42));
let got = cache.get("/x").unwrap(); assert_eq!(got.length, Some(42));
let s = cache.stats();
assert_eq!(s.hits, 1);
assert_eq!(s.misses, 1);
assert_eq!(s.expired, 0);
}
#[test]
fn ttl_expiry_evicts_and_counts() {
let cache = FileInfoCache::maybe_new(Duration::from_millis(1), 128).unwrap();
cache.insert("/e", info_of_length(7));
std::thread::sleep(Duration::from_millis(5));
assert!(
cache.get("/e").is_none(),
"expired entry must not be served"
);
let s = cache.stats();
assert_eq!(s.expired, 1);
assert_eq!(s.misses, 1);
assert_eq!(cache.len(), 0, "expired entry must be evicted lazily");
}
#[test]
fn invalidate_removes_entry_and_counts() {
let cache = FileInfoCache::maybe_new(Duration::from_secs(60), 128).unwrap();
cache.insert("/i", info_of_length(1));
assert_eq!(cache.len(), 1);
cache.invalidate("/i");
assert_eq!(cache.len(), 0);
assert_eq!(cache.stats().invalidations, 1);
cache.invalidate("/i");
assert_eq!(cache.stats().invalidations, 1);
}
#[test]
fn clear_drops_all_and_counts_as_invalidations() {
let cache = FileInfoCache::maybe_new(Duration::from_secs(60), 128).unwrap();
for i in 0..5 {
cache.insert(&format!("/k{}", i), info_of_length(i as i64));
}
assert_eq!(cache.len(), 5);
cache.clear();
assert_eq!(cache.len(), 0);
assert_eq!(cache.stats().invalidations, 5);
}
#[test]
fn get_updates_lru_recency() {
let cache = FileInfoCache::maybe_new(Duration::from_secs(60), 2).unwrap();
cache.insert("/a", info_of_length(1));
cache.insert("/b", info_of_length(2));
assert!(cache.get("/a").is_some(), "warm /a to bump recency");
cache.insert("/c", info_of_length(3));
assert!(cache.get("/a").is_some(), "/a must survive as MRU");
assert!(cache.get("/b").is_none(), "/b must be evicted as LRU");
assert!(cache.get("/c").is_some());
}
#[test]
fn insert_arc_shares_arc_identity_with_get() {
let cache = FileInfoCache::maybe_new(Duration::from_secs(60), 128).unwrap();
let original = Arc::new(info_of_length(99));
cache.insert_arc("/shared", Arc::clone(&original));
let got = cache.get("/shared").expect("hit");
assert!(
Arc::ptr_eq(&original, &got),
"get must return the same Arc inserted via insert_arc"
);
assert_eq!(got.length, Some(99));
}
}