use crate::cache::{CacheKey, GraphNodeSummary};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheManifest {
pub bytes_by_language: HashMap<String, u64>,
pub sqry_version: String,
pub updated_at: SystemTime,
}
impl Default for CacheManifest {
fn default() -> Self {
Self {
bytes_by_language: HashMap::new(),
sqry_version: env!("CARGO_PKG_VERSION").to_string(),
updated_at: SystemTime::now(),
}
}
}
pub struct PersistManager {
cache_root: PathBuf,
user_namespace_id: String,
}
impl PersistManager {
pub fn new<P: AsRef<Path>>(cache_root: P) -> Result<Self> {
let cache_root = cache_root.as_ref().to_path_buf();
fs::create_dir_all(&cache_root).with_context(|| {
format!("Failed to create cache directory: {}", cache_root.display())
})?;
let user_namespace_id = Self::compute_user_hash();
let manager = Self {
cache_root,
user_namespace_id,
};
manager.cleanup_stale_locks()?;
Ok(manager)
}
fn compute_user_hash() -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "default".to_string());
let mut hasher = DefaultHasher::new();
username.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
#[must_use]
pub fn user_cache_dir(&self) -> PathBuf {
self.cache_root.join(&self.user_namespace_id)
}
fn entry_path(&self, key: &CacheKey) -> PathBuf {
let storage_key = key.storage_key();
self.user_cache_dir().join(format!("{storage_key}.bin"))
}
fn lock_path(&self, key: &CacheKey) -> PathBuf {
let mut path = self.entry_path(key);
path.set_extension("bin.lock");
path
}
pub fn write_entry(&self, key: &CacheKey, summaries: &[GraphNodeSummary]) -> Result<usize> {
let entry_path = self.entry_path(key);
let lock_path = self.lock_path(key);
if let Some(parent) = entry_path.parent() {
fs::create_dir_all(parent)?;
}
let _lock_guard = Self::acquire_lock(&lock_path)?;
let data = postcard::to_allocvec(summaries).context("Failed to serialize cache entry")?;
let tmp_cache_file_path = entry_path.with_extension("tmp");
{
let mut temp_file = fs::File::create(&tmp_cache_file_path).with_context(|| {
format!(
"Failed to create temp file: {}",
tmp_cache_file_path.display()
)
})?;
temp_file.write_all(&data)?;
temp_file.sync_all()?; }
#[cfg(windows)]
if entry_path.exists() {
fs::remove_file(&entry_path).with_context(|| {
format!("Failed to remove existing file: {}", entry_path.display())
})?;
}
match fs::rename(&tmp_cache_file_path, &entry_path) {
Ok(()) => {
log::debug!(
"Wrote cache entry: {} ({} bytes)",
entry_path.display(),
data.len()
);
Ok(data.len())
}
Err(e) => {
let _ = fs::remove_file(&tmp_cache_file_path);
Err(e).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_cache_file_path.display(),
entry_path.display()
)
})
}
}
}
pub fn read_entry(&self, key: &CacheKey) -> Result<Option<Vec<GraphNodeSummary>>> {
let entry_path = self.entry_path(key);
if !entry_path.exists() {
return Ok(None);
}
const MAX_CACHE_ENTRY_BYTES: u64 = 64 * 1024 * 1024; let metadata = fs::metadata(&entry_path)
.with_context(|| format!("Failed to stat cache entry: {}", entry_path.display()))?;
if metadata.len() > MAX_CACHE_ENTRY_BYTES {
anyhow::bail!(
"Cache entry is too large ({} bytes, max {}): {}",
metadata.len(),
MAX_CACHE_ENTRY_BYTES,
entry_path.display()
);
}
let data = fs::read(&entry_path)
.with_context(|| format!("Failed to read cache entry: {}", entry_path.display()))?;
let summaries: Vec<GraphNodeSummary> = postcard::from_bytes(&data).with_context(|| {
format!(
"Failed to deserialize cache entry: {}",
entry_path.display()
)
})?;
log::debug!(
"Read cache entry: {} ({} symbols)",
entry_path.display(),
summaries.len()
);
Ok(Some(summaries))
}
pub fn delete_entry(&self, key: &CacheKey) -> Result<()> {
let entry_path = self.entry_path(key);
let lock_path = self.lock_path(key);
if entry_path.exists() {
fs::remove_file(&entry_path).with_context(|| {
format!("Failed to delete cache entry: {}", entry_path.display())
})?;
}
if lock_path.exists() {
let _ = fs::remove_file(&lock_path); }
Ok(())
}
fn acquire_lock(lock_path: &Path) -> Result<LockGuard> {
let max_retries = 50;
let retry_delay = Duration::from_millis(100);
for attempt in 0..max_retries {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(mut file) => {
let pid = std::process::id();
writeln!(file, "{pid}")?;
file.sync_all()?;
return Ok(LockGuard {
path: lock_path.to_path_buf(),
});
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if Self::is_lock_stale(lock_path)? {
let _ = fs::remove_file(lock_path);
continue;
}
if attempt < max_retries - 1 {
std::thread::sleep(retry_delay);
} else {
anyhow::bail!(
"Failed to acquire lock after {} attempts: {}",
max_retries,
lock_path.display()
);
}
}
Err(e) => {
return Err(e).context("Failed to create lock file");
}
}
}
anyhow::bail!("Failed to acquire lock: {}", lock_path.display())
}
fn is_lock_stale(lock_path: &Path) -> Result<bool> {
let content = fs::read_to_string(lock_path)?;
let pid = content
.trim()
.parse::<u32>()
.context("Failed to parse PID from lock file")?;
if !Self::process_exists(pid) {
log::debug!("Process {pid} no longer exists, lock is stale");
return Ok(true);
}
let metadata = fs::metadata(lock_path)?;
let modified = metadata.modified()?;
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::ZERO);
if age > Duration::from_secs(300) {
log::warn!(
"Lock held by PID {} for {:?} - forcing cleanup: {}",
pid,
age,
lock_path.display()
);
return Ok(true);
}
Ok(false)
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
let proc_path = format!("/proc/{pid}");
std::path::Path::new(&proc_path).exists()
}
#[cfg(not(target_os = "linux"))]
{
use nix::sys::signal::kill;
use nix::unistd::Pid;
match kill(Pid::from_raw(pid as i32), None) {
Ok(_) => true, Err(_) => false, }
}
}
#[cfg(not(unix))]
fn process_exists(_pid: u32) -> bool {
true
}
fn cleanup_stale_locks(&self) -> Result<()> {
let user_dir = self.user_cache_dir();
if !user_dir.exists() {
return Ok(()); }
let walker = walkdir::WalkDir::new(&user_dir)
.max_depth(10)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "lock")
});
let mut cleaned = 0;
for entry in walker {
let path = entry.path();
if Self::is_lock_stale(path)? {
if let Err(e) = fs::remove_file(path) {
log::warn!("Failed to remove stale lock {}: {}", path.display(), e);
} else {
log::debug!("Removed stale lock: {}", path.display());
cleaned += 1;
}
}
}
if cleaned > 0 {
log::info!("Cleaned up {cleaned} stale lock files");
}
Ok(())
}
pub fn clear_all(&self) -> Result<()> {
let user_dir = self.user_cache_dir();
if user_dir.exists() {
fs::remove_dir_all(&user_dir).with_context(|| {
format!("Failed to remove cache directory: {}", user_dir.display())
})?;
log::info!("Cleared all cache entries in {}", user_dir.display());
}
Ok(())
}
}
struct LockGuard {
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
if let Err(e) = fs::remove_file(&self.path) {
log::warn!("Failed to remove lock file {}: {}", self.path.display(), e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::CacheKey;
use crate::graph::unified::node::NodeKind;
use crate::hash::Blake3Hash;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tempfile::TempDir;
fn make_test_key() -> CacheKey {
let hash = Blake3Hash::from_bytes([42; 32]);
CacheKey::from_raw_path(PathBuf::from("/test/file.rs"), "rust", hash)
}
fn make_test_summary() -> GraphNodeSummary {
GraphNodeSummary::new(
Arc::from("test_fn"),
NodeKind::Function,
Arc::from(Path::new("test.rs")),
1,
0,
1,
10,
)
}
#[test]
fn test_persist_manager_new() {
let tmp_cache_dir = TempDir::new().unwrap();
let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
assert!(tmp_cache_dir.path().exists());
assert!(!manager.user_namespace_id.is_empty());
}
#[test]
fn test_write_and_read_entry() {
let tmp_cache_dir = TempDir::new().unwrap();
let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
let key = make_test_key();
let summaries = vec![make_test_summary()];
let bytes_written = manager.write_entry(&key, &summaries).unwrap();
assert!(bytes_written > 0);
let read_summaries = manager.read_entry(&key).unwrap().unwrap();
assert_eq!(read_summaries.len(), 1);
assert_eq!(read_summaries[0].name, summaries[0].name);
}
#[test]
fn test_read_nonexistent_entry() {
let tmp_cache_dir = TempDir::new().unwrap();
let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
let key = make_test_key();
let result = manager.read_entry(&key).unwrap();
assert!(result.is_none());
}
#[test]
fn test_delete_entry() {
let tmp_cache_dir = TempDir::new().unwrap();
let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
let key = make_test_key();
let summaries = vec![make_test_summary()];
manager.write_entry(&key, &summaries).unwrap();
assert!(manager.read_entry(&key).unwrap().is_some());
manager.delete_entry(&key).unwrap();
assert!(manager.read_entry(&key).unwrap().is_none());
}
#[test]
fn test_clear_all() {
let tmp_cache_dir = TempDir::new().unwrap();
let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
let key = make_test_key();
let summaries = vec![make_test_summary()];
manager.write_entry(&key, &summaries).unwrap();
assert!(manager.read_entry(&key).unwrap().is_some());
manager.clear_all().unwrap();
assert!(manager.read_entry(&key).unwrap().is_none());
}
}