Skip to main content

aptu_coder_core/
cache_disk.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Disk-based cache for analysis results.
4//!
5//! Provides persistent, file-backed caching of analysis outputs with atomic writes,
6//! per-shard locking, and stale-file eviction.
7
8use fs2::FileExt;
9use serde::{Serialize, de::DeserializeOwned};
10use std::io::{Read, Write};
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicU64, Ordering};
15use tempfile::NamedTempFile;
16use tracing::{error, warn};
17
18/// Threshold at which cumulative disk cache write failures trigger an alert.
19const DISK_CACHE_DEGRADED_THRESHOLD: u64 = 100;
20
21/// Persistent disk cache for analysis results.
22///
23/// Stores serialized analysis outputs in a directory hierarchy, with per-shard
24/// advisory locking to prevent concurrent writes to the same entry. Supports
25/// atomic writes via `NamedTempFile::persist` and graceful degradation on I/O errors.
26pub struct DiskCache {
27    base: PathBuf,
28    disabled: bool,
29    /// Counts write failures since last drain. Incremented inside `put` on any I/O error.
30    write_failures: AtomicU64,
31    /// Cumulative write failures across all drains. Never reset; used for threshold checks.
32    total_write_failures: AtomicU64,
33    /// Number of entries currently in the disk cache.
34    entry_count: AtomicU64,
35    /// Total size in bytes of all entries in the disk cache (approximate).
36    total_size_bytes: AtomicU64,
37}
38
39impl DiskCache {
40    /// Returns the number of write failures accumulated since the last call and resets the
41    /// per-drain counter. The cumulative `total_write_failures` is never reset.
42    #[must_use]
43    pub fn drain_write_failures(&self) -> u64 {
44        self.write_failures.swap(0, Ordering::Relaxed)
45    }
46
47    /// Returns true when cumulative write failures have reached `DISK_CACHE_DEGRADED_THRESHOLD`.
48    /// Callers can use this to emit a degraded health signal without polling the counter.
49    #[must_use]
50    pub fn is_degraded(&self) -> bool {
51        self.total_write_failures.load(Ordering::Relaxed) >= DISK_CACHE_DEGRADED_THRESHOLD
52    }
53
54    /// Returns cache statistics as (entry_count, total_size_bytes).
55    /// Note: size_bytes is approximate; deleted files decrement entry_count but not size_bytes.
56    #[must_use]
57    pub fn cache_stats(&self) -> (u64, u64) {
58        (
59            self.entry_count.load(Ordering::Relaxed),
60            self.total_size_bytes.load(Ordering::Relaxed),
61        )
62    }
63}
64
65impl DiskCache {
66    /// Creates the cache directory (mode 0700) and returns a new instance.
67    /// If `disabled` is true, or if directory creation fails, all operations are no-ops.
68    pub fn new(base: PathBuf, disabled: bool) -> Self {
69        if disabled {
70            return Self {
71                base,
72                disabled: true,
73                write_failures: AtomicU64::new(0),
74                total_write_failures: AtomicU64::new(0),
75                entry_count: AtomicU64::new(0),
76                total_size_bytes: AtomicU64::new(0),
77            };
78        }
79        if let Err(e) = std::fs::create_dir_all(&base) {
80            warn!(path = %base.display(), error = %e, "disk cache disabled: failed to create cache directory");
81            return Self {
82                base,
83                disabled: true,
84                write_failures: AtomicU64::new(0),
85                total_write_failures: AtomicU64::new(0),
86                entry_count: AtomicU64::new(0),
87                total_size_bytes: AtomicU64::new(0),
88            };
89        }
90        #[cfg(unix)]
91        if let Err(e) = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)) {
92            warn!(path = %base.display(), error = %e, "disk cache: failed to set directory permissions to 0700");
93        }
94        #[cfg(not(unix))]
95        let _ = &base; // permissions not supported on this platform
96        Self {
97            base,
98            disabled: false,
99            write_failures: AtomicU64::new(0),
100            total_write_failures: AtomicU64::new(0),
101            entry_count: AtomicU64::new(0),
102            total_size_bytes: AtomicU64::new(0),
103        }
104    }
105
106    pub fn entry_path(&self, tool: &str, key: &blake3::Hash) -> PathBuf {
107        let hex = format!("{}", key);
108        self.base
109            .join(tool)
110            .join(&hex[..2])
111            .join(format!("{}.json.snap", hex))
112    }
113
114    /// Retrieves a cached entry by key, decompressing and deserializing on success.
115    /// Returns None if the entry does not exist, is corrupted, or deserialization fails.
116    pub fn get<T: DeserializeOwned>(&self, tool: &str, key: &blake3::Hash) -> Option<T> {
117        if self.disabled {
118            return None;
119        }
120        let path = self.entry_path(tool, key);
121        let dir = path.parent()?;
122
123        // Acquire shared lock on per-shard .lock sentinel before reading
124        let _lock = lock_shard_shared(dir)?;
125
126        let compressed = std::fs::read(&path).ok()?;
127        let mut decompressed_data = Vec::new();
128        snap::read::FrameDecoder::new(&compressed[..])
129            .read_to_end(&mut decompressed_data)
130            .ok()?;
131        serde_json::from_slice(&decompressed_data).ok()
132    }
133
134    /// Serializes and compresses a value for storage.
135    fn serialize_entry<T: Serialize>(value: &T) -> Option<Vec<u8>> {
136        let json = serde_json::to_vec(value).ok()?;
137        let mut compressed = Vec::new();
138        snap::write::FrameEncoder::new(&mut compressed)
139            .write_all(&json)
140            .ok()?;
141        Some(compressed)
142    }
143
144    /// Atomically writes a compressed entry to disk using NamedTempFile::persist.
145    /// Acquires an exclusive lock on the per-shard .lock sentinel before writing.
146    /// Returns Err if any step fails; caller silently drops the error.
147    fn write_entry_atomically(
148        dir: &std::path::Path,
149        path: &std::path::Path,
150        compressed: &[u8],
151    ) -> Result<(), std::io::Error> {
152        use std::io::Write;
153        // Acquire exclusive lock on per-shard .lock sentinel before writing
154        let _lock = lock_shard_exclusive(dir)?;
155        let mut tmp = NamedTempFile::new_in(dir)?;
156        tmp.write_all(compressed)?;
157        tmp.persist(path).map(|_| ()).map_err(|e| e.error)
158    }
159
160    /// Atomic write via NamedTempFile::persist (rename(2)). Silently drops all errors.
161    pub fn put<T: Serialize>(&self, tool: &str, key: &blake3::Hash, value: &T) {
162        if self.disabled {
163            return;
164        }
165        let path = self.entry_path(tool, key);
166        let dir = match path.parent() {
167            Some(d) => d.to_path_buf(),
168            None => return,
169        };
170        if let Err(e) = std::fs::create_dir_all(&dir) {
171            warn!(tool, error = %e, "disk cache: failed to create cache directory");
172            self.record_write_failure();
173            return;
174        }
175        let compressed = match Self::serialize_entry(value) {
176            Some(c) => c,
177            None => return,
178        };
179        let compressed_size = compressed.len() as u64;
180        if Self::write_entry_atomically(&dir, &path, &compressed)
181            .ok()
182            .is_none()
183        {
184            self.record_write_failure();
185            return;
186        }
187        // Only increment counters on successful write
188        self.entry_count.fetch_add(1, Ordering::Relaxed);
189        self.total_size_bytes
190            .fetch_add(compressed_size, Ordering::Relaxed);
191    }
192
193    /// Increments both the per-drain and cumulative failure counters. Escalates to `error!`
194    /// once cumulative failures reach `DISK_CACHE_DEGRADED_THRESHOLD` so a sustained
195    /// disk-full or permission problem surfaces above the noise of individual `warn!` entries.
196    fn record_write_failure(&self) {
197        self.write_failures.fetch_add(1, Ordering::Relaxed);
198        let total = self.total_write_failures.fetch_add(1, Ordering::Relaxed) + 1;
199        if total == DISK_CACHE_DEGRADED_THRESHOLD {
200            error!(
201                path = %self.base.display(),
202                total,
203                threshold = DISK_CACHE_DEGRADED_THRESHOLD,
204                "disk cache is degraded: consecutive write failures have reached the alert threshold; \
205                 check disk space and permissions at the cache directory"
206            );
207        }
208    }
209
210    /// Removes files not accessed within retention_days. Best-effort; silently drops errors.
211    pub fn evict_stale(&self, retention_days: u64) {
212        if self.disabled {
213            return;
214        }
215        let cutoff = std::time::SystemTime::now()
216            .checked_sub(std::time::Duration::from_secs(retention_days * 86_400))
217            .unwrap_or(std::time::UNIX_EPOCH);
218        if let Ok((evicted_count, evicted_bytes)) = evict_dir_recursive(&self.base, cutoff) {
219            self.entry_count.fetch_sub(evicted_count, Ordering::Relaxed);
220            self.total_size_bytes
221                .fetch_sub(evicted_bytes, Ordering::Relaxed);
222        }
223    }
224}
225
226fn evict_dir_recursive(
227    dir: &std::path::Path,
228    cutoff: std::time::SystemTime,
229) -> std::io::Result<(u64, u64)> {
230    let mut evicted_count = 0u64;
231    let mut evicted_bytes = 0u64;
232    for entry in std::fs::read_dir(dir)? {
233        let entry = entry?;
234        let meta = entry.metadata()?;
235        let path = entry.path();
236        if meta.is_dir() {
237            if let Ok((count, bytes)) = evict_dir_recursive(&path, cutoff) {
238                evicted_count += count;
239                evicted_bytes += bytes;
240            }
241        } else if meta.is_file()
242            && let Ok(mtime) = meta.modified()
243            && mtime < cutoff
244        {
245            let file_size = meta.len();
246            if let Err(e) = std::fs::remove_file(&path) {
247                warn!(path = %path.display(), error = %e, "disk cache: failed to evict stale cache file");
248            } else {
249                evicted_count += 1;
250                evicted_bytes += file_size;
251            }
252        }
253    }
254    Ok((evicted_count, evicted_bytes))
255}
256
257/// Acquire a shared (read) lock on the per-shard `.lock` sentinel.
258/// Creates the lock file if it does not exist. Lock failures degrade
259/// gracefully (warn and return None) so that read availability is
260/// never blocked by lock infrastructure issues.
261fn lock_shard_shared(shard_dir: &std::path::Path) -> Option<ShardLockGuard> {
262    let lock_path = shard_dir.join(".lock");
263    let file = std::fs::OpenOptions::new()
264        .create(true)
265        .write(true)
266        .truncate(false)
267        .open(&lock_path)
268        .ok()?;
269    match file.lock_shared() {
270        Ok(()) => Some(ShardLockGuard(file)),
271        Err(e) => {
272            warn!(
273                error = %e, lock_path = %lock_path.display(),
274                "disk cache: failed to acquire shared lock on shard; proceeding without lock"
275            );
276            None
277        }
278    }
279}
280
281/// Acquire an exclusive (write) lock on the per-shard `.lock` sentinel.
282/// Creates the lock file if it does not exist. Returns Err if the lock
283/// file cannot be opened or if the lock acquisition fails, propagating
284/// the error to the caller (which typically degrades gracefully).
285fn lock_shard_exclusive(shard_dir: &std::path::Path) -> Result<ShardLockGuard, std::io::Error> {
286    let lock_path = shard_dir.join(".lock");
287    let file = std::fs::OpenOptions::new()
288        .create(true)
289        .write(true)
290        .truncate(false)
291        .open(&lock_path)?;
292    file.lock_exclusive()?;
293    Ok(ShardLockGuard(file))
294}
295
296/// RAII guard that releases a per-shard flock when dropped.
297/// Closing the underlying file descriptor releases the BSD/OFC lock.
298struct ShardLockGuard(
299    /// Held exclusively for its `Drop` implementation: closing the file
300    /// descriptor releases the advisory flock. Never read directly.
301    #[expect(dead_code)]
302    std::fs::File,
303);
304
305#[cfg(test)]
306mod disk_cache_tests {
307    use super::*;
308    use tempfile::TempDir;
309
310    #[test]
311    fn test_disk_cache_roundtrip() {
312        let dir = TempDir::new().unwrap();
313        let cache = DiskCache::new(dir.path().to_path_buf(), false);
314        let key = blake3::hash(b"test-key");
315        let value = serde_json::json!({"result": "success", "count": 42});
316        cache.put("analyze_file", &key, &value);
317        let retrieved: Option<serde_json::Value> = cache.get("analyze_file", &key);
318        assert_eq!(retrieved, Some(value));
319    }
320
321    #[test]
322    fn test_focused_analysis_output_disk_cache_roundtrip() {
323        // Arrange
324        let dir = TempDir::new().unwrap();
325        let cache = DiskCache::new(dir.path().to_path_buf(), false);
326        let key = blake3::hash(b"focused-analysis-key");
327        let chain = crate::graph::InternalCallChain {
328            chain: vec![("caller_fn".to_string(), PathBuf::from("src/lib.rs"), 10)],
329        };
330        let value = crate::analyze::FocusedAnalysisOutput {
331            formatted: "formatted output".to_string(),
332            next_cursor: None,
333            prod_chains: vec![chain.clone()],
334            test_chains: vec![chain.clone()],
335            outgoing_chains: vec![chain],
336            def_count: 3,
337            unfiltered_caller_count: 5,
338            impl_trait_caller_count: 2,
339            callers: None,
340            test_callers: None,
341            callees: None,
342            def_use_sites: Vec::new(),
343            cache_tier: None,
344        };
345
346        // Act
347        cache.put("analyze_symbol", &key, &value);
348        let retrieved: Option<crate::analyze::FocusedAnalysisOutput> =
349            cache.get("analyze_symbol", &key);
350
351        // Assert
352        let retrieved = retrieved.unwrap();
353        assert_eq!(retrieved.prod_chains, value.prod_chains);
354        assert_eq!(retrieved.test_chains, value.test_chains);
355        assert_eq!(retrieved.outgoing_chains, value.outgoing_chains);
356        assert_eq!(retrieved.def_count, value.def_count);
357        assert_eq!(
358            retrieved.unfiltered_caller_count,
359            value.unfiltered_caller_count
360        );
361        assert_eq!(
362            retrieved.impl_trait_caller_count,
363            value.impl_trait_caller_count
364        );
365    }
366
367    #[test]
368    fn test_disk_cache_permissions() {
369        #[cfg(unix)]
370        {
371            use std::os::unix::fs::PermissionsExt;
372            let dir = TempDir::new().unwrap();
373            let cache_dir = dir.path().join("analysis-cache");
374            let _cache = DiskCache::new(cache_dir.clone(), false);
375            let meta = std::fs::metadata(&cache_dir).unwrap();
376            let mode = meta.permissions().mode() & 0o777;
377            assert_eq!(mode, 0o700, "cache dir must be mode 0700");
378        }
379    }
380
381    #[test]
382    fn test_disk_cache_corrupt_entry_returns_none() {
383        let dir = TempDir::new().unwrap();
384        let cache = DiskCache::new(dir.path().to_path_buf(), false);
385        let key = blake3::hash(b"corrupt-key");
386        let path = cache.entry_path("analyze_file", &key);
387        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
388        std::fs::write(&path, b"not valid snappy data").unwrap();
389        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
390        assert!(result.is_none(), "corrupt entry must return None");
391    }
392
393    #[test]
394    fn test_disk_cache_disabled_on_dir_creation_failure() {
395        let dir = TempDir::new().unwrap();
396        // Place a regular file where DiskCache::new() would create a directory.
397        // create_dir_all fails with ENOTDIR; new() must flip disabled=true.
398        let blocked = dir.path().join("blocked");
399        std::fs::write(&blocked, b"").unwrap();
400        let cache = DiskCache::new(blocked, false);
401        // disabled=true: put is a no-op, get always returns None
402        let key = blake3::hash(b"should-not-exist");
403        cache.put("analyze_file", &key, &serde_json::json!({"x": 1}));
404        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
405        assert!(
406            result.is_none(),
407            "cache must be disabled after dir creation failure"
408        );
409        assert!(
410            cache.disabled,
411            "disabled flag must be true after dir creation failure"
412        );
413    }
414
415    #[test]
416    fn test_concurrent_get_put_same_shard() {
417        // Edge case: concurrent get() + put() on the same shard from two threads
418        // must not panic and must return consistent results.
419        let dir = TempDir::new().unwrap();
420        let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
421        let key = blake3::hash(b"concurrent-test-key");
422        let value = serde_json::json!({"result": "from put thread", "n": 42});
423
424        // Pre-populate so get() has a chance to read something
425        cache.put("analyze_file", &key, &value);
426
427        let cache_get = cache.clone();
428        let cache_put = cache.clone();
429        let key_put = key;
430        let key_get = key;
431        let value_put = serde_json::json!({"result": "from put thread", "n": 100});
432
433        std::thread::scope(|scope| {
434            scope.spawn(|| {
435                // Write thread: perform put
436                cache_put.put("analyze_file", &key_put, &value_put);
437            });
438            scope.spawn(|| {
439                // Read thread: perform get concurrently
440                let _: Option<serde_json::Value> = cache_get.get("analyze_file", &key_get);
441            });
442        });
443
444        // After both threads complete, verify the cache is in a consistent state
445        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
446        assert!(
447            result.is_some(),
448            "entry must still be retrievable after concurrent access"
449        );
450    }
451
452    #[test]
453    fn test_concurrent_puts_same_shard() {
454        // Edge case: write_entry_atomically acquires exclusive lock before persist;
455        // concurrent writes from two threads must not corrupt the cache entry.
456        let dir = TempDir::new().unwrap();
457        let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
458        let key = blake3::hash(b"concurrent-put-key");
459        let value_a = serde_json::json!({"writer": "A", "data": "hello from A"});
460        let value_b = serde_json::json!({"writer": "B", "data": "hello from B"});
461
462        let cache_a = cache.clone();
463        let cache_b = cache.clone();
464        let key_a = key;
465        let key_b = key;
466
467        std::thread::scope(|scope| {
468            scope.spawn(|| {
469                cache_a.put("analyze_file", &key_a, &value_a);
470            });
471            scope.spawn(|| {
472                cache_b.put("analyze_file", &key_b, &value_b);
473            });
474        });
475
476        // After both writes complete, the entry must be deserializable (not corrupt)
477        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
478        assert!(
479            result.is_some(),
480            "entry must be retrievable after concurrent puts"
481        );
482        // Either value is acceptable; the key invariant is that the data is uncorrupted
483        let v = result.unwrap();
484        let writer = v.get("writer").and_then(|w| w.as_str());
485        assert!(
486            writer == Some("A") || writer == Some("B"),
487            "entry must contain data from one of the concurrent writers, got {writer:?}"
488        );
489    }
490}