Skip to main content

sphinx_ultra/
cache.rs

1use anyhow::Result;
2use blake3::Hasher;
3use chrono::{DateTime, Utc};
4use dashmap::DashMap;
5use log::{debug, warn};
6use parking_lot::RwLock;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{Duration, UNIX_EPOCH};
12
13use crate::document::Document;
14use crate::error::BuildError;
15
16pub struct BuildCache {
17    cache_dir: PathBuf,
18    documents: Arc<DashMap<PathBuf, CachedDocument>>,
19    file_hashes: Arc<RwLock<HashMap<PathBuf, String>>>,
20    hit_count: Arc<RwLock<usize>>,
21    miss_count: Arc<RwLock<usize>>,
22    max_size_mb: usize,
23    expiration_duration: Duration,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27struct CachedDocument {
28    document: Document,
29    hash: String,
30    cached_at: DateTime<Utc>,
31    access_count: usize,
32    size_bytes: usize,
33}
34
35impl BuildCache {
36    pub fn new(
37        cache_dir: PathBuf,
38        max_size_mb: usize,
39        expiration_hours: u64,
40        config_fingerprint: &str,
41    ) -> Result<Self> {
42        std::fs::create_dir_all(&cache_dir)?;
43
44        // Cached documents were produced under a specific configuration; if
45        // the configuration changed, everything in the cache is stale.
46        let fingerprint_file = cache_dir.join(".config-fingerprint");
47        let stored = std::fs::read_to_string(&fingerprint_file).unwrap_or_default();
48        if stored.trim() != config_fingerprint {
49            if !stored.is_empty() {
50                debug!("Configuration changed; discarding cache");
51            }
52            std::fs::remove_dir_all(&cache_dir)?;
53            std::fs::create_dir_all(&cache_dir)?;
54            std::fs::write(&fingerprint_file, config_fingerprint)?;
55        }
56
57        let cache = Self {
58            cache_dir,
59            documents: Arc::new(DashMap::new()),
60            file_hashes: Arc::new(RwLock::new(HashMap::new())),
61            hit_count: Arc::new(RwLock::new(0)),
62            miss_count: Arc::new(RwLock::new(0)),
63            max_size_mb,
64            expiration_duration: Duration::from_secs(expiration_hours * 60 * 60),
65        };
66
67        // Load existing cache from disk
68        cache.load_from_disk()?;
69
70        Ok(cache)
71    }
72
73    pub fn get_document(&self, file_path: &Path) -> Result<Document> {
74        let hash = self.calculate_file_hash(file_path)?;
75
76        // Clone what we need out of the `get` guard before touching the map
77        // again: holding a DashMap `Ref` while calling `alter` on the same
78        // key deadlocks on the shard lock.
79        let cached = self
80            .documents
81            .get(file_path)
82            .map(|c| (c.hash.clone(), c.cached_at, c.document.clone()));
83
84        if let Some((cached_hash, cached_at, document)) = cached {
85            if cached_hash == hash && !self.is_expired(&cached_at) {
86                // Update access count
87                self.documents.alter(file_path, |_, mut cached| {
88                    cached.access_count += 1;
89                    cached
90                });
91
92                *self.hit_count.write() += 1;
93                debug!("Cache hit for {}", file_path.display());
94                return Ok(document);
95            }
96            // Remove expired or outdated entry
97            self.documents.remove(file_path);
98        }
99
100        *self.miss_count.write() += 1;
101        debug!("Cache miss for {}", file_path.display());
102        Err(BuildError::Cache("Document not found in cache".to_string()).into())
103    }
104
105    pub fn store_document(&self, file_path: &Path, document: &Document) -> Result<()> {
106        let hash = self.calculate_file_hash(file_path)?;
107        let size_bytes = self.estimate_document_size(document);
108
109        let cached_doc = CachedDocument {
110            document: document.clone(),
111            hash: hash.clone(),
112            cached_at: Utc::now(),
113            access_count: 1,
114            size_bytes,
115        };
116
117        // Check if we need to evict some entries
118        self.evict_if_needed(size_bytes)?;
119
120        self.documents.insert(file_path.to_path_buf(), cached_doc);
121        self.file_hashes
122            .write()
123            .insert(file_path.to_path_buf(), hash.clone());
124
125        debug!(
126            "Cached document: {} ({} bytes)",
127            file_path.display(),
128            size_bytes
129        );
130
131        // Persist to disk asynchronously
132        self.persist_to_disk(file_path, document)?;
133
134        Ok(())
135    }
136
137    #[allow(dead_code)]
138    pub fn invalidate(&self, file_path: &Path) {
139        self.documents.remove(file_path);
140        self.file_hashes.write().remove(file_path);
141
142        // Remove from disk cache
143        let cache_file = self.get_cache_file_path(file_path);
144        if cache_file.exists() {
145            if let Err(e) = std::fs::remove_file(&cache_file) {
146                warn!(
147                    "Failed to remove cache file {}: {}",
148                    cache_file.display(),
149                    e
150                );
151            }
152        }
153
154        debug!("Invalidated cache for {}", file_path.display());
155    }
156
157    #[allow(dead_code)]
158    pub fn clear(&self) -> Result<()> {
159        self.documents.clear();
160        self.file_hashes.write().clear();
161        *self.hit_count.write() = 0;
162        *self.miss_count.write() = 0;
163
164        if self.cache_dir.exists() {
165            std::fs::remove_dir_all(&self.cache_dir)?;
166            std::fs::create_dir_all(&self.cache_dir)?;
167        }
168
169        debug!("Cleared all cache");
170        Ok(())
171    }
172
173    pub fn hit_count(&self) -> usize {
174        *self.hit_count.read()
175    }
176
177    #[allow(dead_code)]
178    pub fn miss_count(&self) -> usize {
179        *self.miss_count.read()
180    }
181
182    #[allow(dead_code)]
183    pub fn hit_ratio(&self) -> f64 {
184        let hits = *self.hit_count.read() as f64;
185        let misses = *self.miss_count.read() as f64;
186        if hits + misses > 0.0 {
187            hits / (hits + misses)
188        } else {
189            0.0
190        }
191    }
192
193    pub fn size_mb(&self) -> f64 {
194        let total_bytes: usize = self
195            .documents
196            .iter()
197            .map(|entry| entry.value().size_bytes)
198            .sum();
199        total_bytes as f64 / 1024.0 / 1024.0
200    }
201
202    fn calculate_file_hash(&self, file_path: &Path) -> Result<String> {
203        let content = std::fs::read(file_path)?;
204        let metadata = std::fs::metadata(file_path)?;
205
206        let mut hasher = Hasher::new();
207        hasher.update(&content);
208
209        // Include file metadata in hash
210        if let Ok(modified) = metadata.modified() {
211            if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
212                hasher.update(&duration.as_secs().to_le_bytes());
213            }
214        }
215
216        Ok(hasher.finalize().to_hex().to_string())
217    }
218
219    fn is_expired(&self, cached_at: &DateTime<Utc>) -> bool {
220        let now = Utc::now();
221        let elapsed = now.signed_duration_since(*cached_at);
222        elapsed.num_seconds() > self.expiration_duration.as_secs() as i64
223    }
224
225    fn estimate_document_size(&self, document: &Document) -> usize {
226        // Rough estimate of document size in memory
227        document.html.len()
228            + document.title.len()
229            + document.source_path.to_string_lossy().len()
230            + document.output_path.to_string_lossy().len()
231            + 1024 // Overhead for other fields
232    }
233
234    fn evict_if_needed(&self, new_size: usize) -> Result<()> {
235        let current_size_mb = self.size_mb();
236        let new_size_mb = (new_size as f64) / 1024.0 / 1024.0;
237
238        if current_size_mb + new_size_mb > self.max_size_mb as f64 {
239            self.evict_least_accessed_entries(new_size_mb)?;
240        }
241
242        Ok(())
243    }
244
245    /// Evict entries with the lowest access counts (LFU-style). This is not
246    /// LRU — recency is not tracked — and is named accordingly.
247    fn evict_least_accessed_entries(&self, space_needed_mb: f64) -> Result<()> {
248        let mut entries: Vec<_> = self
249            .documents
250            .iter()
251            .map(|entry| {
252                (
253                    entry.key().clone(),
254                    entry.value().access_count,
255                    entry.value().size_bytes,
256                )
257            })
258            .collect();
259
260        // Sort by access count (least-accessed first)
261        entries.sort_by_key(|(_, access_count, _)| *access_count);
262
263        let mut space_freed_mb = 0.0;
264        for (path, _, size_bytes) in entries {
265            if space_freed_mb >= space_needed_mb {
266                break;
267            }
268
269            self.documents.remove(&path);
270            self.file_hashes.write().remove(&path);
271            space_freed_mb += (size_bytes as f64) / 1024.0 / 1024.0;
272
273            debug!(
274                "Evicted {} from cache ({} MB)",
275                path.display(),
276                size_bytes as f64 / 1024.0 / 1024.0
277            );
278        }
279
280        Ok(())
281    }
282
283    fn load_from_disk(&self) -> Result<()> {
284        if !self.cache_dir.exists() {
285            return Ok(());
286        }
287
288        for entry in std::fs::read_dir(&self.cache_dir)? {
289            let entry = entry?;
290            if entry.file_type()?.is_file()
291                && entry.path().extension().is_some_and(|ext| ext == "json")
292            {
293                if let Err(e) = self.load_cache_file(&entry.path()) {
294                    warn!(
295                        "Failed to load cache file {}: {}",
296                        entry.path().display(),
297                        e
298                    );
299                }
300            }
301        }
302
303        debug!("Loaded {} documents from disk cache", self.documents.len());
304        Ok(())
305    }
306
307    fn load_cache_file(&self, cache_file: &Path) -> Result<()> {
308        let content = std::fs::read_to_string(cache_file)?;
309        let cached_doc: CachedDocument = serde_json::from_str(&content)?;
310
311        // Check if the cached document is still valid
312        if !self.is_expired(&cached_doc.cached_at) {
313            let source_path = &cached_doc.document.source_path;
314            if source_path.exists() {
315                let current_hash = self.calculate_file_hash(source_path)?;
316                if current_hash == cached_doc.hash {
317                    self.documents.insert(source_path.clone(), cached_doc);
318                }
319            }
320        }
321
322        Ok(())
323    }
324
325    fn persist_to_disk(&self, file_path: &Path, _document: &Document) -> Result<()> {
326        let cache_file = self.get_cache_file_path(file_path);
327        if let Some(parent) = cache_file.parent() {
328            std::fs::create_dir_all(parent)?;
329        }
330
331        if let Some(cached_doc) = self.documents.get(file_path) {
332            let content = serde_json::to_string_pretty(&*cached_doc)?;
333            std::fs::write(&cache_file, content)?;
334        }
335
336        Ok(())
337    }
338
339    fn get_cache_file_path(&self, file_path: &Path) -> PathBuf {
340        let hash = blake3::hash(file_path.to_string_lossy().as_bytes());
341        let filename = format!("{}.json", hash.to_hex());
342        self.cache_dir.join(filename)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use tempfile::TempDir;
350
351    fn make_document(source: &Path) -> Document {
352        let mut doc = Document::new(source.to_path_buf(), source.with_extension("html"));
353        doc.html = "<html><body>cached</body></html>".to_string();
354        doc.source_mtime = Utc::now();
355        doc
356    }
357
358    #[test]
359    fn roundtrip_preserves_rendered_html() {
360        let tmp = TempDir::new().unwrap();
361        let source = tmp.path().join("page.rst");
362        std::fs::write(&source, "Page\n----\n").unwrap();
363
364        let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
365        cache
366            .store_document(&source, &make_document(&source))
367            .unwrap();
368
369        let restored = cache.get_document(&source).unwrap();
370        assert_eq!(restored.html, "<html><body>cached</body></html>");
371        assert_eq!(cache.hit_count(), 1);
372    }
373
374    #[test]
375    fn warm_hit_does_not_deadlock() {
376        // Regression: `get` guard held across `alter` on the same DashMap key
377        // deadlocked every warm incremental rebuild.
378        let tmp = TempDir::new().unwrap();
379        let source = tmp.path().join("page.rst");
380        std::fs::write(&source, "Page\n----\n").unwrap();
381
382        let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
383        cache
384            .store_document(&source, &make_document(&source))
385            .unwrap();
386        for _ in 0..3 {
387            cache.get_document(&source).unwrap();
388        }
389        assert_eq!(cache.hit_count(), 3);
390    }
391
392    #[test]
393    fn changed_fingerprint_discards_persisted_cache() {
394        let tmp = TempDir::new().unwrap();
395        let source = tmp.path().join("page.rst");
396        std::fs::write(&source, "Page\n----\n").unwrap();
397        let cache_dir = tmp.path().join("cache");
398
399        {
400            let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
401            cache
402                .store_document(&source, &make_document(&source))
403                .unwrap();
404        }
405
406        // Same fingerprint: persisted entry survives.
407        {
408            let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
409            assert!(cache.get_document(&source).is_ok());
410        }
411
412        // Different fingerprint: cache is wiped.
413        {
414            let cache = BuildCache::new(cache_dir, 500, 24, "fp-2").unwrap();
415            assert!(cache.get_document(&source).is_err());
416        }
417    }
418
419    #[test]
420    fn expiration_hours_are_plumbed() {
421        let tmp = TempDir::new().unwrap();
422        let source = tmp.path().join("page.rst");
423        std::fs::write(&source, "Page\n----\n").unwrap();
424
425        // 0-hour expiry: everything is expired immediately.
426        let cache = BuildCache::new(tmp.path().join("cache"), 500, 0, "fp-1").unwrap();
427        cache
428            .store_document(&source, &make_document(&source))
429            .unwrap();
430        std::thread::sleep(std::time::Duration::from_millis(1100));
431        assert!(
432            cache.get_document(&source).is_err(),
433            "entries must expire per the configured horizon"
434        );
435    }
436}