Skip to main content

drep/llm/
cache.rs

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