guts_storage/
cache.rs

1//! LRU cache layer for storage backends.
2//!
3//! Provides a caching layer that can wrap any storage backend
4//! to improve read performance for frequently accessed objects.
5
6use crate::{GitObject, ObjectId, Result};
7use lru::LruCache;
8use parking_lot::Mutex;
9use std::num::NonZeroUsize;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12/// Configuration for the cache layer.
13#[derive(Debug, Clone)]
14pub struct CacheConfig {
15    /// Maximum number of objects to cache.
16    pub max_objects: usize,
17    /// Maximum total size in bytes.
18    pub max_size_bytes: usize,
19    /// Whether to cache on write (write-through).
20    pub write_through: bool,
21    /// TTL for cached objects in seconds (0 = no expiry).
22    pub ttl_seconds: u64,
23}
24
25impl Default for CacheConfig {
26    fn default() -> Self {
27        Self {
28            max_objects: 10_000,
29            max_size_bytes: 256 * 1024 * 1024, // 256 MB
30            write_through: true,
31            ttl_seconds: 0,
32        }
33    }
34}
35
36/// Cache statistics.
37#[derive(Debug, Clone, Default)]
38pub struct CacheStats {
39    /// Number of cache hits.
40    pub hits: u64,
41    /// Number of cache misses.
42    pub misses: u64,
43    /// Number of evictions.
44    pub evictions: u64,
45    /// Current number of cached objects.
46    pub size: usize,
47    /// Current memory usage in bytes.
48    pub memory_bytes: usize,
49}
50
51impl CacheStats {
52    /// Returns the cache hit ratio.
53    pub fn hit_ratio(&self) -> f64 {
54        let total = self.hits + self.misses;
55        if total == 0 {
56            0.0
57        } else {
58            self.hits as f64 / total as f64
59        }
60    }
61}
62
63/// Cache metrics for monitoring.
64#[derive(Debug, Default)]
65pub struct CacheMetrics {
66    hits: AtomicU64,
67    misses: AtomicU64,
68    evictions: AtomicU64,
69}
70
71impl CacheMetrics {
72    /// Creates new cache metrics.
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Records a cache hit.
78    pub fn record_hit(&self) {
79        self.hits.fetch_add(1, Ordering::Relaxed);
80    }
81
82    /// Records a cache miss.
83    pub fn record_miss(&self) {
84        self.misses.fetch_add(1, Ordering::Relaxed);
85    }
86
87    /// Records an eviction.
88    pub fn record_eviction(&self) {
89        self.evictions.fetch_add(1, Ordering::Relaxed);
90    }
91
92    /// Returns current metrics.
93    pub fn snapshot(&self) -> CacheStats {
94        CacheStats {
95            hits: self.hits.load(Ordering::Relaxed),
96            misses: self.misses.load(Ordering::Relaxed),
97            evictions: self.evictions.load(Ordering::Relaxed),
98            size: 0,
99            memory_bytes: 0,
100        }
101    }
102}
103
104/// Cached storage wrapper.
105///
106/// Wraps any storage backend with an LRU cache for improved read performance.
107pub struct CachedStorage<S> {
108    /// The underlying storage backend.
109    inner: S,
110    /// LRU cache for objects.
111    cache: Mutex<LruCache<ObjectId, GitObject>>,
112    /// Current size in bytes.
113    current_size: AtomicU64,
114    /// Configuration.
115    config: CacheConfig,
116    /// Metrics.
117    metrics: CacheMetrics,
118}
119
120impl<S> CachedStorage<S> {
121    /// Creates a new cached storage wrapper.
122    pub fn new(inner: S, config: CacheConfig) -> Self {
123        let max_objects = NonZeroUsize::new(config.max_objects).unwrap_or(NonZeroUsize::MIN);
124        Self {
125            inner,
126            cache: Mutex::new(LruCache::new(max_objects)),
127            current_size: AtomicU64::new(0),
128            config,
129            metrics: CacheMetrics::new(),
130        }
131    }
132
133    /// Creates a cached storage with default configuration.
134    pub fn with_defaults(inner: S) -> Self {
135        Self::new(inner, CacheConfig::default())
136    }
137
138    /// Returns the underlying storage.
139    pub fn inner(&self) -> &S {
140        &self.inner
141    }
142
143    /// Returns the cache configuration.
144    pub fn config(&self) -> &CacheConfig {
145        &self.config
146    }
147
148    /// Returns current cache statistics.
149    pub fn stats(&self) -> CacheStats {
150        let cache = self.cache.lock();
151        let mut stats = self.metrics.snapshot();
152        stats.size = cache.len();
153        stats.memory_bytes = self.current_size.load(Ordering::Relaxed) as usize;
154        stats
155    }
156
157    /// Clears the cache.
158    pub fn clear(&self) {
159        let mut cache = self.cache.lock();
160        cache.clear();
161        self.current_size.store(0, Ordering::Relaxed);
162    }
163
164    /// Invalidates a specific object from the cache.
165    pub fn invalidate(&self, id: &ObjectId) {
166        let mut cache = self.cache.lock();
167        if let Some(obj) = cache.pop(id) {
168            let size = obj.data.len() as u64;
169            self.current_size.fetch_sub(size, Ordering::Relaxed);
170        }
171    }
172
173    /// Attempts to get an object from cache.
174    fn cache_get(&self, id: &ObjectId) -> Option<GitObject> {
175        let mut cache = self.cache.lock();
176        cache.get(id).cloned()
177    }
178
179    /// Puts an object into the cache.
180    fn cache_put(&self, object: &GitObject) {
181        let size = object.data.len() as u64;
182
183        // Check if we need to evict
184        let mut cache = self.cache.lock();
185        while self.current_size.load(Ordering::Relaxed) + size > self.config.max_size_bytes as u64 {
186            if let Some((_, evicted)) = cache.pop_lru() {
187                let evicted_size = evicted.data.len() as u64;
188                self.current_size.fetch_sub(evicted_size, Ordering::Relaxed);
189                self.metrics.record_eviction();
190            } else {
191                break;
192            }
193        }
194
195        // Insert into cache
196        if let Some(old) = cache.put(object.id, object.clone()) {
197            let old_size = old.data.len() as u64;
198            self.current_size.fetch_sub(old_size, Ordering::Relaxed);
199        }
200        self.current_size.fetch_add(size, Ordering::Relaxed);
201    }
202}
203
204impl<S> CachedStorage<S>
205where
206    S: crate::traits::ObjectStoreBackend,
207{
208    /// Gets an object, checking cache first.
209    pub fn get(&self, id: &ObjectId) -> Result<Option<GitObject>> {
210        // Check cache first
211        if let Some(obj) = self.cache_get(id) {
212            self.metrics.record_hit();
213            return Ok(Some(obj));
214        }
215
216        self.metrics.record_miss();
217
218        // Fetch from underlying storage
219        let result = self.inner.get(id)?;
220
221        // Cache the result if found
222        if let Some(ref obj) = result {
223            self.cache_put(obj);
224        }
225
226        Ok(result)
227    }
228
229    /// Puts an object, optionally caching it.
230    pub fn put(&self, object: GitObject) -> Result<ObjectId> {
231        let id = self.inner.put(object.clone())?;
232
233        if self.config.write_through {
234            self.cache_put(&object);
235        }
236
237        Ok(id)
238    }
239
240    /// Checks if an object exists.
241    pub fn contains(&self, id: &ObjectId) -> Result<bool> {
242        // Check cache first
243        {
244            let cache = self.cache.lock();
245            if cache.contains(id) {
246                return Ok(true);
247            }
248        }
249
250        // Check underlying storage
251        self.inner.contains(id)
252    }
253
254    /// Deletes an object.
255    pub fn delete(&self, id: &ObjectId) -> Result<bool> {
256        self.invalidate(id);
257        self.inner.delete(id)
258    }
259
260    /// Returns the number of objects.
261    pub fn len(&self) -> Result<usize> {
262        self.inner.len()
263    }
264
265    /// Returns true if empty.
266    pub fn is_empty(&self) -> Result<bool> {
267        self.inner.is_empty()
268    }
269
270    /// Lists all object IDs.
271    pub fn list_objects(&self) -> Result<Vec<ObjectId>> {
272        self.inner.list_objects()
273    }
274
275    /// Batch get with caching.
276    pub fn batch_get(&self, ids: &[ObjectId]) -> Result<Vec<Option<GitObject>>> {
277        let mut results = Vec::with_capacity(ids.len());
278        let mut uncached = Vec::new();
279        let mut uncached_indices = Vec::new();
280
281        // Check cache first
282        for (i, id) in ids.iter().enumerate() {
283            if let Some(obj) = self.cache_get(id) {
284                self.metrics.record_hit();
285                results.push(Some(obj));
286            } else {
287                self.metrics.record_miss();
288                uncached.push(*id);
289                uncached_indices.push(i);
290                results.push(None);
291            }
292        }
293
294        // Fetch uncached from storage
295        if !uncached.is_empty() {
296            let fetched = self.inner.batch_get(&uncached)?;
297            for (i, obj) in uncached_indices.into_iter().zip(fetched) {
298                if let Some(ref o) = obj {
299                    self.cache_put(o);
300                }
301                results[i] = obj;
302            }
303        }
304
305        Ok(results)
306    }
307}
308
309// Implement ObjectStoreBackend for CachedStorage
310impl<S> crate::traits::ObjectStoreBackend for CachedStorage<S>
311where
312    S: crate::traits::ObjectStoreBackend,
313{
314    fn put(&self, object: GitObject) -> Result<ObjectId> {
315        CachedStorage::put(self, object)
316    }
317
318    fn get(&self, id: &ObjectId) -> Result<Option<GitObject>> {
319        CachedStorage::get(self, id)
320    }
321
322    fn contains(&self, id: &ObjectId) -> Result<bool> {
323        CachedStorage::contains(self, id)
324    }
325
326    fn delete(&self, id: &ObjectId) -> Result<bool> {
327        CachedStorage::delete(self, id)
328    }
329
330    fn len(&self) -> Result<usize> {
331        CachedStorage::len(self)
332    }
333
334    fn list_objects(&self) -> Result<Vec<ObjectId>> {
335        CachedStorage::list_objects(self)
336    }
337
338    fn batch_get(&self, ids: &[ObjectId]) -> Result<Vec<Option<GitObject>>> {
339        CachedStorage::batch_get(self, ids)
340    }
341
342    fn flush(&self) -> Result<()> {
343        self.inner.flush()
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::ObjectStore;
351
352    #[test]
353    fn test_cache_hit() {
354        let store = ObjectStore::new();
355        let cached = CachedStorage::with_defaults(store);
356
357        let obj = GitObject::blob(b"test data".to_vec());
358        let id = cached.put(obj).unwrap();
359
360        // First get should hit cache
361        let result = cached.get(&id).unwrap();
362        assert!(result.is_some());
363
364        let stats = cached.stats();
365        assert_eq!(stats.hits, 1);
366        assert_eq!(stats.misses, 0);
367    }
368
369    #[test]
370    fn test_cache_miss_then_hit() {
371        let store = ObjectStore::new();
372
373        // Put directly in store
374        let obj = GitObject::blob(b"test data".to_vec());
375        let id = store.put(obj);
376
377        let cached = CachedStorage::with_defaults(store);
378
379        // First get - cache miss
380        let result = cached.get(&id).unwrap();
381        assert!(result.is_some());
382
383        // Second get - cache hit
384        let result = cached.get(&id).unwrap();
385        assert!(result.is_some());
386
387        let stats = cached.stats();
388        assert_eq!(stats.hits, 1);
389        assert_eq!(stats.misses, 1);
390    }
391
392    #[test]
393    fn test_cache_invalidation() {
394        let store = ObjectStore::new();
395        let cached = CachedStorage::with_defaults(store);
396
397        let obj = GitObject::blob(b"test data".to_vec());
398        let id = cached.put(obj).unwrap();
399
400        // Invalidate
401        cached.invalidate(&id);
402
403        // Next get should be a miss
404        let _ = cached.get(&id).unwrap();
405
406        let stats = cached.stats();
407        assert_eq!(stats.misses, 1);
408    }
409
410    #[test]
411    fn test_cache_eviction() {
412        let config = CacheConfig {
413            max_objects: 2,
414            max_size_bytes: 50, // Small size to trigger eviction
415            write_through: true,
416            ttl_seconds: 0,
417        };
418        let store = ObjectStore::new();
419        let cached = CachedStorage::new(store, config);
420
421        // Add 3 objects (each ~20 bytes, so 3rd will exceed 50 byte limit)
422        for i in 0..3 {
423            let obj = GitObject::blob(format!("data-{}-padding", i).into_bytes());
424            cached.put(obj).unwrap();
425        }
426
427        let stats = cached.stats();
428        assert!(stats.size <= 2); // Should have evicted at least one
429    }
430
431    #[test]
432    fn test_cache_clear() {
433        let store = ObjectStore::new();
434        let cached = CachedStorage::with_defaults(store);
435
436        let obj = GitObject::blob(b"test data".to_vec());
437        cached.put(obj).unwrap();
438
439        cached.clear();
440
441        let stats = cached.stats();
442        assert_eq!(stats.size, 0);
443    }
444
445    #[test]
446    fn test_hit_ratio() {
447        let stats = CacheStats {
448            hits: 8,
449            misses: 2,
450            evictions: 0,
451            size: 10,
452            memory_bytes: 1000,
453        };
454        assert!((stats.hit_ratio() - 0.8).abs() < 0.001);
455    }
456
457    #[test]
458    fn test_hit_ratio_zero_total() {
459        let stats = CacheStats::default();
460        assert_eq!(stats.hit_ratio(), 0.0);
461    }
462
463    // Helper implementation for tests
464    impl crate::traits::ObjectStoreBackend for ObjectStore {
465        fn put(&self, object: GitObject) -> Result<ObjectId> {
466            Ok(ObjectStore::put(self, object))
467        }
468
469        fn get(&self, id: &ObjectId) -> Result<Option<GitObject>> {
470            match ObjectStore::get(self, id) {
471                Ok(obj) => Ok(Some(obj)),
472                Err(crate::StorageError::ObjectNotFound(_)) => Ok(None),
473                Err(e) => Err(e),
474            }
475        }
476
477        fn contains(&self, id: &ObjectId) -> Result<bool> {
478            Ok(ObjectStore::contains(self, id))
479        }
480
481        fn delete(&self, _id: &ObjectId) -> Result<bool> {
482            Ok(false) // Not implemented for basic ObjectStore
483        }
484
485        fn len(&self) -> Result<usize> {
486            Ok(ObjectStore::len(self))
487        }
488
489        fn list_objects(&self) -> Result<Vec<ObjectId>> {
490            Ok(ObjectStore::list_objects(self))
491        }
492    }
493}