Skip to main content

drep/llm/
cache.rs

1//! Content-addressed cache for LLM responses.
2//!
3//! Two design choices diverge from the Python `drep/llm/cache.py` and the
4//! criterion is "the cache should hit on the call paths the gate actually
5//! takes", not "the cache should mirror what 1.x did":
6//!
7//! - **The key is content-only, never commit-aware.** The Python mixed the
8//!   commit SHA into the key so a new commit invalidated everything. Hashing
9//!   the content already invalidates precisely when the content changes; the
10//!   SHA was forcing a miss on every unchanged file at every new commit, which
11//!   is the exact case the cache exists to serve. On a pre-commit gate that is
12//!   the common path.
13//! - **One file per entry, the value is the JSON.** No sidecar metadata file.
14//!   The key is the filename, so nothing has to be re-validated on read except
15//!   age. If the entry is corrupt, unreadable, or expired, the read returns
16//!   `None` and the caller re-queries and overwrites. A cache is an
17//!   optimisation; it must not be able to take the gate down.
18//!
19//! ## Storage layout
20//!
21//! `root/<first two hex chars>/<full hex>.json`. Sharding keeps directories
22//! from growing to tens of thousands of entries (a flat `root/<hex>.json`
23//! layout would do the same for `readdir`, but `ls`-ing the cache to debug
24//! it would take seconds).
25//!
26//! ## Key composition
27//!
28//! `blake3` over the six inputs, each length-prefixed with an 8-byte
29//! big-endian length. Length prefixing rules out the
30//! `key("ab", "c", ...)` vs `key("a", "bc", ...)` collision that a separator
31//! byte cannot guarantee once `content` or `system_prompt` is allowed to
32//! contain that byte. `temperature` uses Rust's shortest round-tripping
33//! representation, so equal values hash alike without collapsing neighboring
34//! `f32` values.
35//!
36//! **The backend identity is part of the key, not just the model.** A model name is
37//! not a globally unique identity: the canonical failover pair is one open
38//! model served from a local runtime and from a cloud provider, which name it
39//! identically. Keyed on the model alone, the fallback's answer lands where the
40//! head would look for its own - so a later run with the head restored is
41//! served a response it never produced, which is the exact defect the
42//! per-provider key exists to prevent.
43//!
44//! ## Defaults
45//!
46//! 30 days TTL, 256 MiB max bytes.
47
48use std::path::{Path, PathBuf};
49use std::time::{Duration, SystemTime};
50use std::{fs, io::Write};
51
52use serde_json::Value;
53use thiserror::Error;
54
55/// A cache key: the blake3 hex digest of the six key inputs.
56///
57/// The inner `String` is exactly 64 lower-case ASCII hex characters because
58/// `blake3::Hash::to_hex` always emits 64 hex chars. Tests rely on this to
59/// compute the on-disk path by hand.
60#[derive(Clone, Debug, PartialEq, Eq, Hash)]
61pub struct CacheKey(String);
62
63impl CacheKey {
64    /// The hex digest. Test-only path construction uses this.
65    pub(crate) fn as_hex(&self) -> &str {
66        &self.0
67    }
68}
69
70/// What can go wrong at write time.
71///
72/// Reads are infallible by design: a corrupt or missing entry is a miss, not
73/// an error, so `get` returns `Option<Value>`. The caller treats write
74/// failures as non-fatal (log and continue); only the path to disk, the
75/// serialisation, the directory creation, and eviction need error variants.
76#[derive(Debug, Error)]
77pub enum CacheError {
78    /// The shard directory could not be created on `put`.
79    #[error("could not create cache shard {0}: {1}")]
80    CreateShard(PathBuf, std::io::Error),
81    /// Serialising the value to JSON failed. Should be unreachable for
82    /// values produced by `serde_json` itself, but the type permits any
83    /// `Value`, so the error path exists.
84    #[error("could not serialise cache entry {0}: {1}")]
85    Serialize(String, serde_json::Error),
86    /// Writing the JSON file failed (disk full, permissions, race with
87    /// concurrent eviction).
88    #[error("could not write cache entry {0}: {1}")]
89    Write(PathBuf, std::io::Error),
90    /// Reading the cache directory during eviction failed.
91    #[error("could not read cache directory: {0}")]
92    Walk(std::io::Error),
93    /// Removing an entry during eviction failed.
94    #[error("could not remove cache entry {0}: {1}")]
95    Remove(PathBuf, std::io::Error),
96}
97
98/// The cache: a directory tree of one JSON file per entry, keyed by blake3
99/// digest of the six prompt and backend inputs.
100///
101/// Built once per process and shared across the analyzer; every method takes
102/// `&self` so `Cache` can live behind an `Arc` if a future caller needs it.
103#[derive(Debug, Clone)]
104pub struct Cache {
105    root: PathBuf,
106    ttl: Duration,
107    max_bytes: u64,
108}
109
110impl Cache {
111    /// Build a cache rooted at `root`. Creates `root` if absent; a creation
112    /// failure is swallowed (the next `put` will surface it).
113    pub fn new(root: PathBuf, ttl_days: u64, max_bytes: u64) -> Self {
114        let _ = std::fs::create_dir_all(&root);
115        Self {
116            root,
117            ttl: Duration::from_secs(ttl_days.saturating_mul(86_400)),
118            max_bytes,
119        }
120    }
121
122    /// The conventional location: `directories::ProjectDirs::cache_dir()`,
123    /// falling back to `.drep-cache` in the cwd when the platform has no
124    /// cache directory. Returned as a relative path on the fallback branch
125    /// so the caller can resolve it against whatever cwd they choose.
126    pub fn default_root() -> PathBuf {
127        if let Some(dirs) = directories::ProjectDirs::from("dev", "slb350", "drep") {
128            return dirs.cache_dir().to_path_buf();
129        }
130        PathBuf::from(".drep-cache")
131    }
132
133    /// Compute the key for
134    /// `(system_prompt, content, backend, model, request_shape, temperature)`.
135    ///
136    /// Deliberately does NOT consult `self`: the key is content-only, so two
137    /// `Cache` instances at different roots produce the same key for the
138    /// same inputs. That is what criterion 7 asserts and what makes the
139    /// cache portable across CI runs.
140    ///
141    /// `request_shape` is in the key because one endpoint can serve the same model
142    /// over both wire formats - `api.minimax.io` publishes `/v1` and
143    /// `/anthropic/v1` for `MiniMax-M3` - and the two are different requests
144    /// with different reasoning handling. Keying without it files one
145    /// protocol's answer where the other looks for its own, which is the same
146    /// defect that put `endpoint` in the key.
147    pub fn key(
148        &self,
149        system_prompt: &str,
150        content: &str,
151        backend: &str,
152        model: &str,
153        request_shape: &str,
154        temperature: Option<f32>,
155    ) -> CacheKey {
156        let mut hasher = blake3::Hasher::new();
157        write_field(&mut hasher, system_prompt.as_bytes());
158        write_field(&mut hasher, content.as_bytes());
159        write_field(&mut hasher, backend.as_bytes());
160        write_field(&mut hasher, model.as_bytes());
161        write_field(&mut hasher, request_shape.as_bytes());
162        // `{:?}` on an ordinary finite `f32` is the shortest string that
163        // round-trips, so values such as `0.2` and `0.20` share a key while
164        // neighboring finite values do not. Config validation excludes NaN;
165        // signed zero is harmlessly allowed to retain its distinct spelling.
166        //
167        // This replaced `{:.6}`, whose comment claimed six decimal places were
168        // finer than `f32`'s resolution. They are not: `f32` has ~7 significant
169        // digits, not 7 decimal places, so near 1.0 its ulp is ~1.2e-7 while
170        // six decimals steps by 1e-6 - coarser, and able to collapse two
171        // genuinely different temperatures onto one key.
172        //
173        // An unset temperature is a *different request* from any set one - the
174        // field is absent and the server picks - so it gets a sentinel that no
175        // formatted float can collide with, rather than being folded onto some
176        // stand-in value.
177        let temp_str = match temperature {
178            Some(value) => format!("{value:?}"),
179            None => "unset".to_string(),
180        };
181        write_field(&mut hasher, temp_str.as_bytes());
182        CacheKey(hasher.finalize().to_hex().to_string())
183    }
184
185    /// Read the entry at `key`, or `None` if absent, expired, unreadable, or
186    /// unparseable. Reads never fail.
187    ///
188    /// An expired entry is removed opportunistically; a corrupt or
189    /// unreadable entry is left in place so the next read has another chance
190    /// (transient I/O is more likely than persistent corruption, and a
191    /// half-deleted cache is worse than a slightly redundant one).
192    pub fn get(&self, key: &CacheKey) -> Option<Value> {
193        let path = self.entry_path(key);
194        let meta = std::fs::metadata(&path).ok()?;
195        let mtime = meta.modified().ok()?;
196        // A future mtime (clock skew, manual planting) makes
197        // `duration_since` return `Err`. Treat that as age zero rather
198        // than propagating the error: the entry is well within TTL,
199        // and the alternative would silently turn every clock-skewed
200        // cache into a miss.
201        let age = SystemTime::now()
202            .duration_since(mtime)
203            .unwrap_or(Duration::ZERO);
204        if age > self.ttl {
205            // Expired. Removing is best-effort: a stale entry is no worse
206            // than a missing one, but failing to remove is not worth a Result.
207            let _ = std::fs::remove_file(&path);
208            return None;
209        }
210        let bytes = std::fs::read(&path).ok()?;
211        serde_json::from_slice(&bytes).ok()
212    }
213
214    /// Write `value` to disk under `key`.
215    ///
216    /// Creates the shard directory on demand so the very first `put` into a
217    /// fresh cache does not need a separate bootstrap step.
218    pub fn put(&self, key: &CacheKey, value: &Value) -> Result<(), CacheError> {
219        let path = self.entry_path(key);
220        let parent = path.parent().ok_or_else(|| {
221            CacheError::Write(
222                path.clone(),
223                std::io::Error::new(
224                    std::io::ErrorKind::InvalidInput,
225                    "cache entry path has no shard directory",
226                ),
227            )
228        })?;
229        fs::create_dir_all(parent).map_err(|e| CacheError::CreateShard(parent.to_path_buf(), e))?;
230        let bytes = serde_json::to_vec(value)
231            .map_err(|e| CacheError::Serialize(key.as_hex().to_owned(), e))?;
232        let mut temporary = tempfile::NamedTempFile::new_in(parent)
233            .map_err(|e| CacheError::Write(path.clone(), e))?;
234        temporary
235            .write_all(&bytes)
236            .map_err(|e| CacheError::Write(path.clone(), e))?;
237        temporary
238            .persist(&path)
239            .map_err(|e| CacheError::Write(path.clone(), e.error))?;
240        Ok(())
241    }
242
243    /// Walk the tree, evicting the oldest entries (by mtime) until the
244    /// total size is at or under `max_bytes`. Returns the number of bytes
245    /// freed; `0` when no eviction was needed.
246    pub fn evict_if_needed(&self) -> Result<u64, CacheError> {
247        let mut entries = self.collect_entries()?;
248        let total: u64 = entries.iter().map(|e| e.size).sum();
249        if total <= self.max_bytes {
250            return Ok(0);
251        }
252        entries.sort_by_key(|e| e.mtime);
253        let mut current = total;
254        let mut freed = 0u64;
255        for entry in entries {
256            if current <= self.max_bytes {
257                break;
258            }
259            std::fs::remove_file(&entry.path)
260                .map_err(|e| CacheError::Remove(entry.path.clone(), e))?;
261            current = current.saturating_sub(entry.size);
262            freed = freed.saturating_add(entry.size);
263        }
264        Ok(freed)
265    }
266
267    /// Compute the on-disk path for `key`.
268    ///
269    /// `pub(crate)` so the test suite can construct the same path to plant
270    /// an expired mtime or to write a corrupt body for the miss-on-bad-data
271    /// criterion.
272    pub(crate) fn entry_path(&self, key: &CacheKey) -> PathBuf {
273        let hex = key.as_hex();
274        // blake3::Hash::to_hex always produces 64 ASCII hex chars, so the
275        // `..2` slice is safe by construction.
276        let shard = &hex[..2];
277        self.root.join(shard).join(format!("{hex}.json"))
278    }
279
280    /// Walk every entry under `root` and return `(path, mtime, size)`.
281    ///
282    /// Skips the shard-directories' own metadata (no file under them, no
283    /// entry). Entries we cannot stat are skipped silently: a transient
284    /// stat failure should not abort eviction when the tree still has
285    /// deletable members.
286    fn collect_entries(&self) -> Result<Vec<CacheEntry>, CacheError> {
287        let mut out = Vec::new();
288        let shards = std::fs::read_dir(&self.root).map_err(CacheError::Walk)?;
289        for shard in shards {
290            let Ok(shard) = shard else { continue };
291            // Only descend into the two-hex-char directories. Anything else -
292            // a stray file the user dropped in the cache root, or a directory
293            // that is not one of ours - is ignored, so nothing outside the
294            // layout this module writes can be evicted.
295            //
296            // The name check is load-bearing, not decorative. `is_dir()` alone
297            // was what the code did while this comment claimed otherwise, which
298            // meant `evict_if_needed` - the one destructive path here - would
299            // happily delete files out of *any* directory someone had placed
300            // under the cache root.
301            let file_type = match shard.file_type() {
302                Ok(ft) => ft,
303                Err(_) => continue,
304            };
305            if !file_type.is_dir() {
306                continue;
307            }
308            if !is_shard_name(&shard.file_name()) {
309                continue;
310            }
311            let shard_path = shard.path();
312            let shard_name = shard.file_name();
313            let entries = match std::fs::read_dir(&shard_path) {
314                Ok(e) => e,
315                Err(_) => continue,
316            };
317            for entry in entries {
318                let entry = match entry {
319                    Ok(e) => e,
320                    Err(_) => continue,
321                };
322                if !is_entry_name(&shard_name, &entry.file_name()) {
323                    continue;
324                }
325                let path = entry.path();
326                let file_type = match entry.file_type() {
327                    Ok(file_type) => file_type,
328                    Err(_) => continue,
329                };
330                if !file_type.is_file() {
331                    continue;
332                }
333                let meta = match entry.metadata() {
334                    Ok(m) => m,
335                    Err(_) => continue,
336                };
337                if !meta.is_file() {
338                    continue;
339                }
340                // Falling back to UNIX_EPOCH on a failed `modified()` means
341                // the entry sorts first in eviction - i.e., it would be
342                // evicted first. That is the safe direction: a cache that
343                // evicts an unreadable entry loses a redundant file, not
344                // correctness.
345                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
346                let size = meta.len();
347                out.push(CacheEntry { path, mtime, size });
348            }
349        }
350        Ok(out)
351    }
352}
353
354impl Cache {
355    /// The cache root directory. `pub(crate)` for tests that need to assert
356    /// sharding / shard creation.
357    #[allow(dead_code)]
358    pub(crate) fn root(&self) -> &Path {
359        &self.root
360    }
361}
362
363/// One entry on disk, pre-computed for the eviction walk.
364struct CacheEntry {
365    path: PathBuf,
366    mtime: SystemTime,
367    size: u64,
368}
369
370/// Whether `name` is one of this module's shard directories.
371///
372/// Exactly two lower-case hex characters, which is what [`Cache::entry_path`]
373/// produces from a blake3 digest. Written against the same alphabet rather than
374/// a looser "two characters" check, so a directory named `ab` is a shard and
375/// one named `zz` or `AB` is not.
376fn is_shard_name(name: &std::ffi::OsStr) -> bool {
377    name.to_str().is_some_and(|name| is_lower_hex(name, 2))
378}
379
380/// Whether `name` has the exact form written by [`Cache::entry_path`] for
381/// `shard`: 64 lower-case hexadecimal digest characters plus `.json`, with
382/// the digest beginning with the parent shard name.
383fn is_entry_name(shard: &std::ffi::OsStr, name: &std::ffi::OsStr) -> bool {
384    let (Some(shard), Some(name)) = (shard.to_str(), name.to_str()) else {
385        return false;
386    };
387    let Some(digest) = name.strip_suffix(".json") else {
388        return false;
389    };
390    digest.starts_with(shard) && is_lower_hex(digest, 64)
391}
392
393fn is_lower_hex(value: &str, expected_len: usize) -> bool {
394    value.len() == expected_len
395        && value
396            .bytes()
397            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
398}
399
400/// Write a length-prefixed field to the hasher.
401///
402/// Length-prefixing rules out boundary collisions (`("ab","c")` vs
403/// `("a","bc")`) without depending on any byte that cannot appear in the
404/// payload. The length is a fixed 8-byte big-endian `u64`, so a single
405/// field can be at most `u64::MAX` bytes long - far longer than any real
406/// prompt.
407fn write_field(hasher: &mut blake3::Hasher, bytes: &[u8]) {
408    let len = u64::try_from(bytes.len()).expect("prompt field longer than u64::MAX bytes");
409    hasher.update(&len.to_be_bytes());
410    hasher.update(bytes);
411}
412
413#[cfg(test)]
414mod tests;