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 generation = LRU_GENERATION.fetch_add(1, Ordering::Relaxed);
147                state.upsert(key, CacheEntry { size, generation });
148            }
149        }
150
151        debug!(
152            cache_dir = %config.cache_dir.display(),
153            entries = state.entries.len(),
154            current_size_bytes = state.current_size,
155            max_size_bytes = config.max_size_bytes,
156            "disk cache initialised"
157        );
158
159        Ok(Self {
160            config,
161            state: Mutex::new(state),
162        })
163    }
164
165    /// Get a cached object by key, returning the bytes if present.
166    ///
167    /// Updates the access time for LRU tracking. Returns `None` on miss.
168    pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
169        let file_path = self.key_to_path(key);
170
171        // Check existence in state first (fast path)
172        {
173            let mut state = self.state.lock();
174            if state.entries.contains_key(key) {
175                state.touch(key);
176            } else {
177                return None;
178            }
179        }
180
181        match fs::read(&file_path).await {
182            Ok(data) => {
183                debug!(key, size = data.len(), "cache hit");
184                Some(data)
185            }
186            Err(e) => {
187                warn!(key, error = %e, "cache entry missing on disk, evicting from state");
188                self.remove_from_state(key);
189                None
190            }
191        }
192    }
193
194    /// Store an object in the cache.
195    ///
196    /// Evicts LRU entries if necessary to fit within the size budget.
197    ///
198    /// # Errors
199    ///
200    /// Returns `ObjStoreError::CacheIo` on filesystem write failures.
201    pub async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
202        let size = data.len() as u64;
203
204        // If the object is larger than the cache, write it but don't track
205        // it in cache state — avoids violating the LRU size invariant.
206        if size > self.config.max_size_bytes {
207            debug!(
208                key,
209                size,
210                max = self.config.max_size_bytes,
211                "object exceeds cache budget, skipping cache"
212            );
213            return Ok(());
214        }
215
216        // Evict and reserve space atomically under the lock
217        // before writing to disk. This prevents a concurrent put() from
218        // evicting a file that's currently being fetched/read.
219        self.evict_to_fit(size).await?;
220
221        let file_path = self.key_to_path(key);
222
223        // Reserve the entry in state before writing — prevents concurrent
224        // eviction from re-using the space while we write.
225        {
226            let mut state = self.state.lock();
227            state.upsert(
228                key.to_string(),
229                CacheEntry {
230                    size,
231                    generation: LRU_GENERATION.fetch_add(1, Ordering::Relaxed),
232                },
233            );
234        }
235
236        if let Err(e) = fs::write(&file_path, data).await {
237            // Rollback on write failure — remove the reservation.
238            self.remove_from_state(key);
239            return Err(ObjStoreError::CacheIo(e));
240        }
241
242        debug!(key, size, "cached object");
243        Ok(())
244    }
245
246    /// Remove an object from the cache.
247    ///
248    /// # Errors
249    ///
250    /// Returns `ObjStoreError::CacheIo` on filesystem delete failures.
251    pub async fn remove(&self, key: &str) -> Result<()> {
252        let file_path = self.key_to_path(key);
253        self.remove_from_state(key);
254
255        match fs::remove_file(&file_path).await {
256            Ok(()) => Ok(()),
257            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
258            Err(e) => Err(ObjStoreError::CacheIo(e)),
259        }
260    }
261
262    /// Check if a key is in the cache without updating access time.
263    pub fn contains(&self, key: &str) -> bool {
264        self.state.lock().entries.contains_key(key)
265    }
266
267    /// Current total size of cached data.
268    pub fn current_size(&self) -> u64 {
269        self.state.lock().current_size
270    }
271
272    /// Number of entries in the cache.
273    pub fn entry_count(&self) -> usize {
274        self.state.lock().entries.len()
275    }
276
277    /// Evict LRU entries until `needed` bytes can fit.
278    async fn evict_to_fit(&self, needed: u64) -> Result<()> {
279        let max = self.config.max_size_bytes;
280        if needed > max {
281            // Single object larger than the entire cache — skip caching
282            return Ok(());
283        }
284
285        // Collect entries to evict under the lock, then delete files outside.
286        // Uses the BTreeMap LRU index for O(log N) iteration from oldest.
287        let to_evict: Vec<(String, PathBuf, u64)> = {
288            let mut state = self.state.lock();
289            if state.current_size + needed <= max {
290                return Ok(()); // fits already
291            }
292
293            let mut evict_list = Vec::new();
294            // Pop from BTreeMap front (oldest generation) until enough space is freed
295            while state.current_size + needed > max {
296                let Some(((_gen, key), _)) = state.lru_index.pop_first() else {
297                    break; // no more entries to evict
298                };
299                if let Some(entry) = state.entries.remove(&key) {
300                    let path = self.key_to_path(&key);
301                    state.current_size -= entry.size;
302                    evict_list.push((key, path, entry.size));
303                }
304            }
305
306            evict_list
307        };
308
309        let mut evicted = 0u64;
310        for (key, path, size) in &to_evict {
311            debug!(key, "evicting cached object");
312            if let Err(e) = fs::remove_file(path).await
313                && e.kind() != std::io::ErrorKind::NotFound
314            {
315                warn!(key, error = %e, "failed to evict cached file, re-adding to state");
316                // Rollback: re-insert the entry so state stays in sync with disk.
317                // Use generation 0 so it stays at front of LRU (oldest) and
318                // gets retried on next eviction pass.
319                let mut state = self.state.lock();
320                state.upsert(
321                    key.clone(),
322                    CacheEntry {
323                        size: *size,
324                        generation: 0,
325                    },
326                );
327                continue;
328            }
329            evicted += 1;
330        }
331
332        metrics::counter!("chronix_objstore_cache_evictions_total").increment(evicted);
333        Ok(())
334    }
335
336    fn remove_from_state(&self, key: &str) {
337        self.state.lock().remove(key);
338    }
339
340    fn key_to_path(&self, key: &str) -> PathBuf {
341        // Encode unsafe characters for flat filesystem storage.
342        // We percent-encode '/' and '%' to avoid ambiguity:
343        //   '/' → "%2F"   '%' → "%25"
344        // This is reversible and avoids the collision that a naive
345        // "replace('/', '__')" would have (e.g. key "a__b" vs "a/b").
346        let safe_name = key.replace('%', "%25").replace('/', "%2F");
347
348        // Guard against filesystem filename length limits (255 bytes
349        // on ext4/APFS). If the percent-encoded name exceeds 200 bytes,
350        // truncate it and append a hash suffix for uniqueness.
351        let filename = if safe_name.len() > 200 {
352            use std::collections::hash_map::DefaultHasher;
353            use std::hash::{Hash, Hasher};
354            let mut hasher = DefaultHasher::new();
355            key.hash(&mut hasher);
356            let h = hasher.finish();
357            format!("{:.100}_{:016x}", safe_name, h)
358        } else {
359            safe_name
360        };
361
362        self.config.cache_dir.join(filename)
363    }
364
365    /// Reverse of `key_to_path`'s filename encoding.
366    fn path_to_key(filename: &str) -> String {
367        filename.replace("%2F", "/").replace("%25", "%")
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use tempfile::TempDir;
375
376    async fn make_cache(max_size: u64) -> (DiskCache, TempDir) {
377        let tmp = TempDir::new().unwrap();
378        let config = CacheConfig {
379            cache_dir: tmp.path().to_path_buf(),
380            max_size_bytes: max_size,
381        };
382        let cache = DiskCache::new(config).await.unwrap();
383        (cache, tmp)
384    }
385
386    #[tokio::test]
387    async fn put_and_get_roundtrip() {
388        let (cache, _tmp) = make_cache(1024).await;
389        cache.put("test/object.bin", b"hello world").await.unwrap();
390
391        let data = cache.get("test/object.bin").await.unwrap();
392        assert_eq!(data, b"hello world");
393    }
394
395    #[tokio::test]
396    async fn get_missing_returns_none() {
397        let (cache, _tmp) = make_cache(1024).await;
398        assert!(cache.get("nonexistent").await.is_none());
399    }
400
401    #[tokio::test]
402    async fn remove_deletes_entry() {
403        let (cache, _tmp) = make_cache(1024).await;
404        cache.put("key", b"data").await.unwrap();
405        assert!(cache.contains("key"));
406
407        cache.remove("key").await.unwrap();
408        assert!(!cache.contains("key"));
409        assert!(cache.get("key").await.is_none());
410    }
411
412    #[tokio::test]
413    async fn remove_nonexistent_is_ok() {
414        let (cache, _tmp) = make_cache(1024).await;
415        cache.remove("ghost").await.unwrap();
416    }
417
418    #[tokio::test]
419    async fn overwrite_updates_size() {
420        let (cache, _tmp) = make_cache(1024).await;
421        cache.put("key", b"short").await.unwrap();
422        assert_eq!(cache.current_size(), 5);
423
424        cache.put("key", b"a longer value").await.unwrap();
425        assert_eq!(cache.current_size(), 14);
426        assert_eq!(cache.entry_count(), 1);
427    }
428
429    #[tokio::test]
430    async fn lru_eviction_frees_space() {
431        // Cache max = 20 bytes
432        let (cache, _tmp) = make_cache(20).await;
433
434        cache.put("a", &[0u8; 10]).await.unwrap();
435        // Small delay so timestamps differ
436        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
437        cache.put("b", &[1u8; 10]).await.unwrap();
438
439        assert_eq!(cache.entry_count(), 2);
440        assert_eq!(cache.current_size(), 20);
441
442        // Writing c(10 bytes) should evict a (oldest)
443        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
444        cache.put("c", &[2u8; 10]).await.unwrap();
445
446        assert!(!cache.contains("a"), "oldest entry 'a' should be evicted");
447        assert!(cache.contains("b"));
448        assert!(cache.contains("c"));
449        assert!(cache.current_size() <= 20);
450    }
451
452    #[tokio::test]
453    async fn object_larger_than_cache_skips_caching() {
454        let (cache, _tmp) = make_cache(10).await;
455        // Put a pre-existing item
456        cache.put("small", b"hi").await.unwrap();
457
458        // This is larger than the cache, eviction should not remove 'small'
459        cache.put("huge", &[0u8; 100]).await.unwrap();
460
461        // "small" should still be there since huge can't fit anyway
462        // (the current implementation writes the file regardless, but
463        // evict_to_fit returns early — so huge IS written on disk but
464        // didn't evict small)
465        assert!(cache.contains("small"));
466    }
467
468    #[tokio::test]
469    async fn rebuild_state_from_disk() {
470        let tmp = TempDir::new().unwrap();
471        let config = CacheConfig {
472            cache_dir: tmp.path().to_path_buf(),
473            max_size_bytes: 1024,
474        };
475
476        // Write files directly
477        fs::write(tmp.path().join("file1"), b"aaa").await.unwrap();
478        fs::write(tmp.path().join("file2"), b"bbbbb").await.unwrap();
479
480        let cache = DiskCache::new(config).await.unwrap();
481        assert_eq!(cache.entry_count(), 2);
482        assert_eq!(cache.current_size(), 8); // 3 + 5
483    }
484
485    #[tokio::test]
486    async fn key_to_path_replaces_slashes() {
487        let (cache, tmp) = make_cache(1024).await;
488        let path = cache.key_to_path("shard_1/segment_001.csx");
489        assert_eq!(path, tmp.path().join("shard_1%2Fsegment_001.csx"));
490
491        // Roundtrip: path_to_key reverses the encoding
492        let filename = path.file_name().unwrap().to_str().unwrap();
493        assert_eq!(DiskCache::path_to_key(filename), "shard_1/segment_001.csx");
494
495        // Keys containing '%' are encoded unambiguously
496        let path2 = cache.key_to_path("100%/done");
497        assert_eq!(path2, tmp.path().join("100%25%2Fdone"));
498        let filename2 = path2.file_name().unwrap().to_str().unwrap();
499        assert_eq!(DiskCache::path_to_key(filename2), "100%/done");
500    }
501
502    #[tokio::test]
503    async fn current_size_and_entry_count() {
504        let (cache, _tmp) = make_cache(1024).await;
505        assert_eq!(cache.current_size(), 0);
506        assert_eq!(cache.entry_count(), 0);
507
508        cache.put("a", b"hello").await.unwrap();
509        assert_eq!(cache.current_size(), 5);
510        assert_eq!(cache.entry_count(), 1);
511
512        cache.put("b", b"world!").await.unwrap();
513        assert_eq!(cache.current_size(), 11);
514        assert_eq!(cache.entry_count(), 2);
515    }
516
517    #[tokio::test]
518    async fn test_long_cache_key_doesnt_overflow() {
519        let (cache, _tmp) = make_cache(4096).await;
520        // Build a key that, after percent-encoding, exceeds 200 bytes
521        let long_key = "a/".repeat(120); // 240 chars → each '/' becomes "%2F" → huge
522        let path = cache.key_to_path(&long_key);
523        let filename = path.file_name().unwrap().to_str().unwrap();
524
525        // Filename must stay within filesystem limits
526        assert!(
527            filename.len() <= 255,
528            "filename length {} exceeds 255-byte filesystem limit",
529            filename.len()
530        );
531
532        // Should contain the hash suffix (16 hex chars after '_')
533        assert!(
534            filename.contains('_'),
535            "long filename should contain a hash suffix"
536        );
537
538        // put/get roundtrip still works
539        cache.put(&long_key, b"long key data").await.unwrap();
540        let data = cache.get(&long_key).await.unwrap();
541        assert_eq!(data, b"long key data");
542    }
543}