Skip to main content

sqry_core/cache/
persist.rs

1//! Disk persistence for cache entries.
2//!
3//! This module provides atomic disk persistence with crash safety and multi-process
4//! coordination. Cache entries are stored in `.sqry-cache/` with the following layout:
5//!
6//! ```text
7//! .sqry-cache/
8//! ├── <user_namespace_id>/               # Per-user namespace (hash of $USER)
9//! │   ├── rust/                          # Language-specific directory
10//! │   │   ├── <content_hash>/            # BLAKE3 of file content (64 hex chars)
11//! │   │   │   ├── <path_hash>/           # BLAKE3 of canonical path (16 hex chars)
12//! │   │   │   │   ├── filename.rs.bin       # Cached symbols
13//! │   │   │   │   └── filename.rs.bin.lock  # Write lock
14//! │   ├── python/
15//! │   ├── typescript/
16//! │   └── manifest.json                   # Size tracking (Phase 3)
17//! ```
18//!
19//! # Features
20//!
21//! - **Atomic writes**: Temp file + `fs::rename` for crash safety
22//! - **Multi-process safe**: Per-entry lock files prevent corruption
23//! - **Stale lock cleanup**: Removes locks from dead processes
24//! - **Size tracking**: Manifest tracks total bytes per language
25//!
26//! # Examples
27//!
28//! ```rust,ignore
29//! use sqry_core::cache::persist::PersistManager;
30//!
31//! let manager = PersistManager::new("/path/to/.sqry-cache")?;
32//!
33//! // Write entry
34//! manager.write_entry(&key, &summaries)?;
35//!
36//! // Read entry
37//! if let Some(summaries) = manager.read_entry(&key)? {
38//!     // Use cached data
39//! }
40//! ```
41
42use crate::cache::{CacheKey, GraphNodeSummary};
43use anyhow::{Context, Result};
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::fs;
47use std::io::Write;
48use std::path::{Path, PathBuf};
49use std::time::{Duration, SystemTime};
50
51/// Cache manifest for tracking size and metadata.
52///
53/// **Status**: Defined but not yet used. Full implementation planned for Phase 3
54/// (manifest management, disk size tracking, and enforcement).
55///
56/// When implemented, the manifest will be stored as `manifest.json` in the cache
57/// root directory and used to:
58/// - Track total disk usage per language
59/// - Enforce disk size limits with LRU eviction
60/// - Provide cache statistics for CLI tooling
61///
62/// See [`docs/development/cache/PHASE_2_COMPLETE.md`](../../../docs/development/cache/PHASE_2_COMPLETE.md)
63/// for Phase 3 roadmap.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CacheManifest {
66    /// Total bytes used per language
67    pub bytes_by_language: HashMap<String, u64>,
68
69    /// sqry version that wrote this manifest
70    pub sqry_version: String,
71
72    /// Last updated timestamp
73    pub updated_at: SystemTime,
74}
75
76impl Default for CacheManifest {
77    fn default() -> Self {
78        Self {
79            bytes_by_language: HashMap::new(),
80            sqry_version: env!("CARGO_PKG_VERSION").to_string(),
81            updated_at: SystemTime::now(),
82        }
83    }
84}
85
86/// Persistence manager for cache entries.
87///
88/// Handles atomic writes, lock management, and manifest tracking.
89pub struct PersistManager {
90    /// Root cache directory (e.g., `.sqry-cache/`)
91    cache_root: PathBuf,
92
93    /// User-specific namespace hash (subdirectory under `cache_root`)
94    user_namespace_id: String,
95}
96
97impl PersistManager {
98    /// Create a new persistence manager.
99    ///
100    /// # Arguments
101    ///
102    /// - `cache_root`: Root directory for cache storage
103    ///
104    /// # Examples
105    ///
106    /// ```rust,ignore
107    /// let manager = PersistManager::new(".sqry-cache")?;
108    /// ```
109    ///
110    /// # Errors
111    ///
112    /// Returns [`anyhow::Error`] when the cache directories cannot be created or when stale
113    /// lock cleanup fails.
114    pub fn new<P: AsRef<Path>>(cache_root: P) -> Result<Self> {
115        let cache_root = cache_root.as_ref().to_path_buf();
116
117        // Create cache directory if it doesn't exist
118        fs::create_dir_all(&cache_root).with_context(|| {
119            format!("Failed to create cache directory: {}", cache_root.display())
120        })?;
121
122        // Generate user-specific namespace
123        let user_namespace_id = Self::compute_user_hash();
124
125        let manager = Self {
126            cache_root,
127            user_namespace_id,
128        };
129
130        // Clean up stale locks on initialization
131        manager.cleanup_stale_locks()?;
132
133        Ok(manager)
134    }
135
136    /// Compute a hash of the current user for namespacing.
137    ///
138    /// Uses the current username to create a subdirectory under cache root.
139    /// This prevents collisions when multiple users share the same filesystem.
140    fn compute_user_hash() -> String {
141        use std::collections::hash_map::DefaultHasher;
142        use std::hash::{Hash, Hasher};
143
144        let username = std::env::var("USER")
145            .or_else(|_| std::env::var("USERNAME"))
146            .unwrap_or_else(|_| "default".to_string());
147
148        let mut hasher = DefaultHasher::new();
149        username.hash(&mut hasher);
150        format!("{:x}", hasher.finish())
151    }
152
153    /// Get the user-specific cache directory.
154    ///
155    /// Returns the path where this user's cache entries are stored.
156    #[must_use]
157    pub fn user_cache_dir(&self) -> PathBuf {
158        self.cache_root.join(&self.user_namespace_id)
159    }
160
161    /// Get the file path for a cache entry.
162    ///
163    /// Format: `<user_namespace_id>/<language>/<hash>/<filename>.bin`
164    fn entry_path(&self, key: &CacheKey) -> PathBuf {
165        let storage_key = key.storage_key();
166        self.user_cache_dir().join(format!("{storage_key}.bin"))
167    }
168
169    /// Get the lock file path for a cache entry.
170    fn lock_path(&self, key: &CacheKey) -> PathBuf {
171        let mut path = self.entry_path(key);
172        path.set_extension("bin.lock");
173        path
174    }
175
176    /// Write a cache entry to disk atomically.
177    ///
178    /// Uses temp file + rename pattern for crash safety.
179    ///
180    /// # Arguments
181    ///
182    /// - `key`: Cache key identifying the entry
183    /// - `summaries`: Node summaries to persist
184    ///
185    /// # Returns
186    ///
187    /// `Ok(bytes_written)` on success
188    ///
189    /// # Errors
190    ///
191    /// Returns [`anyhow::Error`] when locking, serialization, writing, or atomic rename steps fail.
192    pub fn write_entry(&self, key: &CacheKey, summaries: &[GraphNodeSummary]) -> Result<usize> {
193        let entry_path = self.entry_path(key);
194        let lock_path = self.lock_path(key);
195
196        // Create parent directories
197        if let Some(parent) = entry_path.parent() {
198            fs::create_dir_all(parent)?;
199        }
200
201        // Acquire lock
202        let _lock_guard = Self::acquire_lock(&lock_path)?;
203
204        // Serialize to postcard
205        let data = postcard::to_allocvec(summaries).context("Failed to serialize cache entry")?;
206
207        // Write to temporary file
208        let tmp_cache_file_path = entry_path.with_extension("tmp");
209        {
210            let mut temp_file = fs::File::create(&tmp_cache_file_path).with_context(|| {
211                format!(
212                    "Failed to create temp file: {}",
213                    tmp_cache_file_path.display()
214                )
215            })?;
216
217            temp_file.write_all(&data)?;
218            temp_file.sync_all()?; // Ensure data is flushed
219        } // Close file handle before rename (required on Windows)
220
221        // On Windows, remove destination file first if it exists
222        // (Windows doesn't allow renaming over existing files with open handles)
223        #[cfg(windows)]
224        if entry_path.exists() {
225            fs::remove_file(&entry_path).with_context(|| {
226                format!("Failed to remove existing file: {}", entry_path.display())
227            })?;
228        }
229
230        // Atomic rename
231        match fs::rename(&tmp_cache_file_path, &entry_path) {
232            Ok(()) => {
233                log::debug!(
234                    "Wrote cache entry: {} ({} bytes)",
235                    entry_path.display(),
236                    data.len()
237                );
238                Ok(data.len())
239            }
240            Err(e) => {
241                // Clean up temp file on failure
242                let _ = fs::remove_file(&tmp_cache_file_path);
243                Err(e).with_context(|| {
244                    format!(
245                        "Failed to rename {} to {}",
246                        tmp_cache_file_path.display(),
247                        entry_path.display()
248                    )
249                })
250            }
251        }
252    }
253
254    /// Read a cache entry from disk.
255    ///
256    /// # Returns
257    ///
258    /// - `Ok(Some(summaries))` if entry exists and is valid
259    /// - `Ok(None)` if entry doesn't exist
260    /// - `Err(_)` on I/O or deserialization errors
261    /// # Errors
262    ///
263    /// Returns [`anyhow::Error`] when the entry cannot be read or deserialized.
264    pub fn read_entry(&self, key: &CacheKey) -> Result<Option<Vec<GraphNodeSummary>>> {
265        let entry_path = self.entry_path(key);
266
267        if !entry_path.exists() {
268            return Ok(None);
269        }
270
271        let data = fs::read(&entry_path)
272            .with_context(|| format!("Failed to read cache entry: {}", entry_path.display()))?;
273
274        let summaries: Vec<GraphNodeSummary> = postcard::from_bytes(&data).with_context(|| {
275            format!(
276                "Failed to deserialize cache entry: {}",
277                entry_path.display()
278            )
279        })?;
280
281        log::debug!(
282            "Read cache entry: {} ({} symbols)",
283            entry_path.display(),
284            summaries.len()
285        );
286
287        Ok(Some(summaries))
288    }
289
290    /// Delete a cache entry from disk.
291    /// # Errors
292    ///
293    /// Returns [`anyhow::Error`] when either the entry or its lock file cannot be removed.
294    pub fn delete_entry(&self, key: &CacheKey) -> Result<()> {
295        let entry_path = self.entry_path(key);
296        let lock_path = self.lock_path(key);
297
298        // Remove entry file
299        if entry_path.exists() {
300            fs::remove_file(&entry_path).with_context(|| {
301                format!("Failed to delete cache entry: {}", entry_path.display())
302            })?;
303        }
304
305        // Remove lock file if it exists
306        if lock_path.exists() {
307            let _ = fs::remove_file(&lock_path); // Best effort
308        }
309
310        Ok(())
311    }
312
313    /// Acquire a write lock for an entry.
314    ///
315    /// Creates a lock file to prevent concurrent writes from other processes.
316    /// Returns a guard that automatically removes the lock on drop.
317    fn acquire_lock(lock_path: &Path) -> Result<LockGuard> {
318        // Try to create lock file (exclusive creation)
319        // If it already exists, wait and retry
320        let max_retries = 50;
321        let retry_delay = Duration::from_millis(100);
322
323        for attempt in 0..max_retries {
324            match fs::OpenOptions::new()
325                .write(true)
326                .create_new(true)
327                .open(lock_path)
328            {
329                Ok(mut file) => {
330                    // Write our PID to the lock file
331                    let pid = std::process::id();
332                    writeln!(file, "{pid}")?;
333                    file.sync_all()?;
334
335                    return Ok(LockGuard {
336                        path: lock_path.to_path_buf(),
337                    });
338                }
339                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
340                    // Lock exists, check if it's stale
341                    if Self::is_lock_stale(lock_path)? {
342                        // Remove stale lock and retry
343                        let _ = fs::remove_file(lock_path);
344                        continue;
345                    }
346
347                    // Lock is active, wait and retry
348                    if attempt < max_retries - 1 {
349                        std::thread::sleep(retry_delay);
350                    } else {
351                        anyhow::bail!(
352                            "Failed to acquire lock after {} attempts: {}",
353                            max_retries,
354                            lock_path.display()
355                        );
356                    }
357                }
358                Err(e) => {
359                    return Err(e).context("Failed to create lock file");
360                }
361            }
362        }
363
364        anyhow::bail!("Failed to acquire lock: {}", lock_path.display())
365    }
366
367    /// Check if a lock file is stale (process no longer exists).
368    ///
369    /// First checks if the process exists (immediate recovery from crashes).
370    /// If process exists, verifies lock age as a safety check against hung processes.
371    fn is_lock_stale(lock_path: &Path) -> Result<bool> {
372        // Try to read PID from lock file
373        let content = fs::read_to_string(lock_path)?;
374        let pid = content
375            .trim()
376            .parse::<u32>()
377            .context("Failed to parse PID from lock file")?;
378
379        // Check if process exists (immediate crash recovery)
380        if !Self::process_exists(pid) {
381            log::debug!("Process {pid} no longer exists, lock is stale");
382            return Ok(true);
383        }
384
385        // Process exists - check age as secondary safety check
386        let metadata = fs::metadata(lock_path)?;
387        let modified = metadata.modified()?;
388        let age = SystemTime::now()
389            .duration_since(modified)
390            .unwrap_or(Duration::ZERO);
391
392        // If lock is older than 5 minutes, force cleanup (hung process)
393        if age > Duration::from_secs(300) {
394            log::warn!(
395                "Lock held by PID {} for {:?} - forcing cleanup: {}",
396                pid,
397                age,
398                lock_path.display()
399            );
400            return Ok(true);
401        }
402
403        Ok(false)
404    }
405
406    /// Check if a process with the given PID exists.
407    ///
408    /// Uses platform-specific methods:
409    /// - Linux: Check /proc/<pid> directory
410    /// - macOS/BSD: Use kill(pid, 0) signal probe via nix crate
411    /// - Windows: Always returns true (no portable check available)
412    #[cfg(unix)]
413    fn process_exists(pid: u32) -> bool {
414        #[cfg(target_os = "linux")]
415        {
416            // Linux: Check /proc/<pid> directory
417            let proc_path = format!("/proc/{pid}");
418            std::path::Path::new(&proc_path).exists()
419        }
420
421        #[cfg(not(target_os = "linux"))]
422        {
423            // macOS/BSD: Use kill(pid, 0) signal probe
424            // Sending signal 0 checks if the process exists without actually sending a signal
425            use nix::sys::signal::kill;
426            use nix::unistd::Pid;
427
428            match kill(Pid::from_raw(pid as i32), None) {
429                Ok(_) => true,   // Process exists
430                Err(_) => false, // Process doesn't exist or no permission
431            }
432        }
433    }
434
435    #[cfg(not(unix))]
436    fn process_exists(_pid: u32) -> bool {
437        // On non-Unix, we can't reliably check process existence
438        // Conservative approach: assume it exists
439        true
440    }
441
442    /// Clean up stale lock files on initialization.
443    fn cleanup_stale_locks(&self) -> Result<()> {
444        let user_dir = self.user_cache_dir();
445
446        if !user_dir.exists() {
447            return Ok(()); // No cache directory yet
448        }
449
450        // Find all .lock files
451        let walker = walkdir::WalkDir::new(&user_dir)
452            .max_depth(10)
453            .into_iter()
454            .filter_map(std::result::Result::ok)
455            .filter(|e| {
456                e.path()
457                    .extension()
458                    .and_then(|ext| ext.to_str())
459                    .is_some_and(|ext| ext == "lock")
460            });
461
462        let mut cleaned = 0;
463        for entry in walker {
464            let path = entry.path();
465
466            if Self::is_lock_stale(path)? {
467                if let Err(e) = fs::remove_file(path) {
468                    log::warn!("Failed to remove stale lock {}: {}", path.display(), e);
469                } else {
470                    log::debug!("Removed stale lock: {}", path.display());
471                    cleaned += 1;
472                }
473            }
474        }
475
476        if cleaned > 0 {
477            log::info!("Cleaned up {cleaned} stale lock files");
478        }
479
480        Ok(())
481    }
482
483    /// Clear all cache entries for this user.
484    /// # Errors
485    ///
486    /// Returns [`anyhow::Error`] when directory traversal or entry deletion fails.
487    pub fn clear_all(&self) -> Result<()> {
488        let user_dir = self.user_cache_dir();
489
490        if user_dir.exists() {
491            fs::remove_dir_all(&user_dir).with_context(|| {
492                format!("Failed to remove cache directory: {}", user_dir.display())
493            })?;
494
495            log::info!("Cleared all cache entries in {}", user_dir.display());
496        }
497
498        Ok(())
499    }
500}
501
502/// RAII guard for lock files.
503///
504/// Automatically removes the lock file when dropped.
505struct LockGuard {
506    path: PathBuf,
507}
508
509impl Drop for LockGuard {
510    fn drop(&mut self) {
511        // Best effort removal - log error but don't panic
512        if let Err(e) = fs::remove_file(&self.path) {
513            log::warn!("Failed to remove lock file {}: {}", self.path.display(), e);
514        }
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::cache::CacheKey;
522    use crate::graph::unified::node::NodeKind;
523    use crate::hash::Blake3Hash;
524    use std::path::{Path, PathBuf};
525    use std::sync::Arc;
526    use tempfile::TempDir;
527
528    fn make_test_key() -> CacheKey {
529        let hash = Blake3Hash::from_bytes([42; 32]);
530        CacheKey::from_raw_path(PathBuf::from("/test/file.rs"), "rust", hash)
531    }
532
533    fn make_test_summary() -> GraphNodeSummary {
534        GraphNodeSummary::new(
535            Arc::from("test_fn"),
536            NodeKind::Function,
537            Arc::from(Path::new("test.rs")),
538            1,
539            0,
540            1,
541            10,
542        )
543    }
544
545    #[test]
546    fn test_persist_manager_new() {
547        let tmp_cache_dir = TempDir::new().unwrap();
548        let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
549
550        // Cache root should exist
551        assert!(tmp_cache_dir.path().exists());
552        // User directory path should be valid (may not exist until first write)
553        assert!(!manager.user_namespace_id.is_empty());
554    }
555
556    #[test]
557    fn test_write_and_read_entry() {
558        let tmp_cache_dir = TempDir::new().unwrap();
559        let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
560
561        let key = make_test_key();
562        let summaries = vec![make_test_summary()];
563
564        // Write entry
565        let bytes_written = manager.write_entry(&key, &summaries).unwrap();
566        assert!(bytes_written > 0);
567
568        // Read entry back
569        let read_summaries = manager.read_entry(&key).unwrap().unwrap();
570        assert_eq!(read_summaries.len(), 1);
571        assert_eq!(read_summaries[0].name, summaries[0].name);
572    }
573
574    #[test]
575    fn test_read_nonexistent_entry() {
576        let tmp_cache_dir = TempDir::new().unwrap();
577        let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
578
579        let key = make_test_key();
580        let result = manager.read_entry(&key).unwrap();
581
582        assert!(result.is_none());
583    }
584
585    #[test]
586    fn test_delete_entry() {
587        let tmp_cache_dir = TempDir::new().unwrap();
588        let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
589
590        let key = make_test_key();
591        let summaries = vec![make_test_summary()];
592
593        // Write entry
594        manager.write_entry(&key, &summaries).unwrap();
595        assert!(manager.read_entry(&key).unwrap().is_some());
596
597        // Delete entry
598        manager.delete_entry(&key).unwrap();
599        assert!(manager.read_entry(&key).unwrap().is_none());
600    }
601
602    #[test]
603    fn test_clear_all() {
604        let tmp_cache_dir = TempDir::new().unwrap();
605        let manager = PersistManager::new(tmp_cache_dir.path()).unwrap();
606
607        let key = make_test_key();
608        let summaries = vec![make_test_summary()];
609
610        // Write entry
611        manager.write_entry(&key, &summaries).unwrap();
612        assert!(manager.read_entry(&key).unwrap().is_some());
613
614        // Clear all
615        manager.clear_all().unwrap();
616        assert!(manager.read_entry(&key).unwrap().is_none());
617    }
618}