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