Skip to main content

chronix_engine/objstore/
cache.rs

1//! Local disk cache with LRU eviction for object store data.
2//!
3//! Recently accessed objects are cached on local disk to avoid repeated
4//! remote fetches. The cache has a configurable maximum size and uses
5//! LRU eviction to stay within budget.
6
7use std::collections::{BTreeMap, HashMap};
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use parking_lot::Mutex;
12use tokio::fs;
13use tracing::{debug, warn};
14
15use crate::objstore::error::{ObjStoreError, Result};
16
17/// Monotonically increasing generation counter for LRU ordering.
18///
19/// Using a counter instead of `SystemTime` avoids issues with
20/// non-monotonic clocks (NTP adjustments, leap seconds, VM migration).
21static LRU_GENERATION: AtomicU64 = AtomicU64::new(1);
22
23/// Configuration for the local disk cache.
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct CacheConfig {
26    /// Root directory for cached objects.
27    pub cache_dir: PathBuf,
28    /// Maximum total cache size in bytes.
29    pub max_size_bytes: u64,
30}
31
32impl Default for CacheConfig {
33    fn default() -> Self {
34        Self {
35            cache_dir: PathBuf::from("/tmp/chronix-objcache"),
36            max_size_bytes: 1024 * 1024 * 1024, // 1 GB
37        }
38    }
39}
40
41/// An entry in the LRU tracking table.
42#[derive(Debug, Clone)]
43struct CacheEntry {
44    /// Size of the cached file in bytes.
45    size: u64,
46    /// Monotonic generation counter for LRU ordering.
47    generation: u64,
48}
49
50/// LRU tracking state — protected by a mutex.
51///
52/// Uses a secondary `BTreeMap` ordered by `(access_time, key)` for O(log N)
53/// LRU eviction instead of the previous O(N log N) sort-all-entries approach.
54#[derive(Debug)]
55struct CacheState {
56    /// Key → entry metadata.
57    entries: HashMap<String, CacheEntry>,
58    /// Ordered index: (generation, key) → size. Enables O(log N) LRU scan.
59    lru_index: BTreeMap<(u64, String), u64>,
60    /// Current total size of all cached files.
61    current_size: u64,
62}
63
64impl CacheState {
65    fn new() -> Self {
66        Self {
67            entries: HashMap::new(),
68            lru_index: BTreeMap::new(),
69            current_size: 0,
70        }
71    }
72
73    /// Insert or update an entry, keeping the LRU index in sync.
74    fn upsert(&mut self, key: String, entry: CacheEntry) {
75        // Remove old LRU index entry if overwriting
76        if let Some(old) = self.entries.get(&key) {
77            self.lru_index.remove(&(old.generation, key.clone()));
78            self.current_size -= old.size;
79        }
80        self.lru_index
81            .insert((entry.generation, key.clone()), entry.size);
82        self.current_size += entry.size;
83        self.entries.insert(key, entry);
84    }
85
86    /// Remove an entry, keeping the LRU index in sync.
87    fn remove(&mut self, key: &str) {
88        if let Some(old) = self.entries.remove(key) {
89            self.lru_index.remove(&(old.generation, key.to_string()));
90            self.current_size -= old.size;
91        }
92    }
93
94    /// Touch an entry (update generation in LRU index).
95    fn touch(&mut self, key: &str) {
96        if let Some(entry) = self.entries.get_mut(key) {
97            self.lru_index.remove(&(entry.generation, key.to_string()));
98            entry.generation = LRU_GENERATION.fetch_add(1, Ordering::Relaxed);
99            self.lru_index
100                .insert((entry.generation, key.to_string()), entry.size);
101        }
102    }
103}
104
105/// Local disk cache with LRU eviction.
106///
107/// Stores copies of remote objects under `cache_dir` using the object's
108/// path as the filename (slashes replaced with `__`). When the cache
109/// exceeds `max_size_bytes`, the least-recently accessed entries are
110/// evicted until the cache fits within budget.
111#[derive(Debug)]
112pub struct DiskCache {
113    config: CacheConfig,
114    state: Mutex<CacheState>,
115}
116
117impl DiskCache {
118    /// Create a new disk cache.
119    ///
120    /// The cache directory is created if it does not already exist.
121    ///
122    /// # Errors
123    ///
124    /// Returns `ObjStoreError::CacheIo` if the cache directory cannot be
125    /// created or scanned.
126    pub async fn new(config: CacheConfig) -> Result<Self> {
127        fs::create_dir_all(&config.cache_dir)
128            .await
129            .map_err(ObjStoreError::CacheIo)?;
130
131        let mut state = CacheState::new();
132
133        // Scan existing cache directory to rebuild state
134        let mut entries = fs::read_dir(&config.cache_dir)
135            .await
136            .map_err(ObjStoreError::CacheIo)?;
137
138        while let Some(entry) = entries.next_entry().await.map_err(ObjStoreError::CacheIo)? {
139            let metadata = entry.metadata().await.map_err(ObjStoreError::CacheIo)?;
140            if metadata.is_file() {
141                let filename = entry.file_name().to_string_lossy().to_string();
142                // Reverse key_to_path encoding: "%2F" → "/", "%25" → "%"
143                let key = Self::path_to_key(&filename);
144                let size = metadata.len();
145                // Assign a unique generation for LRU ordering
146                let gen = LRU_GENERATION.fetch_add(1, Ordering::Relaxed);
147                state.upsert(
148                    key,
149                    CacheEntry {
150                        size,
151                        generation: gen,
152                    },
153                );
154            }
155        }
156
157        debug!(
158            cache_dir = %config.cache_dir.display(),
159            entries = state.entries.len(),
160            current_size_bytes = state.current_size,
161            max_size_bytes = config.max_size_bytes,
162            "disk cache initialised"
163        );
164
165        Ok(Self {
166            config,
167            state: Mutex::new(state),
168        })
169    }
170
171    /// Get a cached object by key, returning the bytes if present.
172    ///
173    /// Updates the access time for LRU tracking. Returns `None` on miss.
174    pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
175        let file_path = self.key_to_path(key);
176
177        // Check existence in state first (fast path)
178        {
179            let mut state = self.state.lock();
180            if state.entries.contains_key(key) {
181                state.touch(key);
182            } else {
183                return None;
184            }
185        }
186
187        match fs::read(&file_path).await {
188            Ok(data) => {
189                debug!(key, size = data.len(), "cache hit");
190                Some(data)
191            }
192            Err(e) => {
193                warn!(key, error = %e, "cache entry missing on disk, evicting from state");
194                self.remove_from_state(key);
195                None
196            }
197        }
198    }
199
200    /// Store an object in the cache.
201    ///
202    /// Evicts LRU entries if necessary to fit within the size budget.
203    ///
204    /// # Errors
205    ///
206    /// Returns `ObjStoreError::CacheIo` on filesystem write failures.
207    pub async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
208        let size = data.len() as u64;
209
210        // If the object is larger than the cache, write it but don't track
211        // it in cache state — avoids violating the LRU size invariant.
212        if size > self.config.max_size_bytes {
213            debug!(
214                key,
215                size,
216                max = self.config.max_size_bytes,
217                "object exceeds cache budget, skipping cache"
218            );
219            return Ok(());
220        }
221
222        // Evict and reserve space atomically under the lock
223        // before writing to disk. This prevents a concurrent put() from
224        // evicting a file that's currently being fetched/read.
225        self.evict_to_fit(size).await?;
226
227        let file_path = self.key_to_path(key);
228
229        // Reserve the entry in state before writing — prevents concurrent
230        // eviction from re-using the space while we write.
231        {
232            let mut state = self.state.lock();
233            state.upsert(
234                key.to_string(),
235                CacheEntry {
236                    size,
237                    generation: LRU_GENERATION.fetch_add(1, Ordering::Relaxed),
238                },
239            );
240        }
241
242        if let Err(e) = fs::write(&file_path, data).await {
243            // Rollback on write failure — remove the reservation.
244            self.remove_from_state(key);
245            return Err(ObjStoreError::CacheIo(e));
246        }
247
248        debug!(key, size, "cached object");
249        Ok(())
250    }
251
252    /// Remove an object from the cache.
253    ///
254    /// # Errors
255    ///
256    /// Returns `ObjStoreError::CacheIo` on filesystem delete failures.
257    pub async fn remove(&self, key: &str) -> Result<()> {
258        let file_path = self.key_to_path(key);
259        self.remove_from_state(key);
260
261        match fs::remove_file(&file_path).await {
262            Ok(()) => Ok(()),
263            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
264            Err(e) => Err(ObjStoreError::CacheIo(e)),
265        }
266    }
267
268    /// Check if a key is in the cache without updating access time.
269    pub fn contains(&self, key: &str) -> bool {
270        self.state.lock().entries.contains_key(key)
271    }
272
273    /// Current total size of cached data.
274    pub fn current_size(&self) -> u64 {
275        self.state.lock().current_size
276    }
277
278    /// Number of entries in the cache.
279    pub fn entry_count(&self) -> usize {
280        self.state.lock().entries.len()
281    }
282
283    /// Evict LRU entries until `needed` bytes can fit.
284    async fn evict_to_fit(&self, needed: u64) -> Result<()> {
285        let max = self.config.max_size_bytes;
286        if needed > max {
287            // Single object larger than the entire cache — skip caching
288            return Ok(());
289        }
290
291        // Collect entries to evict under the lock, then delete files outside.
292        // Uses the BTreeMap LRU index for O(log N) iteration from oldest.
293        let to_evict: Vec<(String, PathBuf, u64)> = {
294            let mut state = self.state.lock();
295            if state.current_size + needed <= max {
296                return Ok(()); // fits already
297            }
298
299            let mut evict_list = Vec::new();
300            // Pop from BTreeMap front (oldest generation) until enough space is freed
301            while state.current_size + needed > max {
302                let Some(((_gen, key), _)) = state.lru_index.pop_first() else {
303                    break; // no more entries to evict
304                };
305                if let Some(entry) = state.entries.remove(&key) {
306                    let path = self.key_to_path(&key);
307                    state.current_size -= entry.size;
308                    evict_list.push((key, path, entry.size));
309                }
310            }
311
312            evict_list
313        };
314
315        let mut evicted = 0u64;
316        for (key, path, size) in &to_evict {
317            debug!(key, "evicting cached object");
318            if let Err(e) = fs::remove_file(path).await {
319                if e.kind() != std::io::ErrorKind::NotFound {
320                    warn!(key, error = %e, "failed to evict cached file, re-adding to state");
321                    // Rollback: re-insert the entry so state stays in sync with disk.
322                    // Use generation 0 so it stays at front of LRU (oldest) and
323                    // gets retried on next eviction pass.
324                    let mut state = self.state.lock();
325                    state.upsert(
326                        key.clone(),
327                        CacheEntry {
328                            size: *size,
329                            generation: 0,
330                        },
331                    );
332                    continue;
333                }
334            }
335            evicted += 1;
336        }
337
338        metrics::counter!("chronix_objstore_cache_evictions_total").increment(evicted);
339        Ok(())
340    }
341
342    fn remove_from_state(&self, key: &str) {
343        self.state.lock().remove(key);
344    }
345
346    fn key_to_path(&self, key: &str) -> PathBuf {
347        // Encode unsafe characters for flat filesystem storage.
348        // We percent-encode '/' and '%' to avoid ambiguity:
349        //   '/' → "%2F"   '%' → "%25"
350        // This is reversible and avoids the collision that a naive
351        // "replace('/', '__')" would have (e.g. key "a__b" vs "a/b").
352        let safe_name = key.replace('%', "%25").replace('/', "%2F");
353
354        // Guard against filesystem filename length limits (255 bytes
355        // on ext4/APFS). If the percent-encoded name exceeds 200 bytes,
356        // truncate it and append a hash suffix for uniqueness.
357        let filename = if safe_name.len() > 200 {
358            use std::collections::hash_map::DefaultHasher;
359            use std::hash::{Hash, Hasher};
360            let mut hasher = DefaultHasher::new();
361            key.hash(&mut hasher);
362            let h = hasher.finish();
363            format!("{:.100}_{:016x}", safe_name, h)
364        } else {
365            safe_name
366        };
367
368        self.config.cache_dir.join(filename)
369    }
370
371    /// Reverse of `key_to_path`'s filename encoding.
372    fn path_to_key(filename: &str) -> String {
373        filename.replace("%2F", "/").replace("%25", "%")
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use tempfile::TempDir;
381
382    async fn make_cache(max_size: u64) -> (DiskCache, TempDir) {
383        let tmp = TempDir::new().unwrap();
384        let config = CacheConfig {
385            cache_dir: tmp.path().to_path_buf(),
386            max_size_bytes: max_size,
387        };
388        let cache = DiskCache::new(config).await.unwrap();
389        (cache, tmp)
390    }
391
392    #[tokio::test]
393    async fn put_and_get_roundtrip() {
394        let (cache, _tmp) = make_cache(1024).await;
395        cache.put("test/object.bin", b"hello world").await.unwrap();
396
397        let data = cache.get("test/object.bin").await.unwrap();
398        assert_eq!(data, b"hello world");
399    }
400
401    #[tokio::test]
402    async fn get_missing_returns_none() {
403        let (cache, _tmp) = make_cache(1024).await;
404        assert!(cache.get("nonexistent").await.is_none());
405    }
406
407    #[tokio::test]
408    async fn remove_deletes_entry() {
409        let (cache, _tmp) = make_cache(1024).await;
410        cache.put("key", b"data").await.unwrap();
411        assert!(cache.contains("key"));
412
413        cache.remove("key").await.unwrap();
414        assert!(!cache.contains("key"));
415        assert!(cache.get("key").await.is_none());
416    }
417
418    #[tokio::test]
419    async fn remove_nonexistent_is_ok() {
420        let (cache, _tmp) = make_cache(1024).await;
421        cache.remove("ghost").await.unwrap();
422    }
423
424    #[tokio::test]
425    async fn overwrite_updates_size() {
426        let (cache, _tmp) = make_cache(1024).await;
427        cache.put("key", b"short").await.unwrap();
428        assert_eq!(cache.current_size(), 5);
429
430        cache.put("key", b"a longer value").await.unwrap();
431        assert_eq!(cache.current_size(), 14);
432        assert_eq!(cache.entry_count(), 1);
433    }
434
435    #[tokio::test]
436    async fn lru_eviction_frees_space() {
437        // Cache max = 20 bytes
438        let (cache, _tmp) = make_cache(20).await;
439
440        cache.put("a", &[0u8; 10]).await.unwrap();
441        // Small delay so timestamps differ
442        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
443        cache.put("b", &[1u8; 10]).await.unwrap();
444
445        assert_eq!(cache.entry_count(), 2);
446        assert_eq!(cache.current_size(), 20);
447
448        // Writing c(10 bytes) should evict a (oldest)
449        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
450        cache.put("c", &[2u8; 10]).await.unwrap();
451
452        assert!(!cache.contains("a"), "oldest entry 'a' should be evicted");
453        assert!(cache.contains("b"));
454        assert!(cache.contains("c"));
455        assert!(cache.current_size() <= 20);
456    }
457
458    #[tokio::test]
459    async fn object_larger_than_cache_skips_caching() {
460        let (cache, _tmp) = make_cache(10).await;
461        // Put a pre-existing item
462        cache.put("small", b"hi").await.unwrap();
463
464        // This is larger than the cache, eviction should not remove 'small'
465        cache.put("huge", &[0u8; 100]).await.unwrap();
466
467        // "small" should still be there since huge can't fit anyway
468        // (the current implementation writes the file regardless, but
469        // evict_to_fit returns early — so huge IS written on disk but
470        // didn't evict small)
471        assert!(cache.contains("small"));
472    }
473
474    #[tokio::test]
475    async fn rebuild_state_from_disk() {
476        let tmp = TempDir::new().unwrap();
477        let config = CacheConfig {
478            cache_dir: tmp.path().to_path_buf(),
479            max_size_bytes: 1024,
480        };
481
482        // Write files directly
483        fs::write(tmp.path().join("file1"), b"aaa").await.unwrap();
484        fs::write(tmp.path().join("file2"), b"bbbbb").await.unwrap();
485
486        let cache = DiskCache::new(config).await.unwrap();
487        assert_eq!(cache.entry_count(), 2);
488        assert_eq!(cache.current_size(), 8); // 3 + 5
489    }
490
491    #[tokio::test]
492    async fn key_to_path_replaces_slashes() {
493        let (cache, tmp) = make_cache(1024).await;
494        let path = cache.key_to_path("shard_1/segment_001.csx");
495        assert_eq!(path, tmp.path().join("shard_1%2Fsegment_001.csx"));
496
497        // Roundtrip: path_to_key reverses the encoding
498        let filename = path.file_name().unwrap().to_str().unwrap();
499        assert_eq!(DiskCache::path_to_key(filename), "shard_1/segment_001.csx");
500
501        // Keys containing '%' are encoded unambiguously
502        let path2 = cache.key_to_path("100%/done");
503        assert_eq!(path2, tmp.path().join("100%25%2Fdone"));
504        let filename2 = path2.file_name().unwrap().to_str().unwrap();
505        assert_eq!(DiskCache::path_to_key(filename2), "100%/done");
506    }
507
508    #[tokio::test]
509    async fn current_size_and_entry_count() {
510        let (cache, _tmp) = make_cache(1024).await;
511        assert_eq!(cache.current_size(), 0);
512        assert_eq!(cache.entry_count(), 0);
513
514        cache.put("a", b"hello").await.unwrap();
515        assert_eq!(cache.current_size(), 5);
516        assert_eq!(cache.entry_count(), 1);
517
518        cache.put("b", b"world!").await.unwrap();
519        assert_eq!(cache.current_size(), 11);
520        assert_eq!(cache.entry_count(), 2);
521    }
522
523    #[tokio::test]
524    async fn test_long_cache_key_doesnt_overflow() {
525        let (cache, _tmp) = make_cache(4096).await;
526        // Build a key that, after percent-encoding, exceeds 200 bytes
527        let long_key = "a/".repeat(120); // 240 chars → each '/' becomes "%2F" → huge
528        let path = cache.key_to_path(&long_key);
529        let filename = path.file_name().unwrap().to_str().unwrap();
530
531        // Filename must stay within filesystem limits
532        assert!(
533            filename.len() <= 255,
534            "filename length {} exceeds 255-byte filesystem limit",
535            filename.len()
536        );
537
538        // Should contain the hash suffix (16 hex chars after '_')
539        assert!(
540            filename.contains('_'),
541            "long filename should contain a hash suffix"
542        );
543
544        // put/get roundtrip still works
545        cache.put(&long_key, b"long key data").await.unwrap();
546        let data = cache.get(&long_key).await.unwrap();
547        assert_eq!(data, b"long key data");
548    }
549}