1use std::collections::BTreeMap;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10use dashmap::DashMap;
11use serde::{Deserialize, Serialize};
12
13use super::cache_types::{CacheKey, DeliveryEntryV2};
14
15#[derive(Clone, Debug)]
16struct MemoryCacheEntry {
17 entry: DeliveryEntryV2,
18 expires_at: Instant,
19}
20
21#[derive(Debug)]
23pub struct L1ProcessCache {
24 entries: DashMap<CacheKey, MemoryCacheEntry>,
25 ttl: Duration,
26}
27
28impl L1ProcessCache {
29 pub fn new(ttl: Duration) -> Self {
31 Self {
32 entries: DashMap::new(),
33 ttl,
34 }
35 }
36
37 pub fn get(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
39 let value = self.entries.get(key)?;
40 if value.expires_at > Instant::now() {
41 return Some(value.entry.clone());
42 }
43 drop(value);
44 self.entries.remove(key);
45 None
46 }
47
48 pub fn insert(&self, entry: DeliveryEntryV2) {
50 let key = entry.key.clone();
51 self.entries.insert(
52 key,
53 MemoryCacheEntry {
54 entry,
55 expires_at: Instant::now() + self.ttl,
56 },
57 );
58 }
59
60 pub fn remove(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
62 self.entries.remove(key).map(|(_, value)| value.entry)
63 }
64
65 pub fn len(&self) -> usize {
67 self.entries.len()
68 }
69
70 pub fn is_empty(&self) -> bool {
72 self.entries.is_empty()
73 }
74}
75
76#[derive(Debug)]
78pub struct L2DaemonCache {
79 entries: DashMap<CacheKey, MemoryCacheEntry>,
80 eviction_index: Mutex<BTreeMap<Instant, CacheKey>>,
81 max_entries: usize,
82 ttl: Duration,
83}
84
85impl L2DaemonCache {
86 pub fn new(max_entries: usize, ttl: Duration) -> Self {
88 Self {
89 entries: DashMap::new(),
90 eviction_index: Mutex::new(BTreeMap::new()),
91 max_entries: max_entries.max(1),
92 ttl,
93 }
94 }
95
96 pub fn get(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
98 let entry = self.entries.get(key)?.clone();
99 if entry.expires_at <= Instant::now() {
100 self.remove(key);
101 return None;
102 }
103 self.touch(key.clone());
104 Some(entry.entry)
105 }
106
107 pub fn insert(&self, entry: DeliveryEntryV2) -> Option<DeliveryEntryV2> {
109 let key = entry.key.clone();
110 let mut index = self
111 .eviction_index
112 .lock()
113 .unwrap_or_else(std::sync::PoisonError::into_inner);
114 remove_index_key(&mut index, &key);
115 let previous = self
116 .entries
117 .insert(
118 key.clone(),
119 MemoryCacheEntry {
120 entry,
121 expires_at: Instant::now() + self.ttl,
122 },
123 )
124 .map(|value| value.entry);
125 let evicted = if previous.is_none() && self.entries.len() > self.max_entries {
126 index.pop_first().and_then(|(_, evicted_key)| {
127 self.entries
128 .remove(&evicted_key)
129 .map(|(_, value)| value.entry)
130 })
131 } else {
132 None
133 };
134 let timestamp = unique_instant(&index, Instant::now());
135 index.insert(timestamp, key);
136 evicted
137 }
138
139 pub fn remove(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
141 let mut index = self
142 .eviction_index
143 .lock()
144 .unwrap_or_else(std::sync::PoisonError::into_inner);
145 remove_index_key(&mut index, key);
146 self.entries.remove(key).map(|(_, value)| value.entry)
147 }
148
149 pub fn len(&self) -> usize {
151 self.entries.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
156 self.entries.is_empty()
157 }
158
159 fn touch(&self, key: CacheKey) {
160 let mut index = self
161 .eviction_index
162 .lock()
163 .unwrap_or_else(std::sync::PoisonError::into_inner);
164 remove_index_key(&mut index, &key);
165 let timestamp = unique_instant(&index, Instant::now());
166 index.insert(timestamp, key);
167 }
168}
169
170fn remove_index_key(index: &mut BTreeMap<Instant, CacheKey>, key: &CacheKey) {
171 index.retain(|_, indexed_key| indexed_key != key);
172}
173
174fn unique_instant(index: &BTreeMap<Instant, CacheKey>, mut timestamp: Instant) -> Instant {
175 while index.contains_key(×tamp) {
176 timestamp = timestamp
177 .checked_add(Duration::from_nanos(1))
178 .unwrap_or(timestamp);
179 }
180 timestamp
181}
182
183#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
185pub struct PersistedCacheEntry {
186 pub entry: DeliveryEntryV2,
188 pub persisted_at_epoch_ms: u64,
190}
191
192#[derive(Debug)]
194pub struct L3DiskCache {
195 root: PathBuf,
196 manifest: DashMap<CacheKey, PersistedCacheEntry>,
197 blob_directory: PathBuf,
198}
199
200impl L3DiskCache {
201 pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
203 let root = root.as_ref().to_path_buf();
204 let blob_directory = root.join("blobs");
205 fs::create_dir_all(&blob_directory)?;
206 let manifest_path = root.join("manifest.json");
207 let entries = if manifest_path.exists() {
208 let bytes = fs::read(&manifest_path)?;
209 serde_json::from_slice::<Vec<PersistedCacheEntry>>(&bytes).map_err(io::Error::other)?
210 } else {
211 Vec::new()
212 };
213 let manifest = DashMap::new();
214 for persisted in entries {
215 manifest.insert(persisted.entry.key.clone(), persisted);
216 }
217 Ok(Self {
218 root,
219 manifest,
220 blob_directory,
221 })
222 }
223
224 pub fn startup_validate(&self, max_age: Duration, max_bytes: u64) {
227 let now_ms = epoch_ms();
228 let max_age_ms = max_age.as_millis() as u64;
229 let expired: Vec<CacheKey> = self
230 .manifest
231 .iter()
232 .filter(|entry| now_ms.saturating_sub(entry.persisted_at_epoch_ms) > max_age_ms)
233 .map(|entry| entry.key().clone())
234 .collect();
235 for key in &expired {
236 self.manifest.remove(key);
237 }
238 if max_bytes > 0 {
239 let mut entries: Vec<_> = self
240 .manifest
241 .iter()
242 .map(|e| {
243 (
244 e.key().clone(),
245 e.persisted_at_epoch_ms,
246 e.entry.token_count,
247 )
248 })
249 .collect();
250 entries.sort_by_key(|(_, ts, _)| *ts);
251 let mut total: u64 = entries.iter().map(|(_, _, t)| *t).sum();
252 for (key, _, tokens) in &entries {
253 if total <= max_bytes {
254 break;
255 }
256 total -= tokens;
257 self.manifest.remove(key);
258 }
259 }
260 if !expired.is_empty() {
261 let _ = self.persist_manifest();
262 }
263 }
264
265 pub fn get(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
267 self.manifest
268 .get(key)
269 .map(|persisted| persisted.entry.clone())
270 }
271
272 pub fn insert(&self, entry: DeliveryEntryV2) -> io::Result<Option<DeliveryEntryV2>> {
274 let key = entry.key.clone();
275 let persisted = PersistedCacheEntry {
276 entry,
277 persisted_at_epoch_ms: epoch_ms(),
278 };
279 let previous = self.manifest.insert(key, persisted).map(|old| old.entry);
280 self.persist_manifest()?;
281 Ok(previous)
282 }
283
284 pub fn remove(&self, key: &CacheKey) -> io::Result<Option<DeliveryEntryV2>> {
286 let removed = self
287 .manifest
288 .remove(key)
289 .map(|(_, persisted)| persisted.entry);
290 if removed.is_some() {
291 self.persist_manifest()?;
292 }
293 Ok(removed)
294 }
295
296 pub fn root(&self) -> &Path {
298 &self.root
299 }
300
301 pub fn blob_directory(&self) -> &Path {
303 &self.blob_directory
304 }
305
306 pub fn len(&self) -> usize {
308 self.manifest.len()
309 }
310
311 pub fn is_empty(&self) -> bool {
313 self.manifest.is_empty()
314 }
315
316 fn persist_manifest(&self) -> io::Result<()> {
317 let mut records = self
318 .manifest
319 .iter()
320 .map(|item| item.value().clone())
321 .collect::<Vec<_>>();
322 records.sort_by(|left, right| left.entry.key.cmp(&right.entry.key));
323 let bytes = serde_json::to_vec(&records).map_err(io::Error::other)?;
324 let temporary = self.root.join("manifest.json.tmp");
325 fs::write(&temporary, bytes)?;
326 fs::rename(temporary, self.root.join("manifest.json"))
327 }
328}
329
330fn epoch_ms() -> u64 {
331 SystemTime::now()
332 .duration_since(UNIX_EPOCH)
333 .unwrap_or_default()
334 .as_millis() as u64
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::core::ocla::cache_types::{
341 AgentHost, CacheIdentity, CacheValidator, ContentHandleRef, DeliveryKind,
342 };
343
344 fn entry(name: &str) -> DeliveryEntryV2 {
345 DeliveryEntryV2 {
346 schema_version: 2,
347 key: CacheKey(format!("cache:v1:file_read:{name}")),
348 kind: DeliveryKind::FileRead,
349 validator: CacheValidator::Immutable,
350 handle: ContentHandleRef {
351 algorithm: "blake3".into(),
352 digest: "d".repeat(64),
353 byte_len: 1,
354 media_type: "text/plain".into(),
355 },
356 display_path: None,
357 line_count: None,
358 token_count: 4,
359 producer: CacheIdentity {
360 agent_id: "agent".into(),
361 conversation_id: "conversation".into(),
362 host: AgentHost::Cli,
363 },
364 created_at_epoch_ms: 0,
365 expires_at_epoch_ms: u64::MAX,
366 }
367 }
368
369 #[test]
370 fn l1_expires_entries_using_its_ttl() {
371 let cache = L1ProcessCache::new(Duration::ZERO);
372 let entry = entry("l1");
373 cache.insert(entry.clone());
374 assert_eq!(cache.get(&entry.key), None);
375 assert!(cache.is_empty());
376 }
377
378 #[test]
379 fn l2_evicts_the_oldest_entry_at_capacity() {
380 let cache = L2DaemonCache::new(1, Duration::from_mins(1));
381 let first = entry("first");
382 let second = entry("second");
383 cache.insert(first.clone());
384 assert_eq!(cache.insert(second.clone()), Some(first));
385 assert_eq!(cache.get(&second.key), Some(second));
386 }
387
388 #[test]
389 fn l3_serializes_manifest_entries() {
390 let directory = tempfile::tempdir().unwrap();
391 let cache = L3DiskCache::open(directory.path()).unwrap();
392 let entry = entry("l3");
393 cache.insert(entry.clone()).unwrap();
394 drop(cache);
395 let reopened = L3DiskCache::open(directory.path()).unwrap();
396 assert_eq!(reopened.get(&entry.key), Some(entry));
397 assert!(reopened.blob_directory().is_dir());
398 }
399
400 #[test]
401 fn persisted_entry_round_trips() {
402 let persisted = PersistedCacheEntry {
403 entry: entry("persisted"),
404 persisted_at_epoch_ms: 3,
405 };
406 assert_eq!(
407 serde_json::from_str::<PersistedCacheEntry>(
408 &serde_json::to_string(&persisted).unwrap()
409 )
410 .unwrap(),
411 persisted
412 );
413 }
414
415 #[test]
416 fn l3_startup_validate_removes_expired_entries() {
417 let dir = tempfile::tempdir().unwrap();
418 let cache = L3DiskCache::open(dir.path()).unwrap();
419 let mut old_entry = entry("old");
420 old_entry.created_at_epoch_ms = 1000;
421 cache.insert(old_entry).unwrap();
424 std::thread::sleep(Duration::from_millis(5));
425 cache.startup_validate(Duration::ZERO, u64::MAX);
427 assert_eq!(cache.len(), 0, "expired entries must be removed");
428 }
429
430 #[test]
431 fn l3_startup_validate_trims_to_max_bytes() {
432 let dir = tempfile::tempdir().unwrap();
433 let cache = L3DiskCache::open(dir.path()).unwrap();
434 for i in 0..10 {
435 let mut e = entry(&format!("item{i}"));
436 e.token_count = 100;
437 cache.insert(e).unwrap();
438 }
439 assert_eq!(cache.len(), 10);
440 cache.startup_validate(Duration::from_secs(999999), 500);
442 assert!(
443 cache.len() <= 5,
444 "GC must trim to max_bytes budget, got {}",
445 cache.len()
446 );
447 }
448
449 #[test]
450 fn l3_manifest_survives_reopen() {
451 let dir = tempfile::tempdir().unwrap();
452 {
453 let cache = L3DiskCache::open(dir.path()).unwrap();
454 cache.insert(entry("persistent")).unwrap();
455 assert_eq!(cache.len(), 1);
456 }
457 let cache = L3DiskCache::open(dir.path()).unwrap();
458 assert_eq!(cache.len(), 1, "manifest must persist across reopen");
459 assert!(
460 cache
461 .get(&CacheKey("cache:v1:file_read:persistent".into()))
462 .is_some()
463 );
464 }
465}