use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use dashmap::DashMap;
#[derive(Debug)]
struct CacheEntry<T> {
value: T,
expires_at: Instant,
}
impl<T> CacheEntry<T> {
fn new(value: T, ttl: Duration) -> Self {
Self {
value,
expires_at: Instant::now() + ttl,
}
}
fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
pub struct MetadataCache<K, V> {
entries: RwLock<HashMap<K, CacheEntry<V>>>,
ttl: Duration,
max_entries: usize,
}
impl<K: std::hash::Hash + Eq + Clone, V: Clone> MetadataCache<K, V> {
#[must_use]
pub fn new(ttl: Duration, max_entries: usize) -> Self {
Self {
entries: RwLock::new(HashMap::new()),
ttl,
max_entries,
}
}
#[must_use]
#[allow(clippy::significant_drop_tightening)]
pub fn get(&self, key: &K) -> Option<V> {
let entries = self.entries.read().ok()?;
let entry = entries.get(key)?;
if entry.is_expired() {
None
} else {
Some(entry.value.clone())
}
}
pub fn insert(&self, key: K, value: V) {
if let Ok(mut entries) = self.entries.write() {
if entries.len() >= self.max_entries {
entries.retain(|_, v| !v.is_expired());
}
entries.insert(key, CacheEntry::new(value, self.ttl));
}
}
pub fn remove(&self, key: &K) {
if let Ok(mut entries) = self.entries.write() {
entries.remove(key);
}
}
pub fn clear(&self) {
if let Ok(mut entries) = self.entries.write() {
entries.clear();
}
}
}
#[derive(Debug, Clone)]
pub struct NegativeCacheConfig {
pub max_entries: usize,
pub timeout: Duration,
}
impl Default for NegativeCacheConfig {
fn default() -> Self {
Self::new()
}
}
impl NegativeCacheConfig {
#[must_use]
pub const fn new() -> Self {
Self {
max_entries: 10_000,
timeout: Duration::from_secs(1),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NegativeCacheStats {
pub entries: usize,
pub hits: u64,
pub misses: u64,
}
impl NegativeCacheStats {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn hit_ratio(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
(self.hits as f64 / total as f64) * 100.0
}
}
}
pub struct NegativeCache {
entries: DashMap<PathBuf, Instant>,
config: NegativeCacheConfig,
hits: AtomicU64,
misses: AtomicU64,
}
impl NegativeCache {
#[must_use]
pub fn new(config: NegativeCacheConfig) -> Self {
Self {
entries: DashMap::with_capacity(config.max_entries),
config,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(NegativeCacheConfig::default())
}
pub fn contains(&self, path: &Path) -> bool {
if let Some(entry) = self.entries.get(path) {
let inserted_at = *entry;
if inserted_at.elapsed() < self.config.timeout {
self.hits.fetch_add(1, Ordering::Relaxed);
return true;
}
drop(entry); self.entries.remove(path);
}
self.misses.fetch_add(1, Ordering::Relaxed);
false
}
pub fn insert(&self, path: PathBuf) {
if self.entries.len() >= self.config.max_entries {
self.evict_expired();
}
self.entries.insert(path, Instant::now());
}
pub fn invalidate(&self, path: &Path) {
self.entries.remove(path);
if let Some(parent) = path.parent() {
self.entries.remove(parent);
}
}
pub fn evict_expired(&self) {
let timeout = self.config.timeout;
self.entries
.retain(|_, inserted_at| inserted_at.elapsed() < timeout);
}
#[must_use]
pub fn stats(&self) -> NegativeCacheStats {
NegativeCacheStats {
entries: self.entries.len(),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
}
}
pub fn clear(&self) {
self.entries.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl std::fmt::Debug for NegativeCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NegativeCache")
.field("entries", &self.entries.len())
.field("config", &self.config)
.field("hits", &self.hits.load(Ordering::Relaxed))
.field("misses", &self.misses.load(Ordering::Relaxed))
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_insert_and_contains() {
let cache = NegativeCache::with_defaults();
let path = PathBuf::from("/test/path");
assert!(!cache.contains(&path));
cache.insert(path.clone());
assert!(cache.contains(&path));
}
#[test]
fn test_expiration() {
let config = NegativeCacheConfig {
max_entries: 100,
timeout: Duration::from_millis(50),
};
let cache = NegativeCache::new(config);
let path = PathBuf::from("/test/expiring");
cache.insert(path.clone());
assert!(cache.contains(&path));
thread::sleep(Duration::from_millis(100));
assert!(!cache.contains(&path));
}
#[test]
fn test_invalidate() {
let cache = NegativeCache::with_defaults();
let path = PathBuf::from("/test/dir/file.txt");
cache.insert(path.clone());
assert!(cache.contains(&path));
cache.invalidate(&path);
assert!(!cache.contains(&path));
}
#[test]
fn test_invalidate_removes_parent() {
let cache = NegativeCache::with_defaults();
let parent = PathBuf::from("/test/dir");
let child = PathBuf::from("/test/dir/file.txt");
cache.insert(parent.clone());
cache.insert(child.clone());
cache.invalidate(&child);
assert!(!cache.contains(&child));
assert!(!cache.contains(&parent));
}
#[test]
fn test_concurrent_access() {
use std::sync::Arc;
let cache = Arc::new(NegativeCache::with_defaults());
let mut handles = vec![];
for i in 0..10 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
for j in 0..100 {
let path = PathBuf::from(format!("/thread_{i}/file_{j}"));
cache.insert(path.clone());
assert!(cache.contains(&path));
}
}));
}
for handle in handles {
handle.join().expect("Thread panicked");
}
assert!(cache.len() <= 1000);
}
#[test]
fn test_max_entries() {
let config = NegativeCacheConfig {
max_entries: 10,
timeout: Duration::from_millis(10), };
let cache = NegativeCache::new(config);
for i in 0..20 {
let path = PathBuf::from(format!("/file_{i}"));
cache.insert(path);
if i == 10 {
thread::sleep(Duration::from_millis(15));
}
}
assert!(cache.len() <= 20);
}
#[test]
fn test_stats() {
let cache = NegativeCache::with_defaults();
let path1 = PathBuf::from("/path1");
let path2 = PathBuf::from("/path2");
let stats = cache.stats();
assert_eq!(stats.entries, 0);
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
cache.contains(&path1);
let stats = cache.stats();
assert_eq!(stats.misses, 1);
cache.insert(path1.clone());
cache.contains(&path1);
let stats = cache.stats();
assert_eq!(stats.entries, 1);
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
cache.contains(&path2);
let stats = cache.stats();
assert_eq!(stats.misses, 2);
}
#[test]
fn test_hit_ratio() {
let stats = NegativeCacheStats {
entries: 10,
hits: 75,
misses: 25,
};
assert!((stats.hit_ratio() - 75.0).abs() < f64::EPSILON);
let empty_stats = NegativeCacheStats::default();
assert!((empty_stats.hit_ratio() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_clear() {
let cache = NegativeCache::with_defaults();
for i in 0..10 {
cache.insert(PathBuf::from(format!("/file_{i}")));
}
assert_eq!(cache.len(), 10);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_evict_expired() {
let config = NegativeCacheConfig {
max_entries: 100,
timeout: Duration::from_millis(30),
};
let cache = NegativeCache::new(config);
for i in 0..10 {
cache.insert(PathBuf::from(format!("/old_{i}")));
}
thread::sleep(Duration::from_millis(50));
for i in 0..5 {
cache.insert(PathBuf::from(format!("/new_{i}")));
}
cache.evict_expired();
assert_eq!(cache.len(), 5);
}
}