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