Skip to main content

ipfrs_storage/
cache.rs

1//! In-memory block cache — L1/L2 hot cache above a backing BlockStore
2//!
3//! # Overview
4//!
5//! Two public cache abstractions are provided:
6//!
7//! * [`BlockCache`] — a standalone LRU cache (pure in-memory, no backing store).
8//! * [`CachedBlockStore<S>`] — a two-level cache that wraps any `BlockStore` (L2)
9//!   with an in-process LRU cache (L1).  Stats are tracked with lock-free atomics.
10//!
11//! A tiered variant ([`TieredCachedBlockStore`]) is also available for workloads
12//! that benefit from separate hot/warm tiers.
13
14use crate::traits::BlockStore;
15use async_trait::async_trait;
16use ipfrs_core::{Block, Cid, Result};
17use lru::LruCache;
18use parking_lot::Mutex;
19use serde::Serialize;
20use std::num::NonZeroUsize;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::Arc;
23
24// ---------------------------------------------------------------------------
25// CacheConfig
26// ---------------------------------------------------------------------------
27
28/// Configuration for [`CachedBlockStore`].
29#[derive(Debug, Clone)]
30pub struct CacheConfig {
31    /// Maximum number of blocks kept in the L1 LRU cache.
32    ///
33    /// Default: 1 024.
34    pub l1_capacity: NonZeroUsize,
35
36    /// Blocks larger than this byte threshold are stored in L2 (the backing
37    /// store) but **not** promoted to L1, keeping the hot-path cache lean.
38    ///
39    /// Default: 256 KiB.
40    pub max_block_bytes: usize,
41}
42
43impl Default for CacheConfig {
44    fn default() -> Self {
45        Self {
46            l1_capacity: NonZeroUsize::new(1024).expect("1024 > 0"),
47            max_block_bytes: 256 * 1024,
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// CacheStats — lock-free atomic counters
54// ---------------------------------------------------------------------------
55
56/// Live, lock-free counters for a [`CachedBlockStore`].
57///
58/// Each field is an [`AtomicU64`] so that observers can read a consistent
59/// snapshot without acquiring any locks.  Ordering is `Relaxed` throughout
60/// because counter values are only informational.
61#[derive(Debug, Default, Serialize)]
62pub struct CacheStats {
63    /// Number of get() calls satisfied from L1.
64    pub hits: AtomicU64,
65    /// Number of get() calls that missed L1 (including blocks not in L2).
66    pub misses: AtomicU64,
67    /// Approximate number of LRU evictions from L1.
68    pub evictions: AtomicU64,
69    /// Number of put() calls (all, including oversized blocks).
70    pub puts: AtomicU64,
71}
72
73impl CacheStats {
74    /// Instantaneous hit-rate in [0.0, 1.0].
75    ///
76    /// Returns `0.0` when no get() calls have been made yet.
77    pub fn hit_rate(&self) -> f64 {
78        let h = self.hits.load(Ordering::Relaxed);
79        let m = self.misses.load(Ordering::Relaxed);
80        let total = h + m;
81        if total == 0 {
82            0.0
83        } else {
84            h as f64 / total as f64
85        }
86    }
87
88    /// Take a point-in-time snapshot of all counters.
89    pub fn snapshot(&self) -> CacheStatsSnapshot {
90        let hits = self.hits.load(Ordering::Relaxed);
91        let misses = self.misses.load(Ordering::Relaxed);
92        let evictions = self.evictions.load(Ordering::Relaxed);
93        let puts = self.puts.load(Ordering::Relaxed);
94        let total = hits + misses;
95        let hit_rate = if total == 0 {
96            0.0
97        } else {
98            hits as f64 / total as f64
99        };
100        CacheStatsSnapshot {
101            hits,
102            misses,
103            evictions,
104            puts,
105            hit_rate,
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// CacheStatsSnapshot — a cloneable, serialisable view of CacheStats
112// ---------------------------------------------------------------------------
113
114/// A serialisable, cloneable point-in-time view of [`CacheStats`].
115#[derive(Clone, Debug, Serialize)]
116pub struct CacheStatsSnapshot {
117    /// Hits at the time of the snapshot.
118    pub hits: u64,
119    /// Misses at the time of the snapshot.
120    pub misses: u64,
121    /// LRU evictions at the time of the snapshot.
122    pub evictions: u64,
123    /// puts at the time of the snapshot.
124    pub puts: u64,
125    /// Derived hit-rate \[0.0, 1.0\] at the time of the snapshot.
126    pub hit_rate: f64,
127}
128
129// ---------------------------------------------------------------------------
130// CachedBlockStore — L1 LRU in front of a backing BlockStore (L2)
131// ---------------------------------------------------------------------------
132
133/// A two-level cache that wraps any [`BlockStore`] (L2) with an in-process
134/// LRU cache (L1).
135///
136/// # Cache behaviour
137///
138/// | Operation  | L1 action                     | L2 action                          |
139/// |------------|-------------------------------|------------------------------------|
140/// | `put`      | insert (if ≤ max_block_bytes) | always written                     |
141/// | `get` hit  | return from L1                | not consulted                      |
142/// | `get` miss | populate on L2 hit            | queried; result promoted to L1     |
143/// | `delete`   | evict                         | deleted                            |
144/// | `has`      | cheap presence check          | consulted only on L1 miss          |
145pub struct CachedBlockStore<S: BlockStore> {
146    /// The backing store (L2).
147    inner: S,
148    /// L1 LRU keyed by the canonical CID string representation.
149    cache: Mutex<LruCache<String, bytes::Bytes>>,
150    /// Capacity threshold for L1 admission (bytes).
151    max_block_bytes: usize,
152    /// Live statistics.
153    stats: CacheStats,
154}
155
156impl<S: BlockStore> CachedBlockStore<S> {
157    /// Construct a new `CachedBlockStore` with explicit configuration.
158    pub fn new(inner: S, config: CacheConfig) -> Self {
159        Self {
160            inner,
161            cache: Mutex::new(LruCache::new(config.l1_capacity)),
162            max_block_bytes: config.max_block_bytes,
163            stats: CacheStats::default(),
164        }
165    }
166
167    /// Construct a new `CachedBlockStore` using [`CacheConfig::default`].
168    pub fn with_default_config(inner: S) -> Self {
169        Self::new(inner, CacheConfig::default())
170    }
171
172    /// Return a point-in-time snapshot of cache statistics.
173    pub fn stats(&self) -> CacheStatsSnapshot {
174        self.stats.snapshot()
175    }
176
177    /// Current number of entries in L1.
178    pub fn cache_size(&self) -> usize {
179        self.cache.lock().len()
180    }
181
182    /// Remove a single CID from L1 (does **not** affect L2).
183    pub fn invalidate(&self, cid: &Cid) {
184        self.cache.lock().pop(&cid.to_string());
185    }
186
187    /// Evict all entries from L1 (does **not** affect L2).
188    pub fn clear_cache(&self) {
189        self.cache.lock().clear();
190    }
191
192    /// Borrow the underlying backing store.
193    pub fn inner(&self) -> &S {
194        &self.inner
195    }
196
197    // ------------------------------------------------------------------
198    // Internal helpers
199    // ------------------------------------------------------------------
200
201    /// Insert `data` into L1 for `cid`, tracking evictions.
202    fn l1_insert(&self, cid: &Cid, data: bytes::Bytes) {
203        let key = cid.to_string();
204        let mut guard = self.cache.lock();
205        // `push` returns the evicted entry (if any).
206        if guard.push(key, data).is_some() {
207            self.stats.evictions.fetch_add(1, Ordering::Relaxed);
208        }
209    }
210
211    /// Look up `cid` in L1 without counting a hit/miss — used internally.
212    fn l1_peek(&self, cid: &Cid) -> Option<bytes::Bytes> {
213        self.cache.lock().get(&cid.to_string()).cloned()
214    }
215}
216
217// ---------------------------------------------------------------------------
218// BlockStore impl
219// ---------------------------------------------------------------------------
220
221#[async_trait]
222impl<S: BlockStore> BlockStore for CachedBlockStore<S> {
223    /// Write to L2 first, then promote to L1 if the block is small enough.
224    async fn put(&self, block: &Block) -> Result<()> {
225        self.stats.puts.fetch_add(1, Ordering::Relaxed);
226        self.inner.put(block).await?;
227        if block.data().len() <= self.max_block_bytes {
228            self.l1_insert(block.cid(), block.data().clone());
229        }
230        Ok(())
231    }
232
233    /// Check L1 first; on miss fetch from L2 and populate L1.
234    async fn get(&self, cid: &Cid) -> Result<Option<Block>> {
235        // L1 hit
236        if let Some(data) = self.l1_peek(cid) {
237            self.stats.hits.fetch_add(1, Ordering::Relaxed);
238            return Ok(Some(Block::from_parts(*cid, data)));
239        }
240
241        // L1 miss — consult L2
242        self.stats.misses.fetch_add(1, Ordering::Relaxed);
243        match self.inner.get(cid).await? {
244            Some(block) => {
245                // Populate L1 if the block fits
246                if block.data().len() <= self.max_block_bytes {
247                    self.l1_insert(cid, block.data().clone());
248                }
249                Ok(Some(block))
250            }
251            None => Ok(None),
252        }
253    }
254
255    /// `has()` checks L1 (cheap) then L2.
256    async fn has(&self, cid: &Cid) -> Result<bool> {
257        if self.cache.lock().contains(&cid.to_string()) {
258            return Ok(true);
259        }
260        self.inner.has(cid).await
261    }
262
263    /// Remove from L1 and L2.
264    async fn delete(&self, cid: &Cid) -> Result<()> {
265        self.cache.lock().pop(&cid.to_string());
266        self.inner.delete(cid).await
267    }
268
269    fn list_cids(&self) -> Result<Vec<Cid>> {
270        self.inner.list_cids()
271    }
272
273    fn len(&self) -> usize {
274        self.inner.len()
275    }
276
277    fn is_empty(&self) -> bool {
278        self.inner.is_empty()
279    }
280
281    async fn flush(&self) -> Result<()> {
282        self.inner.flush().await
283    }
284
285    async fn close(&self) -> Result<()> {
286        self.clear_cache();
287        self.inner.close().await
288    }
289
290    // ------------------------------------------------------------------
291    // Optimised batch operations — minimise lock acquisitions
292    // ------------------------------------------------------------------
293
294    async fn get_many(&self, cids: &[Cid]) -> Result<Vec<Option<Block>>> {
295        let mut results: Vec<Option<Block>> = Vec::with_capacity(cids.len());
296        let mut miss_cids: Vec<Cid> = Vec::new();
297        let mut miss_indices: Vec<usize> = Vec::new();
298
299        // Single L1 lock acquisition for all lookups.
300        {
301            let mut guard = self.cache.lock();
302            for (i, cid) in cids.iter().enumerate() {
303                if let Some(data) = guard.get(&cid.to_string()).cloned() {
304                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
305                    results.push(Some(Block::from_parts(*cid, data)));
306                } else {
307                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
308                    results.push(None);
309                    miss_cids.push(*cid);
310                    miss_indices.push(i);
311                }
312            }
313        }
314
315        if !miss_cids.is_empty() {
316            let fetched = self.inner.get_many(&miss_cids).await?;
317            let mut guard = self.cache.lock();
318            for (idx, block_opt) in miss_indices.iter().zip(fetched.iter()) {
319                if let Some(block) = block_opt {
320                    if block.data().len() <= self.max_block_bytes
321                        && guard
322                            .push(block.cid().to_string(), block.data().clone())
323                            .is_some()
324                    {
325                        self.stats.evictions.fetch_add(1, Ordering::Relaxed);
326                    }
327                    results[*idx] = Some(block.clone());
328                }
329            }
330        }
331
332        Ok(results)
333    }
334
335    async fn put_many(&self, blocks: &[Block]) -> Result<()> {
336        self.stats
337            .puts
338            .fetch_add(blocks.len() as u64, Ordering::Relaxed);
339
340        // Write to L2 first.
341        self.inner.put_many(blocks).await?;
342
343        // Populate L1 in a single lock acquisition.
344        let mut guard = self.cache.lock();
345        for block in blocks {
346            if block.data().len() <= self.max_block_bytes
347                && guard
348                    .push(block.cid().to_string(), block.data().clone())
349                    .is_some()
350            {
351                self.stats.evictions.fetch_add(1, Ordering::Relaxed);
352            }
353        }
354
355        Ok(())
356    }
357
358    async fn has_many(&self, cids: &[Cid]) -> Result<Vec<bool>> {
359        let mut results: Vec<bool> = Vec::with_capacity(cids.len());
360        let mut miss_cids: Vec<Cid> = Vec::new();
361        let mut miss_indices: Vec<usize> = Vec::new();
362
363        {
364            let guard = self.cache.lock();
365            for (i, cid) in cids.iter().enumerate() {
366                if guard.contains(&cid.to_string()) {
367                    results.push(true);
368                } else {
369                    results.push(false);
370                    miss_cids.push(*cid);
371                    miss_indices.push(i);
372                }
373            }
374        }
375
376        if !miss_cids.is_empty() {
377            let store_results = self.inner.has_many(&miss_cids).await?;
378            for (idx, &exists) in miss_indices.iter().zip(store_results.iter()) {
379                results[*idx] = exists;
380            }
381        }
382
383        Ok(results)
384    }
385
386    async fn delete_many(&self, cids: &[Cid]) -> Result<()> {
387        {
388            let mut guard = self.cache.lock();
389            for cid in cids {
390                guard.pop(&cid.to_string());
391            }
392        }
393        self.inner.delete_many(cids).await
394    }
395}
396
397// ---------------------------------------------------------------------------
398// BlockCache — standalone in-memory LRU (no backing store)
399// ---------------------------------------------------------------------------
400
401/// Standalone in-memory LRU cache for blocks.
402///
403/// This is a building block; most callers should prefer [`CachedBlockStore`].
404pub struct BlockCache {
405    cache: Arc<Mutex<LruCache<Cid, Block>>>,
406    capacity: usize,
407    hits: Arc<AtomicU64>,
408    misses: Arc<AtomicU64>,
409}
410
411impl BlockCache {
412    /// Create a new LRU cache with the given capacity (number of blocks).
413    pub fn new(capacity: usize) -> Self {
414        let cap =
415            NonZeroUsize::new(capacity).unwrap_or_else(|| NonZeroUsize::new(1000).expect("1000>0"));
416        Self {
417            cache: Arc::new(Mutex::new(LruCache::new(cap))),
418            capacity,
419            hits: Arc::new(AtomicU64::new(0)),
420            misses: Arc::new(AtomicU64::new(0)),
421        }
422    }
423
424    /// Get a block from cache.
425    #[inline]
426    pub fn get(&self, cid: &Cid) -> Option<Block> {
427        let result = self.cache.lock().get(cid).cloned();
428        if result.is_some() {
429            self.hits.fetch_add(1, Ordering::Relaxed);
430        } else {
431            self.misses.fetch_add(1, Ordering::Relaxed);
432        }
433        result
434    }
435
436    /// Put a block into cache.
437    #[inline]
438    pub fn put(&self, block: Block) {
439        self.cache.lock().put(*block.cid(), block);
440    }
441
442    /// Remove a block from cache.
443    pub fn remove(&self, cid: &Cid) {
444        self.cache.lock().pop(cid);
445    }
446
447    /// Clear the cache and reset counters.
448    pub fn clear(&self) {
449        self.cache.lock().clear();
450        self.hits.store(0, Ordering::Relaxed);
451        self.misses.store(0, Ordering::Relaxed);
452    }
453
454    /// Return a legacy-style statistics snapshot.
455    pub fn stats(&self) -> LegacyCacheStats {
456        LegacyCacheStats {
457            hits: self.hits.load(Ordering::Relaxed),
458            misses: self.misses.load(Ordering::Relaxed),
459            size: self.cache.lock().len(),
460            capacity: self.capacity,
461        }
462    }
463
464    /// Current number of cached entries.
465    pub fn len(&self) -> usize {
466        self.cache.lock().len()
467    }
468
469    /// `true` if the cache is empty.
470    pub fn is_empty(&self) -> bool {
471        self.cache.lock().is_empty()
472    }
473}
474
475// ---------------------------------------------------------------------------
476// LegacyCacheStats — simple snapshot used by BlockCache / helpers
477// ---------------------------------------------------------------------------
478
479/// Plain-data statistics snapshot returned by [`BlockCache::stats`].
480#[derive(Debug, Clone, Default)]
481pub struct LegacyCacheStats {
482    /// Cache hits since creation (or last clear).
483    pub hits: u64,
484    /// Cache misses since creation (or last clear).
485    pub misses: u64,
486    /// Current cache size (number of entries).
487    pub size: usize,
488    /// Cache capacity (maximum entries).
489    pub capacity: usize,
490}
491
492impl LegacyCacheStats {
493    /// Hit-rate in [0.0, 1.0].
494    pub fn hit_rate(&self) -> f64 {
495        let total = self.hits + self.misses;
496        if total == 0 {
497            0.0
498        } else {
499            self.hits as f64 / total as f64
500        }
501    }
502
503    /// Miss-rate in [0.0, 1.0].
504    pub fn miss_rate(&self) -> f64 {
505        1.0 - self.hit_rate()
506    }
507}
508
509// ---------------------------------------------------------------------------
510// TieredBlockCache — hot (L1) + warm (L2) in-process tiers
511// ---------------------------------------------------------------------------
512
513/// Multi-level in-process cache with hot (L1) and warm (L2) tiers.
514///
515/// L1 is smaller and faster.  L2 catches blocks evicted from L1.
516pub struct TieredBlockCache {
517    l1_cache: Arc<Mutex<LruCache<Cid, Block>>>,
518    l2_cache: Arc<Mutex<LruCache<Cid, Block>>>,
519    l1_capacity: usize,
520    l2_capacity: usize,
521    l1_hits: Arc<AtomicU64>,
522    l2_hits: Arc<AtomicU64>,
523    misses: Arc<AtomicU64>,
524}
525
526impl TieredBlockCache {
527    /// Create a new tiered cache.
528    ///
529    /// * `l1_capacity` — capacity of the hot tier (number of blocks).
530    /// * `l2_capacity` — capacity of the warm tier (number of blocks).
531    pub fn new(l1_capacity: usize, l2_capacity: usize) -> Self {
532        let l1_cap = NonZeroUsize::new(l1_capacity)
533            .unwrap_or_else(|| NonZeroUsize::new(100).expect("100>0"));
534        let l2_cap = NonZeroUsize::new(l2_capacity)
535            .unwrap_or_else(|| NonZeroUsize::new(1000).expect("1000>0"));
536
537        Self {
538            l1_cache: Arc::new(Mutex::new(LruCache::new(l1_cap))),
539            l2_cache: Arc::new(Mutex::new(LruCache::new(l2_cap))),
540            l1_capacity,
541            l2_capacity,
542            l1_hits: Arc::new(AtomicU64::new(0)),
543            l2_hits: Arc::new(AtomicU64::new(0)),
544            misses: Arc::new(AtomicU64::new(0)),
545        }
546    }
547
548    /// Get a block — checks L1 then L2; promotes L2 hits to L1.
549    #[inline]
550    pub fn get(&self, cid: &Cid) -> Option<Block> {
551        if let Some(block) = self.l1_cache.lock().get(cid) {
552            self.l1_hits.fetch_add(1, Ordering::Relaxed);
553            return Some(block.clone());
554        }
555
556        if let Some(block) = self.l2_cache.lock().get(cid) {
557            self.l2_hits.fetch_add(1, Ordering::Relaxed);
558            let block_clone = block.clone();
559            self.l1_cache.lock().put(*cid, block_clone.clone());
560            return Some(block_clone);
561        }
562
563        self.misses.fetch_add(1, Ordering::Relaxed);
564        None
565    }
566
567    /// Put a block into L1; blocks evicted from L1 cascade into L2.
568    #[inline]
569    pub fn put(&self, block: Block) {
570        let cid = *block.cid();
571        if let Some(evicted) = self.l1_cache.lock().push(cid, block) {
572            self.l2_cache.lock().put(evicted.0, evicted.1);
573        }
574    }
575
576    /// Remove a block from both tiers.
577    pub fn remove(&self, cid: &Cid) {
578        self.l1_cache.lock().pop(cid);
579        self.l2_cache.lock().pop(cid);
580    }
581
582    /// Clear both tiers and reset counters.
583    pub fn clear(&self) {
584        self.l1_cache.lock().clear();
585        self.l2_cache.lock().clear();
586        self.l1_hits.store(0, Ordering::Relaxed);
587        self.l2_hits.store(0, Ordering::Relaxed);
588        self.misses.store(0, Ordering::Relaxed);
589    }
590
591    /// Return a statistics snapshot.
592    pub fn stats(&self) -> TieredCacheStats {
593        TieredCacheStats {
594            l1_size: self.l1_cache.lock().len(),
595            l1_capacity: self.l1_capacity,
596            l2_size: self.l2_cache.lock().len(),
597            l2_capacity: self.l2_capacity,
598            l1_hits: self.l1_hits.load(Ordering::Relaxed),
599            l2_hits: self.l2_hits.load(Ordering::Relaxed),
600            misses: self.misses.load(Ordering::Relaxed),
601        }
602    }
603}
604
605/// Statistics snapshot for [`TieredBlockCache`].
606#[derive(Debug, Clone)]
607pub struct TieredCacheStats {
608    /// Current L1 size.
609    pub l1_size: usize,
610    /// L1 capacity.
611    pub l1_capacity: usize,
612    /// Current L2 size.
613    pub l2_size: usize,
614    /// L2 capacity.
615    pub l2_capacity: usize,
616    /// L1 hits.
617    pub l1_hits: u64,
618    /// L2 hits.
619    pub l2_hits: u64,
620    /// Total misses (missed both tiers).
621    pub misses: u64,
622}
623
624impl TieredCacheStats {
625    /// Overall hit-rate (L1 + L2 hits / total accesses).
626    pub fn hit_rate(&self) -> f64 {
627        let total_hits = self.l1_hits + self.l2_hits;
628        let total = total_hits + self.misses;
629        if total == 0 {
630            0.0
631        } else {
632            total_hits as f64 / total as f64
633        }
634    }
635
636    /// L1 hit-rate.
637    pub fn l1_hit_rate(&self) -> f64 {
638        let total = self.l1_hits + self.l2_hits + self.misses;
639        if total == 0 {
640            0.0
641        } else {
642            self.l1_hits as f64 / total as f64
643        }
644    }
645
646    /// L2 hit-rate.
647    pub fn l2_hit_rate(&self) -> f64 {
648        let total = self.l1_hits + self.l2_hits + self.misses;
649        if total == 0 {
650            0.0
651        } else {
652            self.l2_hits as f64 / total as f64
653        }
654    }
655
656    /// Miss-rate.
657    pub fn miss_rate(&self) -> f64 {
658        1.0 - self.hit_rate()
659    }
660}
661
662// ---------------------------------------------------------------------------
663// TieredCachedBlockStore — tiered-cache wrapper around a BlockStore
664// ---------------------------------------------------------------------------
665
666/// A [`BlockStore`] wrapper that uses a [`TieredBlockCache`] (hot L1 + warm L2)
667/// in front of any backing store.
668pub struct TieredCachedBlockStore<S: BlockStore> {
669    store: S,
670    cache: TieredBlockCache,
671}
672
673impl<S: BlockStore> TieredCachedBlockStore<S> {
674    /// Create a new tiered caching block store.
675    pub fn new(store: S, l1_capacity: usize, l2_capacity: usize) -> Self {
676        Self {
677            store,
678            cache: TieredBlockCache::new(l1_capacity, l2_capacity),
679        }
680    }
681
682    /// Borrow the underlying store.
683    pub fn store(&self) -> &S {
684        &self.store
685    }
686
687    /// Return cache statistics.
688    pub fn cache_stats(&self) -> TieredCacheStats {
689        self.cache.stats()
690    }
691}
692
693#[async_trait]
694impl<S: BlockStore> BlockStore for TieredCachedBlockStore<S> {
695    async fn put(&self, block: &Block) -> Result<()> {
696        self.cache.put(block.clone());
697        self.store.put(block).await
698    }
699
700    async fn get(&self, cid: &Cid) -> Result<Option<Block>> {
701        if let Some(block) = self.cache.get(cid) {
702            return Ok(Some(block));
703        }
704        if let Some(block) = self.store.get(cid).await? {
705            self.cache.put(block.clone());
706            Ok(Some(block))
707        } else {
708            Ok(None)
709        }
710    }
711
712    async fn has(&self, cid: &Cid) -> Result<bool> {
713        if self.cache.get(cid).is_some() {
714            return Ok(true);
715        }
716        self.store.has(cid).await
717    }
718
719    async fn delete(&self, cid: &Cid) -> Result<()> {
720        self.cache.remove(cid);
721        self.store.delete(cid).await
722    }
723
724    fn list_cids(&self) -> Result<Vec<Cid>> {
725        self.store.list_cids()
726    }
727
728    fn len(&self) -> usize {
729        self.store.len()
730    }
731
732    fn is_empty(&self) -> bool {
733        self.store.is_empty()
734    }
735
736    async fn flush(&self) -> Result<()> {
737        self.store.flush().await
738    }
739
740    async fn close(&self) -> Result<()> {
741        self.cache.clear();
742        self.store.close().await
743    }
744}
745
746// ---------------------------------------------------------------------------
747// Unit tests
748// ---------------------------------------------------------------------------
749
750#[cfg(all(test, feature = "sled-backend"))]
751mod tests {
752    use super::*;
753    use crate::blockstore::BlockStoreConfig;
754    use crate::memory::MemoryBlockStore;
755    use bytes::Bytes;
756    use ipfrs_core::Block;
757
758    // -----------------------------------------------------------------------
759    // Helpers
760    // -----------------------------------------------------------------------
761
762    fn unique_path(tag: &str) -> std::path::PathBuf {
763        std::env::temp_dir().join(format!(
764            "ipfrs-cache-test-{}-{}-{}",
765            tag,
766            std::process::id(),
767            fastrand::u64(..)
768        ))
769    }
770
771    fn make_block(content: &[u8]) -> Block {
772        Block::new(Bytes::copy_from_slice(content)).expect("block creation")
773    }
774
775    fn make_store_with_config(config: CacheConfig) -> CachedBlockStore<MemoryBlockStore> {
776        CachedBlockStore::new(MemoryBlockStore::new(), config)
777    }
778
779    fn make_store() -> CachedBlockStore<MemoryBlockStore> {
780        CachedBlockStore::with_default_config(MemoryBlockStore::new())
781    }
782
783    // -----------------------------------------------------------------------
784    // test_cache_hit
785    // -----------------------------------------------------------------------
786
787    /// Put a block then get it — must be a L1 hit (hit=1, miss=0).
788    #[tokio::test]
789    async fn test_cache_hit() {
790        let store = make_store();
791        let block = make_block(b"hello world");
792
793        store.put(&block).await.expect("put");
794        let result = store.get(block.cid()).await.expect("get");
795        assert!(result.is_some());
796
797        let snap = store.stats();
798        assert_eq!(snap.hits, 1, "expected 1 L1 hit");
799        assert_eq!(snap.misses, 0, "expected 0 misses");
800    }
801
802    // -----------------------------------------------------------------------
803    // test_cache_miss_then_populate
804    // -----------------------------------------------------------------------
805
806    /// Get from an empty cache (miss), but backed by an L2 that has the block.
807    /// After the miss L1 should be populated so a second get is a hit.
808    #[tokio::test]
809    async fn test_cache_miss_then_populate() {
810        // Pre-populate L2 directly.
811        let mem = MemoryBlockStore::new();
812        let block = make_block(b"L2 resident block");
813        mem.put(&block).await.expect("l2 put");
814
815        // Wrap with a fresh L1 — L1 is empty.
816        let store = CachedBlockStore::with_default_config(mem);
817
818        // First get: L1 miss → L2 hit → L1 populated.
819        let r1 = store.get(block.cid()).await.expect("get 1");
820        assert!(r1.is_some());
821
822        let snap1 = store.stats();
823        assert_eq!(snap1.misses, 1);
824        assert_eq!(snap1.hits, 0);
825
826        // Second get: L1 hit.
827        let r2 = store.get(block.cid()).await.expect("get 2");
828        assert!(r2.is_some());
829
830        let snap2 = store.stats();
831        assert_eq!(snap2.hits, 1);
832        assert_eq!(snap2.misses, 1);
833
834        // L1 must now contain the block.
835        assert_eq!(store.cache_size(), 1);
836    }
837
838    // -----------------------------------------------------------------------
839    // test_cache_eviction
840    // -----------------------------------------------------------------------
841
842    /// Fill L1 to capacity then add one more block — the oldest entry must be
843    /// evicted and the eviction counter must equal 1.
844    #[tokio::test]
845    async fn test_cache_eviction() {
846        let cap = NonZeroUsize::new(3).expect("3>0");
847        let config = CacheConfig {
848            l1_capacity: cap,
849            max_block_bytes: 64 * 1024,
850        };
851        let store = make_store_with_config(config);
852
853        // Fill to capacity.
854        for i in 0_u8..3 {
855            let block = make_block(&[i; 8]);
856            store.put(&block).await.expect("put");
857        }
858        assert_eq!(store.cache_size(), 3);
859        assert_eq!(store.stats().evictions, 0);
860
861        // One more — triggers eviction.
862        let extra = make_block(b"extra block!!!");
863        store.put(&extra).await.expect("put extra");
864
865        assert_eq!(store.cache_size(), 3);
866        assert_eq!(store.stats().evictions, 1, "expected exactly 1 eviction");
867    }
868
869    // -----------------------------------------------------------------------
870    // test_cache_delete_removes_from_both
871    // -----------------------------------------------------------------------
872
873    /// Put a block, delete it, then get it — must return None from both L1 and L2.
874    #[tokio::test]
875    async fn test_cache_delete_removes_from_both() {
876        let store = make_store();
877        let block = make_block(b"to be deleted");
878
879        store.put(&block).await.expect("put");
880        store.delete(block.cid()).await.expect("delete");
881
882        // L1 must be empty.
883        assert_eq!(store.cache_size(), 0);
884
885        // L2 must also be empty.
886        let result = store.get(block.cid()).await.expect("get after delete");
887        assert!(result.is_none());
888    }
889
890    // -----------------------------------------------------------------------
891    // test_cache_large_block_skipped
892    // -----------------------------------------------------------------------
893
894    /// A block larger than `max_block_bytes` must not be promoted to L1 but
895    /// must still be retrievable from L2.
896    #[tokio::test]
897    async fn test_cache_large_block_skipped() {
898        let config = CacheConfig {
899            l1_capacity: NonZeroUsize::new(1024).expect("1024>0"),
900            max_block_bytes: 4, // deliberately tiny threshold
901        };
902        let store = make_store_with_config(config);
903
904        let large_block = make_block(b"larger than threshold");
905        store.put(&large_block).await.expect("put large");
906
907        // L1 must be empty because block exceeds threshold.
908        assert_eq!(store.cache_size(), 0, "large block must not enter L1");
909
910        // L2 must still have the block.
911        let result = store.get(large_block.cid()).await.expect("get large");
912        assert!(result.is_some(), "large block must be in L2");
913    }
914
915    // -----------------------------------------------------------------------
916    // test_hit_rate_calculation
917    // -----------------------------------------------------------------------
918
919    /// 3 hits + 1 miss → hit_rate = 0.75.
920    #[tokio::test]
921    async fn test_hit_rate_calculation() {
922        let store = make_store();
923        let block = make_block(b"hit rate test");
924
925        store.put(&block).await.expect("put");
926
927        // 3 hits.
928        for _ in 0_u8..3 {
929            store.get(block.cid()).await.expect("hit");
930        }
931
932        // 1 miss (non-existent CID).
933        let absent = make_block(b"absent block");
934        store.get(absent.cid()).await.expect("miss");
935
936        let snap = store.stats();
937        assert_eq!(snap.hits, 3);
938        assert_eq!(snap.misses, 1);
939
940        let expected = 3.0_f64 / 4.0_f64;
941        let diff = (snap.hit_rate - expected).abs();
942        assert!(
943            diff < 1e-10,
944            "hit_rate={} expected={}",
945            snap.hit_rate,
946            expected
947        );
948    }
949
950    // -----------------------------------------------------------------------
951    // test_cache_stats_snapshot
952    // -----------------------------------------------------------------------
953
954    /// Verify that `CacheStatsSnapshot` fields are accurate after a sequence
955    /// of operations.
956    #[tokio::test]
957    async fn test_cache_stats_snapshot() {
958        let config = CacheConfig {
959            l1_capacity: NonZeroUsize::new(2).expect("2>0"),
960            max_block_bytes: 64 * 1024,
961        };
962        let store = make_store_with_config(config);
963
964        let b1 = make_block(b"block-one");
965        let b2 = make_block(b"block-two");
966        let b3 = make_block(b"block-three");
967
968        // 3 puts.
969        store.put(&b1).await.expect("put b1");
970        store.put(&b2).await.expect("put b2");
971        store.put(&b3).await.expect("put b3"); // evicts b1
972
973        // 1 hit (b2 still in L1), 1 miss (b4 absent).
974        store.get(b2.cid()).await.expect("get b2");
975        let absent = make_block(b"does-not-exist");
976        store.get(absent.cid()).await.expect("absent get");
977
978        let snap = store.stats();
979        assert_eq!(snap.puts, 3, "puts");
980        assert_eq!(snap.hits, 1, "hits");
981        assert_eq!(snap.misses, 1, "misses");
982        assert!(snap.evictions >= 1, "at least one eviction expected");
983        let expected_rate = 0.5_f64;
984        let diff = (snap.hit_rate - expected_rate).abs();
985        assert!(diff < 1e-10, "hit_rate mismatch: {}", snap.hit_rate);
986    }
987
988    // -----------------------------------------------------------------------
989    // Sled-backed integration test (uses temp_dir)
990    // -----------------------------------------------------------------------
991
992    #[tokio::test]
993    async fn test_cached_sled_put_get() {
994        use crate::blockstore::SledBlockStore;
995
996        let path = unique_path("sled");
997        let _ = std::fs::remove_dir_all(&path);
998
999        let config_sled = BlockStoreConfig {
1000            path: path.clone(),
1001            cache_size: 4 * 1024 * 1024,
1002        };
1003        let sled = SledBlockStore::new(config_sled).expect("sled open");
1004        let store = CachedBlockStore::with_default_config(sled);
1005
1006        let block = make_block(b"sled cache integration");
1007        store.put(&block).await.expect("put");
1008
1009        // First get: L1 hit (block inserted during put).
1010        let r = store.get(block.cid()).await.expect("get");
1011        assert!(r.is_some());
1012        assert_eq!(store.stats().hits, 1);
1013
1014        let _ = std::fs::remove_dir_all(&path);
1015    }
1016}