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    config_fingerprint: String,
19    config_changed: bool,
20    documents: Arc<DashMap<PathBuf, CachedDocument>>,
21    file_hashes: Arc<RwLock<HashMap<PathBuf, String>>>,
22    hit_count: Arc<RwLock<usize>>,
23    miss_count: Arc<RwLock<usize>>,
24    max_size_mb: usize,
25    expiration_duration: Duration,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29struct CachedDocument {
30    document: Document,
31    hash: String,
32    cached_at: DateTime<Utc>,
33    access_count: usize,
34    size_bytes: usize,
35}
36
37impl BuildCache {
38    pub fn new(
39        cache_dir: PathBuf,
40        max_size_mb: usize,
41        expiration_hours: u64,
42        config_fingerprint: &str,
43    ) -> Result<Self> {
44        std::fs::create_dir_all(&cache_dir)?;
45
46        // Cached documents were produced under a specific configuration; if
47        // the configuration changed, everything in the cache is stale.
48        let fingerprint_file = cache_dir.join(".config-fingerprint");
49        let stored = std::fs::read_to_string(&fingerprint_file).unwrap_or_default();
50        let config_changed = stored.trim() != config_fingerprint;
51        if config_changed {
52            if !stored.is_empty() {
53                debug!("Configuration changed; discarding cache");
54            }
55            std::fs::remove_dir_all(&cache_dir)?;
56            std::fs::create_dir_all(&cache_dir)?;
57            std::fs::write(&fingerprint_file, config_fingerprint)?;
58        }
59
60        let cache = Self {
61            cache_dir,
62            config_fingerprint: config_fingerprint.to_string(),
63            config_changed,
64            documents: Arc::new(DashMap::new()),
65            file_hashes: Arc::new(RwLock::new(HashMap::new())),
66            hit_count: Arc::new(RwLock::new(0)),
67            miss_count: Arc::new(RwLock::new(0)),
68            max_size_mb,
69            expiration_duration: Duration::from_secs(expiration_hours * 60 * 60),
70        };
71
72        // Load existing cache from disk
73        cache.load_from_disk()?;
74
75        Ok(cache)
76    }
77
78    /// The cache directory this cache was constructed with (post config-
79    /// fingerprint validation/wipe). Callers that persist their own files
80    /// alongside the document cache -- e.g. `BuildEnvironment::save`'s
81    /// `env.bin` -- ride the same directory and are therefore covered by
82    /// the same fingerprint-mismatch wipe.
83    pub fn cache_dir(&self) -> &Path {
84        &self.cache_dir
85    }
86
87    /// Whether this cache directory's stored fingerprint disagreed with the
88    /// configuration it was opened with — the whole directory was then
89    /// discarded, this build's documents, doctrees and `env.bin` included.
90    ///
91    /// A first build counts too (there is no stored fingerprint to agree
92    /// with), and in that case the effect matches Sphinx's `CONFIG_NEW`:
93    /// nothing carried over from a previous build is usable, so every
94    /// document is outdated.
95    ///
96    /// The resemblance stops there, and deliberately so. Sphinx's
97    /// `CONFIG_CHANGED` (`environment/__init__.py:366-369`) fires only for
98    /// config values whose rebuild class is `'env'` and never deletes
99    /// doctrees, the environment or the intersphinx cache; this crate
100    /// compares one whole-configuration digest and wipes the directory. The
101    /// digest is therefore taken over a *filtered* configuration — see
102    /// `builder::EXCLUDED_FROM_FINGERPRINT` — so that operational flags
103    /// (`-W`, `-n`), which Sphinx cannot invalidate on, cannot invalidate
104    /// here either.
105    pub fn config_changed(&self) -> bool {
106        self.config_changed
107    }
108
109    pub fn get_document(&self, file_path: &Path) -> Result<Document> {
110        self.get_document_with(file_path, |_| Some(()))
111            .map(|(document, ())| document)
112            .ok_or_else(|| BuildError::Cache("Document not found in cache".to_string()).into())
113    }
114
115    /// Look up a cached document, letting the caller have the final say.
116    ///
117    /// `accept` runs only after the entry passed the cache's own checks
118    /// (content+mtime hash, expiry). Returning `None` from it means the
119    /// caller cannot actually use the entry — because some companion state
120    /// it needs is missing, say — and the lookup is then counted and
121    /// reported as a **miss**, not a hit: a "hit" the build has to redo
122    /// anyway is not a hit. Anything `accept` computes from the document
123    /// (loading that companion state) comes back alongside it, so callers
124    /// don't have to do the work twice.
125    pub fn get_document_with<T>(
126        &self,
127        file_path: &Path,
128        accept: impl FnOnce(&Document) -> Option<T>,
129    ) -> Option<(Document, T)> {
130        let hash = match self.calculate_file_hash(file_path) {
131            Ok(hash) => hash,
132            Err(_) => {
133                *self.miss_count.write() += 1;
134                return None;
135            }
136        };
137
138        // Clone what we need out of the `get` guard before touching the map
139        // again: holding a DashMap `Ref` while calling `alter` on the same
140        // key deadlocks on the shard lock.
141        let cached = self
142            .documents
143            .get(file_path)
144            .map(|c| (c.hash.clone(), c.cached_at, c.document.clone()));
145
146        if let Some((cached_hash, cached_at, document)) = cached {
147            if cached_hash == hash && !self.is_expired(&cached_at) {
148                if let Some(extra) = accept(&document) {
149                    // Update access count
150                    self.documents.alter(file_path, |_, mut cached| {
151                        cached.access_count += 1;
152                        cached
153                    });
154
155                    *self.hit_count.write() += 1;
156                    debug!("Cache hit for {}", file_path.display());
157                    return Some((document, extra));
158                }
159            } else {
160                // Remove expired or outdated entry. A caller-rejected entry
161                // is left alone: it is still a valid record of the file,
162                // and the rebuild that follows overwrites it anyway.
163                self.documents.remove(file_path);
164            }
165        }
166
167        *self.miss_count.write() += 1;
168        debug!("Cache miss for {}", file_path.display());
169        None
170    }
171
172    pub fn store_document(&self, file_path: &Path, document: &Document) -> Result<()> {
173        let hash = self.calculate_file_hash(file_path)?;
174        let size_bytes = self.estimate_document_size(document);
175
176        let cached_doc = CachedDocument {
177            document: document.clone(),
178            hash: hash.clone(),
179            cached_at: Utc::now(),
180            access_count: 1,
181            size_bytes,
182        };
183
184        // Check if we need to evict some entries
185        self.evict_if_needed(size_bytes)?;
186
187        self.documents.insert(file_path.to_path_buf(), cached_doc);
188        self.file_hashes
189            .write()
190            .insert(file_path.to_path_buf(), hash.clone());
191
192        debug!(
193            "Cached document: {} ({} bytes)",
194            file_path.display(),
195            size_bytes
196        );
197
198        // Persist to disk asynchronously
199        self.persist_to_disk(file_path, document)?;
200
201        Ok(())
202    }
203
204    #[allow(dead_code)]
205    pub fn invalidate(&self, file_path: &Path) {
206        self.documents.remove(file_path);
207        self.file_hashes.write().remove(file_path);
208
209        // Remove from disk cache
210        let cache_file = self.get_cache_file_path(file_path);
211        if cache_file.exists() {
212            if let Err(e) = std::fs::remove_file(&cache_file) {
213                warn!(
214                    "Failed to remove cache file {}: {}",
215                    cache_file.display(),
216                    e
217                );
218            }
219        }
220
221        debug!("Invalidated cache for {}", file_path.display());
222    }
223
224    /// Empty the cache, in memory and on disk (`-E`, `--clean`).
225    ///
226    /// The fingerprint file is written back immediately: it records which
227    /// configuration the *directory* belongs to, and leaving it missing
228    /// would make the next build mistake this deliberate emptying for a
229    /// configuration change and throw away everything the build that
230    /// follows this one is about to cache.
231    #[allow(dead_code)]
232    pub fn clear(&self) -> Result<()> {
233        self.documents.clear();
234        self.file_hashes.write().clear();
235        *self.hit_count.write() = 0;
236        *self.miss_count.write() = 0;
237
238        if self.cache_dir.exists() {
239            std::fs::remove_dir_all(&self.cache_dir)?;
240        }
241        std::fs::create_dir_all(&self.cache_dir)?;
242        std::fs::write(
243            self.cache_dir.join(".config-fingerprint"),
244            &self.config_fingerprint,
245        )?;
246
247        debug!("Cleared all cache");
248        Ok(())
249    }
250
251    pub fn hit_count(&self) -> usize {
252        *self.hit_count.read()
253    }
254
255    #[allow(dead_code)]
256    pub fn miss_count(&self) -> usize {
257        *self.miss_count.read()
258    }
259
260    #[allow(dead_code)]
261    pub fn hit_ratio(&self) -> f64 {
262        let hits = *self.hit_count.read() as f64;
263        let misses = *self.miss_count.read() as f64;
264        if hits + misses > 0.0 {
265            hits / (hits + misses)
266        } else {
267            0.0
268        }
269    }
270
271    pub fn size_mb(&self) -> f64 {
272        let total_bytes: usize = self
273            .documents
274            .iter()
275            .map(|entry| entry.value().size_bytes)
276            .sum();
277        total_bytes as f64 / 1024.0 / 1024.0
278    }
279
280    fn calculate_file_hash(&self, file_path: &Path) -> Result<String> {
281        let content = std::fs::read(file_path)?;
282        let metadata = std::fs::metadata(file_path)?;
283
284        let mut hasher = Hasher::new();
285        hasher.update(&content);
286
287        // Include file metadata in hash
288        if let Ok(modified) = metadata.modified() {
289            if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
290                hasher.update(&duration.as_secs().to_le_bytes());
291            }
292        }
293
294        Ok(hasher.finalize().to_hex().to_string())
295    }
296
297    fn is_expired(&self, cached_at: &DateTime<Utc>) -> bool {
298        let now = Utc::now();
299        let elapsed = now.signed_duration_since(*cached_at);
300        elapsed.num_seconds() > self.expiration_duration.as_secs() as i64
301    }
302
303    fn estimate_document_size(&self, document: &Document) -> usize {
304        // Rough estimate of document size in memory
305        document.html.len()
306            + document.title.len()
307            + document.source_path.to_string_lossy().len()
308            + document.output_path.to_string_lossy().len()
309            + 1024 // Overhead for other fields
310    }
311
312    fn evict_if_needed(&self, new_size: usize) -> Result<()> {
313        let current_size_mb = self.size_mb();
314        let new_size_mb = (new_size as f64) / 1024.0 / 1024.0;
315
316        if current_size_mb + new_size_mb > self.max_size_mb as f64 {
317            self.evict_least_accessed_entries(new_size_mb)?;
318        }
319
320        Ok(())
321    }
322
323    /// Evict entries with the lowest access counts (LFU-style). This is not
324    /// LRU — recency is not tracked — and is named accordingly.
325    fn evict_least_accessed_entries(&self, space_needed_mb: f64) -> Result<()> {
326        let mut entries: Vec<_> = self
327            .documents
328            .iter()
329            .map(|entry| {
330                (
331                    entry.key().clone(),
332                    entry.value().access_count,
333                    entry.value().size_bytes,
334                )
335            })
336            .collect();
337
338        // Sort by access count (least-accessed first)
339        entries.sort_by_key(|(_, access_count, _)| *access_count);
340
341        let mut space_freed_mb = 0.0;
342        for (path, _, size_bytes) in entries {
343            if space_freed_mb >= space_needed_mb {
344                break;
345            }
346
347            self.documents.remove(&path);
348            self.file_hashes.write().remove(&path);
349            space_freed_mb += (size_bytes as f64) / 1024.0 / 1024.0;
350
351            debug!(
352                "Evicted {} from cache ({} MB)",
353                path.display(),
354                size_bytes as f64 / 1024.0 / 1024.0
355            );
356        }
357
358        Ok(())
359    }
360
361    fn load_from_disk(&self) -> Result<()> {
362        if !self.cache_dir.exists() {
363            return Ok(());
364        }
365
366        for entry in std::fs::read_dir(&self.cache_dir)? {
367            let entry = entry?;
368            if entry.file_type()?.is_file()
369                && entry.path().extension().is_some_and(|ext| ext == "json")
370            {
371                if let Err(e) = self.load_cache_file(&entry.path()) {
372                    warn!(
373                        "Failed to load cache file {}: {}",
374                        entry.path().display(),
375                        e
376                    );
377                }
378            }
379        }
380
381        debug!("Loaded {} documents from disk cache", self.documents.len());
382        Ok(())
383    }
384
385    fn load_cache_file(&self, cache_file: &Path) -> Result<()> {
386        let content = std::fs::read_to_string(cache_file)?;
387        let cached_doc: CachedDocument = serde_json::from_str(&content)?;
388
389        // Check if the cached document is still valid
390        if !self.is_expired(&cached_doc.cached_at) {
391            let source_path = &cached_doc.document.source_path;
392            if source_path.exists() {
393                let current_hash = self.calculate_file_hash(source_path)?;
394                if current_hash == cached_doc.hash {
395                    self.documents.insert(source_path.clone(), cached_doc);
396                }
397            }
398        }
399
400        Ok(())
401    }
402
403    fn persist_to_disk(&self, file_path: &Path, _document: &Document) -> Result<()> {
404        let cache_file = self.get_cache_file_path(file_path);
405        if let Some(parent) = cache_file.parent() {
406            std::fs::create_dir_all(parent)?;
407        }
408
409        if let Some(cached_doc) = self.documents.get(file_path) {
410            let content = serde_json::to_string_pretty(&*cached_doc)?;
411            std::fs::write(&cache_file, content)?;
412        }
413
414        Ok(())
415    }
416
417    fn get_cache_file_path(&self, file_path: &Path) -> PathBuf {
418        let hash = blake3::hash(file_path.to_string_lossy().as_bytes());
419        let filename = format!("{}.json", hash.to_hex());
420        self.cache_dir.join(filename)
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use tempfile::TempDir;
428
429    fn make_document(source: &Path) -> Document {
430        let mut doc = Document::new(source.to_path_buf(), source.with_extension("html"));
431        doc.html = "<html><body>cached</body></html>".to_string();
432        doc.source_mtime = Utc::now();
433        doc
434    }
435
436    #[test]
437    fn roundtrip_preserves_rendered_html() {
438        let tmp = TempDir::new().unwrap();
439        let source = tmp.path().join("page.rst");
440        std::fs::write(&source, "Page\n----\n").unwrap();
441
442        let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
443        cache
444            .store_document(&source, &make_document(&source))
445            .unwrap();
446
447        let restored = cache.get_document(&source).unwrap();
448        assert_eq!(restored.html, "<html><body>cached</body></html>");
449        assert_eq!(cache.hit_count(), 1);
450    }
451
452    #[test]
453    fn warm_hit_does_not_deadlock() {
454        // Regression: `get` guard held across `alter` on the same DashMap key
455        // deadlocked every warm incremental rebuild.
456        let tmp = TempDir::new().unwrap();
457        let source = tmp.path().join("page.rst");
458        std::fs::write(&source, "Page\n----\n").unwrap();
459
460        let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
461        cache
462            .store_document(&source, &make_document(&source))
463            .unwrap();
464        for _ in 0..3 {
465            cache.get_document(&source).unwrap();
466        }
467        assert_eq!(cache.hit_count(), 3);
468    }
469
470    #[test]
471    fn caller_rejected_entry_counts_as_a_miss() {
472        let tmp = TempDir::new().unwrap();
473        let source = tmp.path().join("page.rst");
474        std::fs::write(&source, "Page\n----\n").unwrap();
475
476        let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
477        cache
478            .store_document(&source, &make_document(&source))
479            .unwrap();
480
481        let rejected = cache.get_document_with(&source, |_| None::<()>);
482        assert!(rejected.is_none());
483        assert_eq!(cache.hit_count(), 0, "a rejected entry is not a hit");
484        assert_eq!(cache.miss_count(), 1);
485
486        // The entry survives rejection, so a later accepting lookup hits.
487        let accepted = cache.get_document_with(&source, |doc| Some(doc.html.clone()));
488        assert_eq!(
489            accepted.map(|(_, html)| html).as_deref(),
490            Some("<html><body>cached</body></html>")
491        );
492        assert_eq!(cache.hit_count(), 1);
493    }
494
495    #[test]
496    fn changed_fingerprint_discards_persisted_cache() {
497        let tmp = TempDir::new().unwrap();
498        let source = tmp.path().join("page.rst");
499        std::fs::write(&source, "Page\n----\n").unwrap();
500        let cache_dir = tmp.path().join("cache");
501
502        {
503            let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
504            cache
505                .store_document(&source, &make_document(&source))
506                .unwrap();
507        }
508
509        // Same fingerprint: persisted entry survives.
510        {
511            let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
512            assert!(cache.get_document(&source).is_ok());
513        }
514
515        // Different fingerprint: cache is wiped.
516        {
517            let cache = BuildCache::new(cache_dir, 500, 24, "fp-2").unwrap();
518            assert!(cache.get_document(&source).is_err());
519        }
520    }
521
522    #[test]
523    fn clearing_keeps_the_directory_claimed_by_this_configuration() {
524        let tmp = TempDir::new().unwrap();
525        let source = tmp.path().join("page.rst");
526        std::fs::write(&source, "Page\n----\n").unwrap();
527        let cache_dir = tmp.path().join("cache");
528
529        {
530            let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
531            cache.clear().unwrap();
532            // What a build after `-E`/`--clean` caches:
533            cache
534                .store_document(&source, &make_document(&source))
535                .unwrap();
536        }
537
538        let cache = BuildCache::new(cache_dir, 500, 24, "fp-1").unwrap();
539        assert!(
540            !cache.config_changed(),
541            "an emptied cache still belongs to the configuration that emptied it"
542        );
543        assert!(
544            cache.get_document(&source).is_ok(),
545            "a cleared cache that was refilled must survive to the next build"
546        );
547    }
548
549    #[test]
550    fn config_changed_reports_the_wipe() {
551        let tmp = TempDir::new().unwrap();
552        let cache_dir = tmp.path().join("cache");
553
554        // A first build has nothing to agree with: everything is new.
555        assert!(BuildCache::new(cache_dir.clone(), 500, 24, "fp-1")
556            .unwrap()
557            .config_changed());
558        assert!(!BuildCache::new(cache_dir.clone(), 500, 24, "fp-1")
559            .unwrap()
560            .config_changed());
561        assert!(BuildCache::new(cache_dir, 500, 24, "fp-2")
562            .unwrap()
563            .config_changed());
564    }
565
566    #[test]
567    fn expiration_hours_are_plumbed() {
568        let tmp = TempDir::new().unwrap();
569        let source = tmp.path().join("page.rst");
570        std::fs::write(&source, "Page\n----\n").unwrap();
571
572        // 0-hour expiry: everything is expired immediately.
573        let cache = BuildCache::new(tmp.path().join("cache"), 500, 0, "fp-1").unwrap();
574        cache
575            .store_document(&source, &make_document(&source))
576            .unwrap();
577        std::thread::sleep(std::time::Duration::from_millis(1100));
578        assert!(
579            cache.get_document(&source).is_err(),
580            "entries must expire per the configured horizon"
581        );
582    }
583}