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 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 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 cache.put("analyze_symbol", &key, &value);
349 let retrieved: Option<crate::analyze::FocusedAnalysisOutput> =
350 cache.get("analyze_symbol", &key);
351
352 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 let blocked = dir.path().join("blocked");
400 std::fs::write(&blocked, b"").unwrap();
401 let cache = DiskCache::new(blocked, false);
402 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 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 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 cache_put.put("analyze_file", &key_put, &value_put);
438 });
439 scope.spawn(|| {
440 let _: Option<serde_json::Value> = cache_get.get("analyze_file", &key_get);
442 });
443 });
444
445 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 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 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 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}