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 #[must_use]
43 pub fn drain_write_failures(&self) -> u64 {
44 self.write_failures.swap(0, Ordering::Relaxed)
45 }
46
47 #[must_use]
50 pub fn is_degraded(&self) -> bool {
51 self.total_write_failures.load(Ordering::Relaxed) >= DISK_CACHE_DEGRADED_THRESHOLD
52 }
53
54 #[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 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; 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 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 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 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 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 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 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 self.entry_count.fetch_add(1, Ordering::Relaxed);
189 self.total_size_bytes
190 .fetch_add(compressed_size, Ordering::Relaxed);
191 }
192
193 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 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
257fn 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
281fn 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
296struct ShardLockGuard(
299 #[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 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 cache.put("analyze_symbol", &key, &value);
348 let retrieved: Option<crate::analyze::FocusedAnalysisOutput> =
349 cache.get("analyze_symbol", &key);
350
351 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 let blocked = dir.path().join("blocked");
399 std::fs::write(&blocked, b"").unwrap();
400 let cache = DiskCache::new(blocked, false);
401 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 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 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 cache_put.put("analyze_file", &key_put, &value_put);
437 });
438 scope.spawn(|| {
439 let _: Option<serde_json::Value> = cache_get.get("analyze_file", &key_get);
441 });
442 });
443
444 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 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 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 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}