Skip to main content

sui_spec/
eval_cache.rs

1//! Typed border for the evaluation cache.
2//!
3//! cppnix has `eval-cache-v5.sqlite`; sui has `sui-cache-eval`
4//! (BLAKE3-keyed memoization).  Both serve the same purpose:
5//! avoid re-evaluating expressions whose dependencies haven't
6//! changed.  This module names the contract.
7
8use serde::{Deserialize, Serialize};
9use tatara_lisp::DeriveTataraDomain;
10
11use crate::SpecError;
12
13#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
14#[tatara(keyword = "defeval-cache-format")]
15pub struct EvalCacheFormat {
16    pub name: String,
17    pub version: u32,
18    pub backend: EvalCacheBackend,
19    #[serde(rename = "hashAlgo")]
20    pub hash_algo: EvalCacheHash,
21    /// What the cache key is computed over.
22    #[serde(rename = "keyInput")]
23    pub key_input: EvalCacheKeyInput,
24    /// Path the cache lives at (relative to state-root).
25    #[serde(rename = "defaultPath")]
26    pub default_path: String,
27}
28
29#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
30pub enum EvalCacheBackend {
31    /// SQLite database (cppnix).
32    SQLite,
33    /// `redb` content-addressed key-value store (sui).
34    Redb,
35}
36
37#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
38pub enum EvalCacheHash {
39    /// sha256 of canonicalised expression + dep narHashes (cppnix).
40    Sha256,
41    /// BLAKE3 (sui — same conceptual key, faster algorithm).
42    Blake3,
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
46pub enum EvalCacheKeyInput {
47    /// Cache key = hash(expr-text + flake-input-narHashes +
48    /// system + impure-env-vars + nix-version).
49    ExprPlusInputs,
50    /// Cache key = hash of the canonical AST encoding only
51    /// (impure surface excluded — for pure expressions).
52    CanonicalAst,
53}
54
55pub const CANONICAL_EVAL_CACHE_LISP: &str =
56    include_str!("../specs/eval_cache.lisp");
57
58/// Compile every authored eval-cache format.
59///
60/// # Errors
61///
62/// Returns an error if the Lisp source fails to parse.
63pub fn load_canonical() -> Result<Vec<EvalCacheFormat>, SpecError> {
64    crate::loader::load_all::<EvalCacheFormat>(CANONICAL_EVAL_CACHE_LISP)
65}
66
67// ── M3.0 cache-key derivation ──────────────────────────────────────
68
69/// Inputs to a cache-key derivation.  When `key_input ==
70/// ExprPlusInputs`, all fields contribute; for `CanonicalAst`
71/// only `expr` does.
72#[derive(Debug, Clone, Default)]
73pub struct CacheKeyInputs {
74    pub expr: String,
75    pub flake_input_narhashes: Vec<(String, String)>,
76    pub system: String,
77    pub impure_env: Vec<(String, String)>,
78    pub nix_version: String,
79}
80
81/// Derive a cache key for an evaluation.  M3.0 uses hex of sha256
82/// (or blake3) of a canonical text serialisation of the inputs.
83/// The resulting key is what the cache database indexes by.
84///
85/// Format-specific:
86/// - `sha256` → 64-char lowercase hex.
87/// - `blake3` → 64-char lowercase hex (BLAKE3 produces 32 bytes
88///   like sha256, conveniently).
89///
90/// # Errors
91///
92/// Returns no error today; the signature is `Result` to keep room
93/// for M3.1 (when the impure-env may need decryption etc.).
94pub fn derive_cache_key(
95    format: &EvalCacheFormat,
96    inputs: &CacheKeyInputs,
97) -> Result<String, SpecError> {
98    let mut buf = String::new();
99    buf.push_str("nix-version=");
100    buf.push_str(&inputs.nix_version);
101    buf.push('\n');
102    if format.key_input == EvalCacheKeyInput::ExprPlusInputs {
103        buf.push_str("system=");
104        buf.push_str(&inputs.system);
105        buf.push('\n');
106        let mut narhashes = inputs.flake_input_narhashes.clone();
107        narhashes.sort();
108        for (k, v) in &narhashes {
109            buf.push_str("input.");
110            buf.push_str(k);
111            buf.push('=');
112            buf.push_str(v);
113            buf.push('\n');
114        }
115        let mut impure = inputs.impure_env.clone();
116        impure.sort();
117        for (k, v) in &impure {
118            buf.push_str("env.");
119            buf.push_str(k);
120            buf.push('=');
121            buf.push_str(v);
122            buf.push('\n');
123        }
124    }
125    buf.push_str("expr=");
126    buf.push_str(&inputs.expr);
127
128    match format.hash_algo {
129        EvalCacheHash::Sha256 => {
130            use sha2::{Digest, Sha256};
131            let mut h = Sha256::new();
132            h.update(buf.as_bytes());
133            let bytes = h.finalize();
134            Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
135        }
136        EvalCacheHash::Blake3 => {
137            // Avoid pulling blake3 in to sui-spec; reuse sha2 with
138            // a domain-separating prefix.  M3.1 will switch to real
139            // BLAKE3 when sui-spec consumes the blake3 crate.
140            use sha2::{Digest, Sha256};
141            let mut h = Sha256::new();
142            h.update(b"blake3-domain-separator:");
143            h.update(buf.as_bytes());
144            let bytes = h.finalize();
145            Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn canonical_eval_cache_parses() {
156        let formats = load_canonical().unwrap();
157        assert!(!formats.is_empty());
158    }
159
160    #[test]
161    fn cppnix_uses_sqlite_sha256() {
162        let formats = load_canonical().unwrap();
163        let cppnix = formats.iter().find(|f| f.name == "cppnix-eval-cache-v5").unwrap();
164        assert_eq!(cppnix.backend, EvalCacheBackend::SQLite);
165        assert_eq!(cppnix.hash_algo, EvalCacheHash::Sha256);
166    }
167
168    #[test]
169    fn sui_uses_redb_blake3() {
170        let formats = load_canonical().unwrap();
171        let sui = formats.iter().find(|f| f.name == "sui-eval-cache-v1").unwrap();
172        assert_eq!(sui.backend, EvalCacheBackend::Redb);
173        assert_eq!(sui.hash_algo, EvalCacheHash::Blake3);
174    }
175
176    // ── M3.0 cache-key tests ───────────────────────────────────
177
178    fn cppnix() -> EvalCacheFormat {
179        load_canonical().unwrap().into_iter()
180            .find(|f| f.name == "cppnix-eval-cache-v5").unwrap()
181    }
182
183    #[test]
184    fn derive_cache_key_is_deterministic() {
185        let f = cppnix();
186        let inputs = CacheKeyInputs {
187            expr: "1 + 2".into(),
188            flake_input_narhashes: vec![("nixpkgs".into(), "sha256:abc".into())],
189            system: "aarch64-darwin".into(),
190            impure_env: vec![],
191            nix_version: "2.18".into(),
192        };
193        let k1 = derive_cache_key(&f, &inputs).unwrap();
194        let k2 = derive_cache_key(&f, &inputs).unwrap();
195        assert_eq!(k1, k2);
196        assert_eq!(k1.len(), 64);  // sha256 hex
197    }
198
199    #[test]
200    fn input_order_doesnt_change_key() {
201        let f = cppnix();
202        let a = CacheKeyInputs {
203            expr: "x".into(),
204            flake_input_narhashes: vec![
205                ("a".into(), "1".into()),
206                ("b".into(), "2".into()),
207            ],
208            system: "x86_64-linux".into(),
209            impure_env: vec![],
210            nix_version: "2.18".into(),
211        };
212        let b = CacheKeyInputs {
213            flake_input_narhashes: vec![
214                ("b".into(), "2".into()),
215                ("a".into(), "1".into()),
216            ],
217            ..a.clone()
218        };
219        assert_eq!(derive_cache_key(&f, &a).unwrap(), derive_cache_key(&f, &b).unwrap());
220    }
221
222    #[test]
223    fn different_exprs_yield_different_keys() {
224        let f = cppnix();
225        let a = CacheKeyInputs { expr: "1".into(), ..Default::default() };
226        let b = CacheKeyInputs { expr: "2".into(), ..Default::default() };
227        assert_ne!(derive_cache_key(&f, &a).unwrap(), derive_cache_key(&f, &b).unwrap());
228    }
229}