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 std::io::Read;
309    use tempfile::TempDir;
310
311    #[test]
312    fn test_disk_cache_roundtrip() {
313        let dir = TempDir::new().unwrap();
314        let cache = DiskCache::new(dir.path().to_path_buf(), false);
315        let key = blake3::hash(b"test-key");
316        let value = serde_json::json!({"result": "success", "count": 42});
317        cache.put("analyze_file", &key, &value);
318        let retrieved: Option<serde_json::Value> = cache.get("analyze_file", &key);
319        assert_eq!(retrieved, Some(value));
320    }
321
322    #[test]
323    fn test_focused_analysis_output_disk_cache_roundtrip() {
324        // Arrange
325        let dir = TempDir::new().unwrap();
326        let cache = DiskCache::new(dir.path().to_path_buf(), false);
327        let key = blake3::hash(b"focused-analysis-key");
328        let chain = crate::graph::InternalCallChain {
329            chain: vec![("caller_fn".to_string(), PathBuf::from("src/lib.rs"), 10)],
330        };
331        let value = crate::analyze::FocusedAnalysisOutput {
332            formatted: "formatted output".to_string(),
333            next_cursor: None,
334            prod_chains: vec![chain.clone()],
335            test_chains: vec![chain.clone()],
336            outgoing_chains: vec![chain],
337            def_count: 3,
338            unfiltered_caller_count: 5,
339            impl_trait_caller_count: 2,
340            callers: None,
341            test_callers: None,
342            callees: None,
343            def_use_sites: Vec::new(),
344            cache_tier: None,
345        };
346
347        // Act
348        cache.put("analyze_symbol", &key, &value);
349        let retrieved: Option<crate::analyze::FocusedAnalysisOutput> =
350            cache.get("analyze_symbol", &key);
351
352        // Assert
353        let retrieved = retrieved.unwrap();
354        assert_eq!(retrieved.prod_chains, value.prod_chains);
355        assert_eq!(retrieved.test_chains, value.test_chains);
356        assert_eq!(retrieved.outgoing_chains, value.outgoing_chains);
357        assert_eq!(retrieved.def_count, value.def_count);
358        assert_eq!(
359            retrieved.unfiltered_caller_count,
360            value.unfiltered_caller_count
361        );
362        assert_eq!(
363            retrieved.impl_trait_caller_count,
364            value.impl_trait_caller_count
365        );
366    }
367
368    #[test]
369    fn test_disk_cache_permissions() {
370        #[cfg(unix)]
371        {
372            use std::os::unix::fs::PermissionsExt;
373            let dir = TempDir::new().unwrap();
374            let cache_dir = dir.path().join("analysis-cache");
375            let _cache = DiskCache::new(cache_dir.clone(), false);
376            let meta = std::fs::metadata(&cache_dir).unwrap();
377            let mode = meta.permissions().mode() & 0o777;
378            assert_eq!(mode, 0o700, "cache dir must be mode 0700");
379        }
380    }
381
382    #[test]
383    fn test_disk_cache_corrupt_entry_returns_none() {
384        let dir = TempDir::new().unwrap();
385        let cache = DiskCache::new(dir.path().to_path_buf(), false);
386        let key = blake3::hash(b"corrupt-key");
387        let path = cache.entry_path("analyze_file", &key);
388        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
389        std::fs::write(&path, b"not valid snappy data").unwrap();
390        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
391        assert!(result.is_none(), "corrupt entry must return None");
392    }
393
394    #[test]
395    fn test_disk_cache_disabled_on_dir_creation_failure() {
396        let dir = TempDir::new().unwrap();
397        // Place a regular file where DiskCache::new() would create a directory.
398        // create_dir_all fails with ENOTDIR; new() must flip disabled=true.
399        let blocked = dir.path().join("blocked");
400        std::fs::write(&blocked, b"").unwrap();
401        let cache = DiskCache::new(blocked, false);
402        // disabled=true: put is a no-op, get always returns None
403        let key = blake3::hash(b"should-not-exist");
404        cache.put("analyze_file", &key, &serde_json::json!({"x": 1}));
405        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
406        assert!(
407            result.is_none(),
408            "cache must be disabled after dir creation failure"
409        );
410        assert!(
411            cache.disabled,
412            "disabled flag must be true after dir creation failure"
413        );
414    }
415
416    #[test]
417    fn test_concurrent_get_put_same_shard() {
418        // Edge case: concurrent get() + put() on the same shard from two threads
419        // must not panic and must return consistent results.
420        let dir = TempDir::new().unwrap();
421        let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
422        let key = blake3::hash(b"concurrent-test-key");
423        let value = serde_json::json!({"result": "from put thread", "n": 42});
424
425        // Pre-populate so get() has a chance to read something
426        cache.put("analyze_file", &key, &value);
427
428        let cache_get = cache.clone();
429        let cache_put = cache.clone();
430        let key_put = key;
431        let key_get = key;
432        let value_put = serde_json::json!({"result": "from put thread", "n": 100});
433
434        std::thread::scope(|scope| {
435            scope.spawn(|| {
436                // Write thread: perform put
437                cache_put.put("analyze_file", &key_put, &value_put);
438            });
439            scope.spawn(|| {
440                // Read thread: perform get concurrently
441                let _: Option<serde_json::Value> = cache_get.get("analyze_file", &key_get);
442            });
443        });
444
445        // After both threads complete, verify the cache is in a consistent state
446        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
447        assert!(
448            result.is_some(),
449            "entry must still be retrievable after concurrent access"
450        );
451    }
452
453    #[test]
454    fn test_concurrent_puts_same_shard() {
455        // Edge case: write_entry_atomically acquires exclusive lock before persist;
456        // concurrent writes from two threads must not corrupt the cache entry.
457        let dir = TempDir::new().unwrap();
458        let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
459        let key = blake3::hash(b"concurrent-put-key");
460        let value_a = serde_json::json!({"writer": "A", "data": "hello from A"});
461        let value_b = serde_json::json!({"writer": "B", "data": "hello from B"});
462
463        let cache_a = cache.clone();
464        let cache_b = cache.clone();
465        let key_a = key;
466        let key_b = key;
467
468        std::thread::scope(|scope| {
469            scope.spawn(|| {
470                cache_a.put("analyze_file", &key_a, &value_a);
471            });
472            scope.spawn(|| {
473                cache_b.put("analyze_file", &key_b, &value_b);
474            });
475        });
476
477        // After both writes complete, the entry must be deserializable (not corrupt)
478        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
479        assert!(
480            result.is_some(),
481            "entry must be retrievable after concurrent puts"
482        );
483        // Either value is acceptable; the key invariant is that the data is uncorrupted
484        let v = result.unwrap();
485        let writer = v.get("writer").and_then(|w| w.as_str());
486        assert!(
487            writer == Some("A") || writer == Some("B"),
488            "entry must contain data from one of the concurrent writers, got {writer:?}"
489        );
490    }
491}