Skip to main content

sui_eval/
eval_cache.rs

1//! Content-addressed evaluation cache.
2//!
3//! Maps `(source_hash, lock_hash)` pairs to previously evaluated results,
4//! skipping redundant evaluation when inputs haven't changed.
5//!
6//! ## Tier model (additive — every tier is optional, all may stack)
7//!
8//! 1. **In-memory** (`HashMap`) for the current session — instant
9//!    lookups. Always present.
10//! 2. **JSON file** at `~/.cache/sui/eval-cache.json` — survives
11//!    across invocations. Optional; enabled by `with_persistent`.
12//! 3. **GraphStore** (`sui-graph-store`, redb + rkyv on a ZFS-friendly
13//!    blob layout) — fleet-shared / cross-process tier. Optional;
14//!    enabled by `with_graph_store`. When set, the eval cache's
15//!    entries become first-class blobs in `GraphKind::EvalCacheEntry`,
16//!    which means a peer with the same GraphStore root (e.g. via
17//!    `zfs send | zfs recv` or a future substituter push) gets every
18//!    cached eval for free. Lookup-order on `get`: memory → graph_store
19//!    (warm on disk via mmap, hits sub-200 µs); on a hit from the
20//!    graph_store tier the result is promoted into memory so the next
21//!    same-process lookup is sub-microsecond.
22//!
23//! All three tiers honor the same `enabled` flag (set by the CLI flag
24//! that disables caching entirely) and the same key shape (`CacheKey`).
25//! Adding a tier never removes an older one — `with_all_tiers` enables
26//! all three at once; individual `with_*` constructors stack them
27//! incrementally.
28//!
29//! Only JSON-serializable values are cached (no lambdas, no thunks).
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use sha2::{Digest, Sha256};
35use sui_graph_store::{GraphHash, GraphKind, GraphStore};
36
37// ── Types ──────────────────────────────────────────────────────
38
39/// Hash of a source file plus its transitive inputs (flake.lock).
40#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
41pub struct CacheKey {
42    /// SHA-256 hex digest of the source file content.
43    pub source_hash: String,
44    /// SHA-256 hex digest of `flake.lock` in the same directory (if any).
45    pub lock_hash: Option<String>,
46}
47
48/// A cached evaluation result.
49#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
50pub struct CachedValue {
51    /// The value serialized as JSON.
52    pub value_json: String,
53    /// Unix timestamp when this entry was stored.
54    pub timestamp: i64,
55}
56
57/// A single cache entry for serialization (key + value).
58#[derive(serde::Serialize, serde::Deserialize)]
59struct CacheEntry {
60    key: CacheKey,
61    value: CachedValue,
62}
63
64// ── Cache ──────────────────────────────────────────────────────
65
66/// Content-addressed evaluation cache with optional persistence.
67pub struct EvalCache {
68    /// In-memory cache for the current session.
69    memory: HashMap<CacheKey, CachedValue>,
70    /// Path to the persistent cache file (JSON).
71    db_path: Option<PathBuf>,
72    /// Optional GraphStore tier — fleet-shared / cross-process cache.
73    /// When set, entries are mirrored as `GraphKind::EvalCacheEntry`
74    /// blobs and a `get` miss in memory falls through here next.
75    graph_store: Option<GraphStore>,
76    /// Whether this cache is enabled (can be disabled via CLI flag).
77    enabled: bool,
78}
79
80impl EvalCache {
81    /// Create a new in-memory-only cache.
82    pub fn new() -> Self {
83        Self {
84            memory: HashMap::new(),
85            db_path: None,
86            graph_store: None,
87            enabled: true,
88        }
89    }
90
91    /// Create a cache with persistent storage at the given path.
92    /// Loads existing entries from disk if the file exists.
93    pub fn with_persistent(db_path: PathBuf) -> Self {
94        let memory = Self::load_from_disk(&db_path).unwrap_or_default();
95        Self {
96            memory,
97            db_path: Some(db_path),
98            graph_store: None,
99            enabled: true,
100        }
101    }
102
103    /// Create a cache using the default persistent path (`~/.cache/sui/eval-cache.json`).
104    pub fn default_persistent() -> Self {
105        match default_cache_path() {
106            Some(p) => Self::with_persistent(p),
107            None => Self::new(),
108        }
109    }
110
111    /// Create a disabled cache (always misses).
112    pub fn disabled() -> Self {
113        Self {
114            memory: HashMap::new(),
115            db_path: None,
116            graph_store: None,
117            enabled: false,
118        }
119    }
120
121    /// Stack a `GraphStore` tier on this cache. Existing tiers
122    /// (in-memory + optional JSON file) are preserved verbatim. Calls
123    /// this builder-style: `EvalCache::default_persistent().with_graph_store(gs)`.
124    #[must_use]
125    pub fn with_graph_store(mut self, store: GraphStore) -> Self {
126        self.graph_store = Some(store);
127        self
128    }
129
130    /// Construct an `EvalCache` with all three tiers enabled.
131    ///
132    /// * In-memory — always.
133    /// * JSON file — at `db_path` (also loaded on construction).
134    /// * GraphStore — using `store`.
135    #[must_use]
136    pub fn with_all_tiers(db_path: PathBuf, store: GraphStore) -> Self {
137        Self::with_persistent(db_path).with_graph_store(store)
138    }
139
140    /// Whether the cache is enabled.
141    pub fn is_enabled(&self) -> bool {
142        self.enabled
143    }
144
145    /// True iff the GraphStore tier is wired.
146    pub fn has_graph_store(&self) -> bool {
147        self.graph_store.is_some()
148    }
149
150    /// Look up a cached result. Tier order: memory → graph_store.
151    /// **Behavior contract**: a hit from the graph_store tier is
152    /// promoted into the memory tier so the next same-process lookup
153    /// is sub-microsecond. The promotion is the only mutation `get`
154    /// performs.
155    pub fn get(&mut self, key: &CacheKey) -> Option<&CachedValue> {
156        if !self.enabled {
157            return None;
158        }
159        // Tier 1: in-memory (sub-microsecond).
160        if self.memory.contains_key(key) {
161            return self.memory.get(key);
162        }
163        // Tier 3: GraphStore (sub-200 µs warm via mmap).
164        if let Some(store) = &self.graph_store {
165            let gh = graph_hash_for_key(key);
166            if let Ok(blob) = store.get(GraphKind::EvalCacheEntry, gh) {
167                if let Ok(value) = serde_json::from_slice::<CachedValue>(&blob) {
168                    self.memory.insert(key.clone(), value);
169                    return self.memory.get(key);
170                }
171            }
172        }
173        None
174    }
175
176    /// Store a result in the cache. Writes to memory unconditionally
177    /// and to every wired persistence tier (JSON + GraphStore)
178    /// best-effort. Tier writes never fail loudly — eval-cache puts
179    /// are advisory; a failed write doesn't change the correctness of
180    /// the eval, just the chance of a future hit.
181    pub fn put(&mut self, key: CacheKey, value: CachedValue) {
182        if !self.enabled {
183            return;
184        }
185        // Tier 1: memory (mandatory).
186        self.memory.insert(key.clone(), value.clone());
187        // Tier 2: JSON file (legacy persistent path).
188        if let Some(ref path) = self.db_path {
189            let _ = Self::save_to_disk(path, &self.memory);
190        }
191        // Tier 3: GraphStore (fleet-shared). Keyed by a deterministic
192        // BLAKE3 of the cache key (NOT by content hash) — the eval
193        // cache wants query-derived lookup, so this uses
194        // `put_unchecked`. Domain-separated with the `"evalcache::v1::"`
195        // prefix to keep query-derived hashes disjoint from CAS hashes
196        // in the same GraphKind.
197        if let Some(store) = &self.graph_store {
198            if let Ok(blob) = serde_json::to_vec(&value) {
199                let lookup_hash = graph_hash_for_key(&key);
200                let _ = store.put_unchecked(GraphKind::EvalCacheEntry, lookup_hash, &blob);
201            }
202        }
203    }
204
205    /// Number of cached entries.
206    pub fn len(&self) -> usize {
207        self.memory.len()
208    }
209
210    /// Whether the cache is empty.
211    pub fn is_empty(&self) -> bool {
212        self.memory.is_empty()
213    }
214
215    /// Compute the cache key for a file on disk.
216    ///
217    /// The source hash is the SHA-256 of the file content. If a `flake.lock`
218    /// exists in the same directory, its hash is included as the lock_hash.
219    pub fn key_for_file(path: &Path) -> Option<CacheKey> {
220        let content = std::fs::read(path).ok()?;
221        let source_hash = sha256_hex(&content);
222
223        let lock_hash = path
224            .parent()
225            .map(|dir| dir.join("flake.lock"))
226            .filter(|p| p.exists())
227            .and_then(|p| std::fs::read(p).ok())
228            .map(|c| sha256_hex(&c));
229
230        Some(CacheKey {
231            source_hash,
232            lock_hash,
233        })
234    }
235
236    // ── Persistence helpers ────────────────────────────────────
237
238    fn load_from_disk(path: &Path) -> Option<HashMap<CacheKey, CachedValue>> {
239        let data = std::fs::read_to_string(path).ok()?;
240        let entries: Vec<CacheEntry> = serde_json::from_str(&data).ok()?;
241        let mut map = HashMap::with_capacity(entries.len());
242        for entry in entries {
243            map.insert(entry.key, entry.value);
244        }
245        Some(map)
246    }
247
248    fn save_to_disk(
249        path: &Path,
250        memory: &HashMap<CacheKey, CachedValue>,
251    ) -> Result<(), std::io::Error> {
252        if let Some(parent) = path.parent() {
253            std::fs::create_dir_all(parent)?;
254        }
255        let entries: Vec<CacheEntry> = memory
256            .iter()
257            .map(|(k, v)| CacheEntry {
258                key: k.clone(),
259                value: v.clone(),
260            })
261            .collect();
262        let json = serde_json::to_string(&entries)
263            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
264        std::fs::write(path, json)
265    }
266}
267
268impl Default for EvalCache {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274// ── Helpers ────────────────────────────────────────────────────
275
276/// Derive a deterministic `GraphHash` from an eval-cache `CacheKey`,
277/// domain-separated so it can't collide with content-addressed entries
278/// stored under the same `GraphKind`. The serialization is canonical
279/// because we control both fields: `(source_hash, lock_hash)` are
280/// already SHA-256 hex digests with stable ordering.
281fn graph_hash_for_key(key: &CacheKey) -> GraphHash {
282    let mut hasher = blake3::Hasher::new();
283    hasher.update(b"evalcache::v1::");
284    hasher.update(key.source_hash.as_bytes());
285    hasher.update(b"::");
286    if let Some(lock) = &key.lock_hash {
287        hasher.update(lock.as_bytes());
288    } else {
289        hasher.update(b"<no-lock>");
290    }
291    GraphHash(hasher.finalize().into())
292}
293
294/// SHA-256 hex digest of a byte slice.
295fn sha256_hex(data: &[u8]) -> String {
296    let mut hasher = Sha256::new();
297    hasher.update(data);
298    format!("{:x}", hasher.finalize())
299}
300
301/// Default persistent cache path.
302///
303/// `SUI_EVAL_CACHE_PATH` overrides the location outright (used for hermetic
304/// tests and for an operator who wants the warm eval-store on a specific
305/// disk/ZFS dataset). Otherwise: `$cache_dir/sui/eval-cache.json`, where
306/// `$cache_dir` is `~/Library/Caches` (macOS) or `$XDG_CACHE_HOME`/`~/.cache`.
307fn default_cache_path() -> Option<PathBuf> {
308    if let Ok(p) = std::env::var("SUI_EVAL_CACHE_PATH") {
309        if !p.is_empty() {
310            return Some(PathBuf::from(p));
311        }
312    }
313    dirs_next().map(|d| d.join("sui").join("eval-cache.json"))
314}
315
316/// Platform cache directory (`$XDG_CACHE_HOME` or `~/.cache`).
317fn dirs_next() -> Option<PathBuf> {
318    if let Ok(val) = std::env::var("XDG_CACHE_HOME") {
319        if !val.is_empty() {
320            return Some(PathBuf::from(val));
321        }
322    }
323    #[cfg(target_os = "macos")]
324    {
325        home_dir().map(|h| h.join("Library").join("Caches"))
326    }
327    #[cfg(not(target_os = "macos"))]
328    {
329        home_dir().map(|h| h.join(".cache"))
330    }
331}
332
333fn home_dir() -> Option<PathBuf> {
334    std::env::var("HOME").ok().map(PathBuf::from)
335}
336
337/// Return the current Unix timestamp (seconds since epoch).
338pub fn now_timestamp() -> i64 {
339    std::time::SystemTime::now()
340        .duration_since(std::time::UNIX_EPOCH)
341        .map(|d| d.as_secs() as i64)
342        .unwrap_or(0)
343}
344
345// ── Tests ──────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn cache_hit_returns_same_value() {
353        let mut cache = EvalCache::new();
354        let key = CacheKey {
355            source_hash: "abc123".to_string(),
356            lock_hash: None,
357        };
358        let value = CachedValue {
359            value_json: r#"{"type":"int","value":42}"#.to_string(),
360            timestamp: 1000,
361        };
362        cache.put(key.clone(), value.clone());
363        let got = cache.get(&key).unwrap();
364        assert_eq!(got.value_json, value.value_json);
365    }
366
367    #[test]
368    fn cache_miss_returns_none() {
369        let mut cache = EvalCache::new();
370        let key = CacheKey {
371            source_hash: "nonexistent".to_string(),
372            lock_hash: None,
373        };
374        assert!(cache.get(&key).is_none());
375    }
376
377    #[test]
378    fn different_content_different_key() {
379        let mut cache = EvalCache::new();
380        let k1 = CacheKey {
381            source_hash: sha256_hex(b"file content A"),
382            lock_hash: None,
383        };
384        let k2 = CacheKey {
385            source_hash: sha256_hex(b"file content B"),
386            lock_hash: None,
387        };
388        cache.put(
389            k1.clone(),
390            CachedValue {
391                value_json: "A".to_string(),
392                timestamp: 1,
393            },
394        );
395        assert!(cache.get(&k1).is_some());
396        assert!(cache.get(&k2).is_none());
397    }
398
399    #[test]
400    fn lock_hash_change_invalidates() {
401        let mut cache = EvalCache::new();
402        let k1 = CacheKey {
403            source_hash: "same".to_string(),
404            lock_hash: Some("lock-v1".to_string()),
405        };
406        let k2 = CacheKey {
407            source_hash: "same".to_string(),
408            lock_hash: Some("lock-v2".to_string()),
409        };
410        cache.put(
411            k1.clone(),
412            CachedValue {
413                value_json: "v1".to_string(),
414                timestamp: 1,
415            },
416        );
417        assert!(cache.get(&k1).is_some());
418        assert!(cache.get(&k2).is_none());
419    }
420
421    #[test]
422    fn disabled_cache_always_misses() {
423        let mut cache = EvalCache::disabled();
424        let key = CacheKey {
425            source_hash: "abc".to_string(),
426            lock_hash: None,
427        };
428        cache.put(
429            key.clone(),
430            CachedValue {
431                value_json: "x".to_string(),
432                timestamp: 1,
433            },
434        );
435        assert!(cache.get(&key).is_none());
436    }
437
438    #[test]
439    fn key_for_file_hashes_content() {
440        let dir = std::env::temp_dir().join("sui-eval-cache-test");
441        let _ = std::fs::create_dir_all(&dir);
442        let path = dir.join("test.nix");
443        std::fs::write(&path, "1 + 2").unwrap();
444
445        let key = EvalCache::key_for_file(&path).unwrap();
446        assert!(!key.source_hash.is_empty());
447        assert!(key.lock_hash.is_none()); // no flake.lock
448
449        let _ = std::fs::remove_file(&path);
450        let _ = std::fs::remove_dir(&dir);
451    }
452
453    #[test]
454    fn key_for_file_with_flake_lock() {
455        let dir = std::env::temp_dir().join("sui-eval-cache-test-lock");
456        let _ = std::fs::create_dir_all(&dir);
457        let path = dir.join("flake.nix");
458        let lock = dir.join("flake.lock");
459        std::fs::write(&path, "{ }").unwrap();
460        std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
461
462        let key = EvalCache::key_for_file(&path).unwrap();
463        assert!(key.lock_hash.is_some());
464
465        let _ = std::fs::remove_file(&path);
466        let _ = std::fs::remove_file(&lock);
467        let _ = std::fs::remove_dir(&dir);
468    }
469
470    #[test]
471    fn persistent_roundtrip() {
472        let dir = std::env::temp_dir().join("sui-eval-cache-persist");
473        let _ = std::fs::create_dir_all(&dir);
474        let db = dir.join("test-cache.json");
475
476        // Write
477        {
478            let mut c = EvalCache::with_persistent(db.clone());
479            c.put(
480                CacheKey {
481                    source_hash: "h1".to_string(),
482                    lock_hash: None,
483                },
484                CachedValue {
485                    value_json: r#""hello""#.to_string(),
486                    timestamp: now_timestamp(),
487                },
488            );
489            assert_eq!(c.len(), 1);
490        }
491
492        // Read back
493        {
494            let mut c = EvalCache::with_persistent(db.clone());
495            let key = CacheKey {
496                source_hash: "h1".to_string(),
497                lock_hash: None,
498            };
499            let v = c.get(&key).unwrap();
500            assert_eq!(v.value_json, r#""hello""#);
501        }
502
503        let _ = std::fs::remove_file(&db);
504        let _ = std::fs::remove_dir(&dir);
505    }
506
507    // ── GraphStore tier — additive integration tests ───────────────
508
509    fn temp_graph_store() -> (tempfile::TempDir, GraphStore) {
510        let dir = tempfile::tempdir().unwrap();
511        let store = GraphStore::open(dir.path().to_path_buf()).unwrap();
512        (dir, store)
513    }
514
515    #[test]
516    fn graph_store_tier_round_trips_a_value() {
517        let (_dir, store) = temp_graph_store();
518        let mut cache = EvalCache::new().with_graph_store(store);
519        assert!(cache.has_graph_store());
520
521        let key = CacheKey {
522            source_hash: sha256_hex(b"some source"),
523            lock_hash: Some(sha256_hex(b"some lock")),
524        };
525        let value = CachedValue {
526            value_json: r#"{"answer":42}"#.to_string(),
527            timestamp: 1_700_000_000,
528        };
529
530        cache.put(key.clone(), value.clone());
531        let got = cache.get(&key).expect("memory tier hits");
532        assert_eq!(got.value_json, value.value_json);
533    }
534
535    #[test]
536    fn graph_store_tier_survives_fresh_cache_instance() {
537        let (_dir, store) = temp_graph_store();
538        let key = CacheKey {
539            source_hash: sha256_hex(b"persist me"),
540            lock_hash: None,
541        };
542        let value = CachedValue {
543            value_json: r#""persisted""#.to_string(),
544            timestamp: 42,
545        };
546
547        // First cache writes; drops.
548        {
549            let mut c = EvalCache::new().with_graph_store(store.clone());
550            c.put(key.clone(), value.clone());
551        }
552
553        // Fresh cache, same GraphStore — must promote on first read.
554        let mut c2 = EvalCache::new().with_graph_store(store);
555        let got = c2.get(&key).expect("graph_store tier hits");
556        assert_eq!(got.value_json, value.value_json);
557        // Promotion: next lookup must hit memory (no GraphStore round-trip).
558        let again = c2.get(&key).expect("memory promotion");
559        assert_eq!(again.value_json, value.value_json);
560    }
561
562    #[test]
563    fn graph_store_tier_isolates_by_cache_key() {
564        let (_dir, store) = temp_graph_store();
565        let mut cache = EvalCache::new().with_graph_store(store);
566
567        let k_a = CacheKey {
568            source_hash: sha256_hex(b"file a"),
569            lock_hash: None,
570        };
571        let k_b = CacheKey {
572            source_hash: sha256_hex(b"file b"),
573            lock_hash: None,
574        };
575
576        cache.put(
577            k_a.clone(),
578            CachedValue {
579                value_json: "A".to_string(),
580                timestamp: 1,
581            },
582        );
583
584        // Second key must miss — domain-separated lookup hash must
585        // not collide.
586        assert!(cache.get(&k_b).is_none());
587        assert!(cache.get(&k_a).is_some());
588    }
589
590    #[test]
591    fn graph_store_tier_disabled_when_cache_disabled() {
592        let (_dir, store) = temp_graph_store();
593        let mut cache = EvalCache::disabled().with_graph_store(store);
594        let key = CacheKey {
595            source_hash: "x".to_string(),
596            lock_hash: None,
597        };
598        cache.put(
599            key.clone(),
600            CachedValue {
601                value_json: "y".to_string(),
602                timestamp: 0,
603            },
604        );
605        assert!(cache.get(&key).is_none());
606    }
607
608    #[test]
609    fn all_three_tiers_stack_cleanly() {
610        let dir = tempfile::tempdir().unwrap();
611        let db_path = dir.path().join("eval-cache.json");
612        let (_gdir, store) = temp_graph_store();
613
614        let key = CacheKey {
615            source_hash: sha256_hex(b"triple-tier source"),
616            lock_hash: None,
617        };
618        let value = CachedValue {
619            value_json: r#""triple-tier""#.to_string(),
620            timestamp: 99,
621        };
622
623        // First cache writes through all three tiers.
624        {
625            let mut c = EvalCache::with_all_tiers(db_path.clone(), store.clone());
626            c.put(key.clone(), value.clone());
627        }
628
629        // Fresh cache pointed at the same JSON file (no GraphStore)
630        // must still hit (Tier 2 — JSON persistence preserved).
631        {
632            let mut c = EvalCache::with_persistent(db_path.clone());
633            assert!(c.get(&key).is_some(), "tier 2 (JSON) must still serve");
634        }
635
636        // Fresh cache pointed at the GraphStore only must also hit
637        // (Tier 3 — fleet-shared persistence preserved).
638        {
639            let mut c = EvalCache::new().with_graph_store(store);
640            assert!(c.get(&key).is_some(), "tier 3 (GraphStore) must still serve");
641        }
642    }
643
644    #[test]
645    fn sha256_hex_deterministic() {
646        let a = sha256_hex(b"hello");
647        let b = sha256_hex(b"hello");
648        assert_eq!(a, b);
649        assert_ne!(a, sha256_hex(b"world"));
650    }
651
652    #[test]
653    fn now_timestamp_reasonable() {
654        let ts = now_timestamp();
655        // Should be after 2020 and before 2100
656        assert!(ts > 1_577_836_800);
657        assert!(ts < 4_102_444_800);
658    }
659
660    #[test]
661    fn len_and_is_empty() {
662        let mut cache = EvalCache::new();
663        assert!(cache.is_empty());
664        assert_eq!(cache.len(), 0);
665        cache.put(
666            CacheKey { source_hash: "x".to_string(), lock_hash: None },
667            CachedValue { value_json: "1".to_string(), timestamp: 1 },
668        );
669        assert!(!cache.is_empty());
670        assert_eq!(cache.len(), 1);
671    }
672}