1use 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
18const DISK_CACHE_DEGRADED_THRESHOLD: u64 = 100;
20
21pub struct DiskCache {
27 base: PathBuf,
28 disabled: bool,
29 write_failures: AtomicU64,
31 total_write_failures: AtomicU64,
33 entry_count: AtomicU64,
35 total_size_bytes: AtomicU64,
37}
38
39impl DiskCache {
40 pub fn drain_write_failures(&self) -> u64 {
43 self.write_failures.swap(0, Ordering::Relaxed)
44 }
45
46 pub fn is_degraded(&self) -> bool {
49 self.total_write_failures.load(Ordering::Relaxed) >= DISK_CACHE_DEGRADED_THRESHOLD
50 }
51
52 #[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 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; 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 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 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 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 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 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 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 self.entry_count.fetch_add(1, Ordering::Relaxed);
187 self.total_size_bytes
188 .fetch_add(compressed_size, Ordering::Relaxed);
189 }
190
191 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 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
255fn 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
279fn 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
294struct ShardLockGuard(
297 #[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_focused_analysis_output_disk_cache_roundtrip() {
322 let dir = TempDir::new().unwrap();
324 let cache = DiskCache::new(dir.path().to_path_buf(), false);
325 let key = blake3::hash(b"focused-analysis-key");
326 let chain = crate::graph::InternalCallChain {
327 chain: vec![("caller_fn".to_string(), PathBuf::from("src/lib.rs"), 10)],
328 };
329 let value = crate::analyze::FocusedAnalysisOutput {
330 formatted: "formatted output".to_string(),
331 next_cursor: None,
332 prod_chains: vec![chain.clone()],
333 test_chains: vec![chain.clone()],
334 outgoing_chains: vec![chain],
335 def_count: 3,
336 unfiltered_caller_count: 5,
337 impl_trait_caller_count: 2,
338 callers: None,
339 test_callers: None,
340 callees: None,
341 def_use_sites: Vec::new(),
342 cache_tier: None,
343 };
344
345 cache.put("analyze_symbol", &key, &value);
347 let retrieved: Option<crate::analyze::FocusedAnalysisOutput> =
348 cache.get("analyze_symbol", &key);
349
350 let retrieved = retrieved.unwrap();
352 assert_eq!(retrieved.prod_chains, value.prod_chains);
353 assert_eq!(retrieved.test_chains, value.test_chains);
354 assert_eq!(retrieved.outgoing_chains, value.outgoing_chains);
355 assert_eq!(retrieved.def_count, value.def_count);
356 assert_eq!(
357 retrieved.unfiltered_caller_count,
358 value.unfiltered_caller_count
359 );
360 assert_eq!(
361 retrieved.impl_trait_caller_count,
362 value.impl_trait_caller_count
363 );
364 }
365
366 #[test]
367 fn test_disk_cache_permissions() {
368 #[cfg(unix)]
369 {
370 use std::os::unix::fs::PermissionsExt;
371 let dir = TempDir::new().unwrap();
372 let cache_dir = dir.path().join("analysis-cache");
373 let _cache = DiskCache::new(cache_dir.clone(), false);
374 let meta = std::fs::metadata(&cache_dir).unwrap();
375 let mode = meta.permissions().mode() & 0o777;
376 assert_eq!(mode, 0o700, "cache dir must be mode 0700");
377 }
378 }
379
380 #[test]
381 fn test_disk_cache_corrupt_entry_returns_none() {
382 let dir = TempDir::new().unwrap();
383 let cache = DiskCache::new(dir.path().to_path_buf(), false);
384 let key = blake3::hash(b"corrupt-key");
385 let path = cache.entry_path("analyze_file", &key);
386 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
387 std::fs::write(&path, b"not valid snappy data").unwrap();
388 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
389 assert!(result.is_none(), "corrupt entry must return None");
390 }
391
392 #[test]
393 fn test_disk_cache_disabled_on_dir_creation_failure() {
394 let dir = TempDir::new().unwrap();
395 let blocked = dir.path().join("blocked");
398 std::fs::write(&blocked, b"").unwrap();
399 let cache = DiskCache::new(blocked, false);
400 let key = blake3::hash(b"should-not-exist");
402 cache.put("analyze_file", &key, &serde_json::json!({"x": 1}));
403 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
404 assert!(
405 result.is_none(),
406 "cache must be disabled after dir creation failure"
407 );
408 assert!(
409 cache.disabled,
410 "disabled flag must be true after dir creation failure"
411 );
412 }
413
414 #[test]
415 fn test_concurrent_get_put_same_shard() {
416 let dir = TempDir::new().unwrap();
419 let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
420 let key = blake3::hash(b"concurrent-test-key");
421 let value = serde_json::json!({"result": "from put thread", "n": 42});
422
423 cache.put("analyze_file", &key, &value);
425
426 let cache_get = cache.clone();
427 let cache_put = cache.clone();
428 let key_put = key;
429 let key_get = key;
430 let value_put = serde_json::json!({"result": "from put thread", "n": 100});
431
432 std::thread::scope(|scope| {
433 scope.spawn(|| {
434 cache_put.put("analyze_file", &key_put, &value_put);
436 });
437 scope.spawn(|| {
438 let _: Option<serde_json::Value> = cache_get.get("analyze_file", &key_get);
440 });
441 });
442
443 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
445 assert!(
446 result.is_some(),
447 "entry must still be retrievable after concurrent access"
448 );
449 }
450
451 #[test]
452 fn test_concurrent_puts_same_shard() {
453 let dir = TempDir::new().unwrap();
456 let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
457 let key = blake3::hash(b"concurrent-put-key");
458 let value_a = serde_json::json!({"writer": "A", "data": "hello from A"});
459 let value_b = serde_json::json!({"writer": "B", "data": "hello from B"});
460
461 let cache_a = cache.clone();
462 let cache_b = cache.clone();
463 let key_a = key;
464 let key_b = key;
465
466 std::thread::scope(|scope| {
467 scope.spawn(|| {
468 cache_a.put("analyze_file", &key_a, &value_a);
469 });
470 scope.spawn(|| {
471 cache_b.put("analyze_file", &key_b, &value_b);
472 });
473 });
474
475 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
477 assert!(
478 result.is_some(),
479 "entry must be retrievable after concurrent puts"
480 );
481 let v = result.unwrap();
483 let writer = v.get("writer").and_then(|w| w.as_str());
484 assert!(
485 writer == Some("A") || writer == Some("B"),
486 "entry must contain data from one of the concurrent writers, got {writer:?}"
487 );
488 }
489}