use crate::analyze::{AnalysisOutput, FileAnalysisOutput, FocusedAnalysisOutput};
use crate::traversal::WalkEntry;
use crate::types::{AnalysisMode, SymbolMatchMode};
use lru::LruCache;
use rayon::prelude::*;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tracing::{debug, instrument, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheTier {
L1Memory,
L2Disk,
Miss,
L1OnlyMiss,
L1L2Miss,
}
#[must_use]
pub fn parse_cache_capacity(env_key: &str, default: usize) -> usize {
std::env::var(env_key)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(default)
.max(1)
}
impl CacheTier {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
CacheTier::L1Memory => "l1_memory",
CacheTier::L2Disk => "l2_disk",
CacheTier::Miss => "miss",
CacheTier::L1OnlyMiss => "l1_only_miss",
CacheTier::L1L2Miss => "l1_l2_miss",
}
}
#[must_use]
pub fn is_hit(&self) -> bool {
matches!(self, CacheTier::L1Memory | CacheTier::L2Disk)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct CacheKey {
pub path: PathBuf,
pub modified: SystemTime,
pub mode: AnalysisMode,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct DirectoryCacheKey {
files: Vec<(PathBuf, SystemTime)>,
mode: AnalysisMode,
max_depth: Option<u32>,
git_ref: Option<String>,
}
impl DirectoryCacheKey {
#[must_use]
pub fn from_entries(
entries: &[WalkEntry],
max_depth: Option<u32>,
mode: AnalysisMode,
git_ref: Option<&str>,
) -> Self {
let mut files: Vec<(PathBuf, SystemTime)> = entries
.par_iter()
.filter(|e| !e.is_dir)
.map(|e| {
let mtime = e.mtime.unwrap_or(SystemTime::UNIX_EPOCH);
(e.path.clone(), mtime)
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
Self {
files,
mode,
max_depth,
git_ref: git_ref.map(ToOwned::to_owned),
}
}
}
#[allow(clippy::expect_used)]
const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
NonZeroUsize::new(100).expect("100 is non-zero");
fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
where
K: std::hash::Hash + Eq,
F: FnOnce(&mut LruCache<K, V>) -> T,
{
match mutex.lock() {
Ok(mut guard) => recovery(&mut guard),
Err(poisoned) => {
tracing::warn!("Mutex poisoned in lock_or_recover; creating fresh LruCache");
let cache_size = NonZeroUsize::new(capacity).unwrap_or(DEFAULT_LOCK_RECOVER_CAPACITY);
let new_cache = LruCache::new(cache_size);
let mut guard = poisoned.into_inner();
*guard = new_cache;
recovery(&mut guard)
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct CallGraphCacheKey {
root_path: PathBuf,
git_ref: Option<String>,
follow_depth: u32,
match_mode: SymbolMatchMode,
impl_only: bool,
ast_recursion_limit: Option<usize>,
file_mtimes: Vec<(PathBuf, u64)>,
}
impl CallGraphCacheKey {
#[must_use]
pub fn from_entries(
root: &std::path::Path,
entries: &[WalkEntry],
git_ref: Option<&str>,
follow_depth: u32,
match_mode: &SymbolMatchMode,
impl_only: bool,
ast_recursion_limit: Option<usize>,
) -> Self {
let mut file_mtimes: Vec<(PathBuf, u64)> = entries
.par_iter()
.filter(|e| !e.is_dir)
.map(|e| {
let mtime = e
.mtime
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
(e.path.clone(), mtime)
})
.collect();
file_mtimes.sort_by(|a, b| a.0.cmp(&b.0));
Self {
root_path: root.to_path_buf(),
git_ref: git_ref.map(ToOwned::to_owned),
follow_depth,
match_mode: match_mode.clone(),
impl_only,
ast_recursion_limit,
file_mtimes,
}
}
}
pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
pub struct CallGraphCache {
capacity: usize,
cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
eviction_count: Arc<AtomicU64>,
}
impl CallGraphCache {
#[must_use]
pub fn new(capacity: usize) -> Self {
let capacity = capacity.max(1);
#[allow(clippy::expect_used)]
let cache_size = NonZeroUsize::new(capacity).expect("capacity is non-zero after .max(1)");
Self {
capacity,
cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
eviction_count: Arc::new(AtomicU64::new(0)),
}
}
#[must_use]
pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
lock_or_recover(&self.cache, self.capacity, |guard| guard.get(key).cloned())
}
pub fn put(&self, key: CallGraphCacheKey, value: CallGraphCacheValue) {
lock_or_recover(&self.cache, self.capacity, |guard| {
if guard.len() >= self.capacity {
self.eviction_count.fetch_add(1, Ordering::Relaxed);
}
guard.put(key, value);
});
}
#[must_use]
pub fn eviction_count(&self) -> u64 {
self.eviction_count.load(Ordering::Relaxed)
}
}
impl Clone for CallGraphCache {
fn clone(&self) -> Self {
Self {
capacity: self.capacity,
cache: Arc::clone(&self.cache),
eviction_count: Arc::clone(&self.eviction_count),
}
}
}
pub struct AnalysisCache {
file_capacity: usize,
dir_capacity: usize,
cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
eviction_count: Arc<AtomicU64>,
}
impl AnalysisCache {
#[must_use]
pub fn new(capacity: usize) -> Self {
let file_capacity = capacity.max(1);
let dir_capacity = parse_cache_capacity("APTU_CODER_DIR_CACHE_CAPACITY", 20);
#[allow(clippy::expect_used)]
let cache_size =
NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
#[allow(clippy::expect_used)]
let dir_cache_size =
NonZeroUsize::new(dir_capacity).expect("dir_capacity is non-zero after .max(1)");
Self {
file_capacity,
dir_capacity,
cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
eviction_count: Arc::new(AtomicU64::new(0)),
}
}
#[instrument(skip(self), fields(path = ?key.path))]
pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
lock_or_recover(&self.cache, self.file_capacity, |guard| {
let result = guard.get(key).cloned();
let cache_size = guard.len();
if let Some(v) = result {
debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
Some(v)
} else {
debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
None
}
})
}
#[instrument(skip(self, value), fields(path = ?key.path))]
#[allow(clippy::needless_pass_by_value)]
pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
lock_or_recover(&self.cache, self.file_capacity, |guard| {
let push_result = guard.push(key.clone(), value);
let cache_size = guard.len();
match push_result {
None => {
debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
}
Some((returned_key, _)) => {
if returned_key == key {
debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
} else {
debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
self.eviction_count.fetch_add(1, Ordering::Relaxed);
}
}
}
});
}
#[instrument(skip(self))]
pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
let result = guard.get(key).cloned();
let cache_size = guard.len();
if let Some(v) = result {
debug!(cache_event = "hit", cache_size = cache_size);
Some(v)
} else {
debug!(cache_event = "miss", cache_size = cache_size);
None
}
})
}
#[instrument(skip(self, value))]
pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
let push_result = guard.push(key, value);
let cache_size = guard.len();
match push_result {
None => {
debug!(cache_event = "insert", cache_size = cache_size);
}
Some((_, _)) => {
debug!(cache_event = "eviction", cache_size = cache_size);
}
}
});
}
#[doc(hidden)]
#[must_use]
pub fn file_capacity(&self) -> usize {
self.file_capacity
}
#[instrument(skip(self), fields(path = ?path))]
pub fn invalidate_file(&self, path: &std::path::Path) {
lock_or_recover(&self.cache, self.file_capacity, |guard| {
let keys: Vec<CacheKey> = guard
.iter()
.filter(|(k, _)| k.path == path)
.map(|(k, _)| k.clone())
.collect();
for key in keys {
guard.pop(&key);
}
let cache_size = guard.len();
debug!(cache_event = "invalidate_file", cache_size = cache_size, path = ?path);
});
}
#[must_use]
pub fn eviction_count(&self) -> u64 {
self.eviction_count.load(Ordering::Relaxed)
}
}
impl Clone for AnalysisCache {
fn clone(&self) -> Self {
Self {
file_capacity: self.file_capacity,
dir_capacity: self.dir_capacity,
cache: Arc::clone(&self.cache),
directory_cache: Arc::clone(&self.directory_cache),
eviction_count: Arc::clone(&self.eviction_count),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::SemanticAnalysis;
#[test]
fn test_from_entries_skips_dirs() {
let dir = tempfile::tempdir().expect("tempdir");
let file = tempfile::NamedTempFile::new_in(dir.path()).expect("tempfile");
let file_path = file.path().to_path_buf();
let entries = vec![
WalkEntry {
path: dir.path().to_path_buf(),
depth: 0,
is_dir: true,
is_symlink: false,
symlink_target: None,
mtime: None,
canonical_path: PathBuf::new(),
},
WalkEntry {
path: file_path.clone(),
depth: 0,
is_dir: false,
is_symlink: false,
symlink_target: None,
mtime: None,
canonical_path: PathBuf::new(),
},
];
let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
assert_eq!(key.files.len(), 1);
assert_eq!(key.files[0].0, file_path);
}
#[test]
fn test_invalidate_file_single_mode() {
let cache = AnalysisCache::new(10);
let path = PathBuf::from("/test/file.rs");
let key = CacheKey {
path: path.clone(),
modified: SystemTime::UNIX_EPOCH,
mode: AnalysisMode::Overview,
};
let output = Arc::new(FileAnalysisOutput::new(
String::new(),
SemanticAnalysis::default(),
0,
None,
));
cache.put(key.clone(), output);
cache.invalidate_file(&path);
assert!(cache.get(&key).is_none());
}
#[test]
fn test_invalidate_file_multi_mode() {
let cache = AnalysisCache::new(10);
let path = PathBuf::from("/test/file.rs");
let key1 = CacheKey {
path: path.clone(),
modified: SystemTime::UNIX_EPOCH,
mode: AnalysisMode::Overview,
};
let key2 = CacheKey {
path: path.clone(),
modified: SystemTime::UNIX_EPOCH,
mode: AnalysisMode::FileDetails,
};
let output = Arc::new(FileAnalysisOutput::new(
String::new(),
SemanticAnalysis::default(),
0,
None,
));
cache.put(key1.clone(), output.clone());
cache.put(key2.clone(), output);
cache.invalidate_file(&path);
assert!(cache.get(&key1).is_none());
assert!(cache.get(&key2).is_none());
}
static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_dir_cache_capacity_default() {
let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
let cache = AnalysisCache::new(100);
assert_eq!(cache.dir_capacity, 20);
}
#[test]
fn test_dir_cache_capacity_from_env() {
let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
let cache = AnalysisCache::new(100);
unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
assert_eq!(cache.dir_capacity, 7);
}
static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_parse_cache_capacity_missing_returns_default() {
let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
assert_eq!(result, 42);
}
#[test]
fn test_parse_cache_capacity_valid_returns_value() {
let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
assert_eq!(result, 64);
}
#[test]
fn test_parse_cache_capacity_zero_returns_one() {
let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
assert_eq!(result, 1);
}
#[test]
fn test_parse_cache_capacity_garbage_returns_default() {
let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
assert_eq!(result, 8);
}
}
pub use crate::cache_disk::DiskCache;