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