Skip to main content

lean_ctx/core/ocla/
cache_tiers.rs

1//! Process, daemon, and disk cache tiers for generalized delivery entries.
2
3use 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/// Process-local cache backed by a concurrent map and a fixed TTL.
22#[derive(Debug)]
23pub struct L1ProcessCache {
24    entries: DashMap<CacheKey, MemoryCacheEntry>,
25    ttl: Duration,
26}
27
28impl L1ProcessCache {
29    /// Creates an empty process-local cache with the supplied TTL.
30    pub fn new(ttl: Duration) -> Self {
31        Self {
32            entries: DashMap::new(),
33            ttl,
34        }
35    }
36
37    /// Returns a live entry and drops expired local entries.
38    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    /// Inserts or replaces an entry using this tier's TTL.
49    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    /// Removes an entry by key.
61    pub fn remove(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
62        self.entries.remove(key).map(|(_, value)| value.entry)
63    }
64
65    /// Returns the number of entries currently retained by this tier.
66    pub fn len(&self) -> usize {
67        self.entries.len()
68    }
69
70    /// Returns whether this tier has no entries.
71    pub fn is_empty(&self) -> bool {
72        self.entries.is_empty()
73    }
74}
75
76/// Daemon-shared cache backed by a concurrent map and ordered eviction index.
77#[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    /// Creates an empty daemon cache, clamping its capacity to at least one entry.
87    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    /// Returns a live entry and refreshes its position in the eviction index.
97    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    /// Inserts or replaces an entry, evicting the least-recently-used entry when full.
108    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    /// Removes an entry and its eviction-index record.
140    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    /// Returns the number of entries currently retained by this tier.
150    pub fn len(&self) -> usize {
151        self.entries.len()
152    }
153
154    /// Returns whether this tier has no entries.
155    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(&timestamp) {
176        timestamp = timestamp
177            .checked_add(Duration::from_nanos(1))
178            .unwrap_or(timestamp);
179    }
180    timestamp
181}
182
183/// Serializable manifest record for an entry retained in the disk cache.
184#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
185pub struct PersistedCacheEntry {
186    /// The delivery entry represented by this manifest record.
187    pub entry: DeliveryEntryV2,
188    /// Time at which the entry was persisted, in Unix epoch milliseconds.
189    pub persisted_at_epoch_ms: u64,
190}
191
192/// Disk-backed cache manifest with a directory reserved for content blobs.
193#[derive(Debug)]
194pub struct L3DiskCache {
195    root: PathBuf,
196    manifest: DashMap<CacheKey, PersistedCacheEntry>,
197    blob_directory: PathBuf,
198}
199
200impl L3DiskCache {
201    /// Opens a disk cache rooted at `root`, creating its manifest and blob directories as needed.
202    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    /// Validates the manifest on startup: removes entries older than `max_age`
225    /// and trims the manifest to `max_bytes` total token budget.
226    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    /// Returns an entry from the persisted manifest without loading its blob.
266    pub fn get(&self, key: &CacheKey) -> Option<DeliveryEntryV2> {
267        self.manifest
268            .get(key)
269            .map(|persisted| persisted.entry.clone())
270    }
271
272    /// Persists an entry to the manifest and returns any entry it replaced.
273    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    /// Removes an entry from the manifest and persists the updated manifest.
285    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    /// Returns the cache root directory.
297    pub fn root(&self) -> &Path {
298        &self.root
299    }
300
301    /// Returns the directory reserved for content-addressed blobs.
302    pub fn blob_directory(&self) -> &Path {
303        &self.blob_directory
304    }
305
306    /// Returns the number of entries present in the manifest.
307    pub fn len(&self) -> usize {
308        self.manifest.len()
309    }
310
311    /// Returns whether the manifest contains no entries.
312    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        // persisted_at_epoch_ms set by insert = now, so we need entries
422        // that were persisted long ago — override via direct manifest access
423        cache.insert(old_entry).unwrap();
424        std::thread::sleep(Duration::from_millis(5));
425        // With max_age=0, everything should be expired
426        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        // max_bytes=500 means only 5 entries of 100 tokens each should survive
441        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}