Skip to main content

oximedia_cache/
write_behind_cache.rs

1//! Write-behind (write-back) cache with dirty tracking and flush.
2//!
3//! This module provides a cache that batches writes and lazily flushes dirty
4//! entries to a backing store.  Entries are marked *dirty* on insert/update;
5//! the caller periodically calls [`WriteBehindCache::flush`] (or
6//! `flush_if_needed`) to persist dirty entries.
7//!
8//! The backing store is abstracted via the [`BackingStore`] trait so the same
9//! cache can sit in front of an in-memory map, a file, or a network service.
10
11use std::collections::HashMap;
12use std::time::Instant;
13
14// ── BackingStore trait ──────────────────────────────────────────────────────
15
16/// Abstraction over the origin data store that the cache sits in front of.
17///
18/// Implementations are expected to be synchronous.  For async I/O, the caller
19/// should provide a blocking adapter.
20pub trait BackingStore {
21    /// The key type.
22    type Key: Eq + std::hash::Hash + Clone;
23    /// The value type.
24    type Value: Clone;
25    /// Error type returned by store operations.
26    type Error: std::fmt::Debug;
27
28    /// Write `(key, value)` to the backing store.
29    fn write(&mut self, key: &Self::Key, value: &Self::Value) -> Result<(), Self::Error>;
30
31    /// Read the value for `key` from the backing store (cache miss path).
32    fn read(&self, key: &Self::Key) -> Result<Option<Self::Value>, Self::Error>;
33
34    /// Delete `key` from the backing store.
35    fn delete(&mut self, key: &Self::Key) -> Result<(), Self::Error>;
36}
37
38// ── Internal entry ──────────────────────────────────────────────────────────
39
40struct CacheEntry<V> {
41    value: V,
42    dirty: bool,
43    last_modified: Instant,
44}
45
46// ── WriteBehindCache ────────────────────────────────────────────────────────
47
48/// Write-behind cache that defers writes to a [`BackingStore`] until
49/// `flush` is called.
50///
51/// # Dirty tracking
52///
53/// Every `put` marks the entry as *dirty*.  `flush` iterates all dirty
54/// entries, writes them to the backing store, and clears the dirty flag.
55/// `flush_if_needed` only flushes when the dirty count exceeds a threshold.
56///
57/// # Eviction
58///
59/// Entries are evicted in insertion order (FIFO) when the cache exceeds
60/// `capacity`.  **Dirty entries are flushed before eviction** so data is
61/// never silently lost.
62pub struct WriteBehindCache<S: BackingStore> {
63    entries: HashMap<S::Key, CacheEntry<S::Value>>,
64    /// Insertion order for FIFO eviction.
65    order: Vec<S::Key>,
66    capacity: usize,
67    store: S,
68    /// Number of entries currently marked dirty.
69    dirty_count: usize,
70    /// Total number of successful flush operations.
71    total_flushes: u64,
72    /// Total number of entries written to the store across all flushes.
73    total_entries_flushed: u64,
74}
75
76/// Snapshot of write-behind cache statistics.
77#[derive(Debug, Clone)]
78pub struct WriteBehindStats {
79    /// Number of entries currently in the cache.
80    pub entry_count: usize,
81    /// Number of dirty (unflushed) entries.
82    pub dirty_count: usize,
83    /// Maximum capacity.
84    pub capacity: usize,
85    /// Total number of flush operations performed.
86    pub total_flushes: u64,
87    /// Total number of individual entries flushed to the store.
88    pub total_entries_flushed: u64,
89}
90
91/// Errors that can occur during write-behind cache operations.
92#[derive(Debug)]
93pub enum WriteBehindError<E: std::fmt::Debug> {
94    /// The backing store returned an error.
95    StoreError(E),
96}
97
98impl<E: std::fmt::Debug> std::fmt::Display for WriteBehindError<E> {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::StoreError(e) => write!(f, "backing store error: {e:?}"),
102        }
103    }
104}
105
106impl<S: BackingStore> WriteBehindCache<S> {
107    /// Create a new write-behind cache with the given capacity and backing
108    /// store.
109    pub fn new(capacity: usize, store: S) -> Self {
110        Self {
111            entries: HashMap::new(),
112            order: Vec::new(),
113            capacity: capacity.max(1),
114            store,
115            dirty_count: 0,
116            total_flushes: 0,
117            total_entries_flushed: 0,
118        }
119    }
120
121    /// Insert or update `(key, value)`.  The entry is marked dirty.
122    ///
123    /// If the cache is at capacity, the oldest entry is evicted (and flushed
124    /// if dirty).
125    pub fn put(&mut self, key: S::Key, value: S::Value) -> Result<(), WriteBehindError<S::Error>> {
126        if self.entries.contains_key(&key) {
127            // Update in place.
128            if let Some(entry) = self.entries.get_mut(&key) {
129                if !entry.dirty {
130                    self.dirty_count += 1;
131                }
132                entry.value = value;
133                entry.dirty = true;
134                entry.last_modified = Instant::now();
135            }
136            return Ok(());
137        }
138
139        // Evict if at capacity.
140        while self.entries.len() >= self.capacity {
141            self.evict_oldest()?;
142        }
143
144        self.order.push(key.clone());
145        self.entries.insert(
146            key,
147            CacheEntry {
148                value,
149                dirty: true,
150                last_modified: Instant::now(),
151            },
152        );
153        self.dirty_count += 1;
154        Ok(())
155    }
156
157    /// Look up `key`.  On a cache miss, attempts to load from the backing
158    /// store (read-through).
159    pub fn get(&mut self, key: &S::Key) -> Result<Option<&S::Value>, WriteBehindError<S::Error>> {
160        if self.entries.contains_key(key) {
161            return Ok(self.entries.get(key).map(|e| &e.value));
162        }
163        // Read-through from backing store.
164        let value = self.store.read(key).map_err(WriteBehindError::StoreError)?;
165        if let Some(v) = value {
166            // Cache the value (clean, not dirty).
167            while self.entries.len() >= self.capacity {
168                self.evict_oldest().map_err(|e| match e {
169                    WriteBehindError::StoreError(se) => WriteBehindError::StoreError(se),
170                })?;
171            }
172            self.order.push(key.clone());
173            self.entries.insert(
174                key.clone(),
175                CacheEntry {
176                    value: v,
177                    dirty: false,
178                    last_modified: Instant::now(),
179                },
180            );
181            return Ok(self.entries.get(key).map(|e| &e.value));
182        }
183        Ok(None)
184    }
185
186    /// Remove `key` from the cache and the backing store.
187    pub fn delete(&mut self, key: &S::Key) -> Result<bool, WriteBehindError<S::Error>> {
188        if let Some(entry) = self.entries.remove(key) {
189            self.order.retain(|k| k != key);
190            if entry.dirty {
191                self.dirty_count = self.dirty_count.saturating_sub(1);
192            }
193            self.store
194                .delete(key)
195                .map_err(WriteBehindError::StoreError)?;
196            return Ok(true);
197        }
198        Ok(false)
199    }
200
201    /// Flush all dirty entries to the backing store.
202    ///
203    /// Returns the number of entries flushed.
204    pub fn flush(&mut self) -> Result<usize, WriteBehindError<S::Error>> {
205        let dirty_keys: Vec<S::Key> = self
206            .entries
207            .iter()
208            .filter(|(_, e)| e.dirty)
209            .map(|(k, _)| k.clone())
210            .collect();
211        let count = dirty_keys.len();
212        for key in &dirty_keys {
213            if let Some(entry) = self.entries.get(key) {
214                self.store
215                    .write(key, &entry.value)
216                    .map_err(WriteBehindError::StoreError)?;
217            }
218            if let Some(entry) = self.entries.get_mut(key) {
219                entry.dirty = false;
220            }
221        }
222        self.dirty_count = 0;
223        self.total_flushes += 1;
224        self.total_entries_flushed += count as u64;
225        Ok(count)
226    }
227
228    /// Flush only if the number of dirty entries exceeds `threshold`.
229    pub fn flush_if_needed(
230        &mut self,
231        threshold: usize,
232    ) -> Result<usize, WriteBehindError<S::Error>> {
233        if self.dirty_count >= threshold {
234            self.flush()
235        } else {
236            Ok(0)
237        }
238    }
239
240    /// Return the number of dirty entries.
241    pub fn dirty_count(&self) -> usize {
242        self.dirty_count
243    }
244
245    /// Return a statistics snapshot.
246    pub fn stats(&self) -> WriteBehindStats {
247        WriteBehindStats {
248            entry_count: self.entries.len(),
249            dirty_count: self.dirty_count,
250            capacity: self.capacity,
251            total_flushes: self.total_flushes,
252            total_entries_flushed: self.total_entries_flushed,
253        }
254    }
255
256    /// Return a shared reference to the backing store.
257    pub fn store(&self) -> &S {
258        &self.store
259    }
260
261    /// Return a mutable reference to the backing store.
262    pub fn store_mut(&mut self) -> &mut S {
263        &mut self.store
264    }
265
266    /// Returns `true` if the entry for `key` is dirty.
267    pub fn is_dirty(&self, key: &S::Key) -> bool {
268        self.entries.get(key).map(|e| e.dirty).unwrap_or(false)
269    }
270
271    /// Returns `true` if the cache contains an entry for `key`.
272    pub fn contains(&self, key: &S::Key) -> bool {
273        self.entries.contains_key(key)
274    }
275
276    /// Return the number of entries in the cache.
277    pub fn len(&self) -> usize {
278        self.entries.len()
279    }
280
281    /// Return `true` when the cache is empty.
282    pub fn is_empty(&self) -> bool {
283        self.entries.is_empty()
284    }
285
286    /// Flush only entries whose dirty age exceeds `max_age`.
287    ///
288    /// "Dirty age" is the time since the entry was last modified.  This
289    /// allows the caller to implement time-based flush policies (e.g. flush
290    /// all entries older than 5 seconds).
291    ///
292    /// Returns the number of entries flushed.
293    pub fn flush_older_than(
294        &mut self,
295        max_age: std::time::Duration,
296    ) -> Result<usize, WriteBehindError<S::Error>> {
297        let now = Instant::now();
298        let old_dirty_keys: Vec<S::Key> = self
299            .entries
300            .iter()
301            .filter(|(_, e)| e.dirty && now.duration_since(e.last_modified) >= max_age)
302            .map(|(k, _)| k.clone())
303            .collect();
304        let count = old_dirty_keys.len();
305        for key in &old_dirty_keys {
306            if let Some(entry) = self.entries.get(key) {
307                self.store
308                    .write(key, &entry.value)
309                    .map_err(WriteBehindError::StoreError)?;
310            }
311            if let Some(entry) = self.entries.get_mut(key) {
312                entry.dirty = false;
313            }
314        }
315        self.dirty_count = self.dirty_count.saturating_sub(count);
316        if count > 0 {
317            self.total_flushes += 1;
318            self.total_entries_flushed += count as u64;
319        }
320        Ok(count)
321    }
322
323    /// Return a list of all dirty keys.
324    pub fn dirty_keys(&self) -> Vec<S::Key> {
325        self.entries
326            .iter()
327            .filter(|(_, e)| e.dirty)
328            .map(|(k, _)| k.clone())
329            .collect()
330    }
331
332    /// Mark an entry as clean without writing to the backing store.
333    ///
334    /// Useful when the caller knows the backing store is already up to date
335    /// (e.g. after an external write).  Returns `true` if the entry was
336    /// dirty and is now clean.
337    pub fn mark_clean(&mut self, key: &S::Key) -> bool {
338        if let Some(entry) = self.entries.get_mut(key) {
339            if entry.dirty {
340                entry.dirty = false;
341                self.dirty_count = self.dirty_count.saturating_sub(1);
342                return true;
343            }
344        }
345        false
346    }
347
348    /// Return the capacity of the cache.
349    pub fn capacity(&self) -> usize {
350        self.capacity
351    }
352
353    /// Evict the oldest entry (FIFO).  Flushes it if dirty.
354    fn evict_oldest(&mut self) -> Result<(), WriteBehindError<S::Error>> {
355        if self.order.is_empty() {
356            return Ok(());
357        }
358        let key = self.order.remove(0);
359        if let Some(entry) = self.entries.remove(&key) {
360            if entry.dirty {
361                self.store
362                    .write(&key, &entry.value)
363                    .map_err(WriteBehindError::StoreError)?;
364                self.dirty_count = self.dirty_count.saturating_sub(1);
365                self.total_entries_flushed += 1;
366            }
367        }
368        Ok(())
369    }
370}
371
372// ── Tests ─────────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::collections::HashMap;
378    use std::sync::{Arc, Mutex};
379
380    /// In-memory backing store for testing.
381    #[derive(Clone)]
382    struct MemStore {
383        data: Arc<Mutex<HashMap<String, String>>>,
384    }
385
386    impl MemStore {
387        fn new() -> Self {
388            Self {
389                data: Arc::new(Mutex::new(HashMap::new())),
390            }
391        }
392
393        fn snapshot(&self) -> HashMap<String, String> {
394            let guard = self.data.lock().unwrap_or_else(|p| p.into_inner());
395            guard.clone()
396        }
397    }
398
399    impl BackingStore for MemStore {
400        type Key = String;
401        type Value = String;
402        type Error = String;
403
404        fn write(&mut self, key: &String, value: &String) -> Result<(), String> {
405            let mut guard = self.data.lock().unwrap_or_else(|p| p.into_inner());
406            guard.insert(key.clone(), value.clone());
407            Ok(())
408        }
409
410        fn read(&self, key: &String) -> Result<Option<String>, String> {
411            let guard = self.data.lock().unwrap_or_else(|p| p.into_inner());
412            Ok(guard.get(key).cloned())
413        }
414
415        fn delete(&mut self, key: &String) -> Result<(), String> {
416            let mut guard = self.data.lock().unwrap_or_else(|p| p.into_inner());
417            guard.remove(key);
418            Ok(())
419        }
420    }
421
422    // 1. Basic put and get
423    #[test]
424    fn test_put_and_get() {
425        let store = MemStore::new();
426        let mut cache = WriteBehindCache::new(10, store);
427        cache.put("k1".to_string(), "v1".to_string()).ok();
428        let val = cache.get(&"k1".to_string()).ok().flatten();
429        assert_eq!(val, Some(&"v1".to_string()));
430    }
431
432    // 2. Dirty tracking
433    #[test]
434    fn test_dirty_tracking() {
435        let store = MemStore::new();
436        let mut cache = WriteBehindCache::new(10, store);
437        cache.put("a".to_string(), "1".to_string()).ok();
438        assert!(cache.is_dirty(&"a".to_string()));
439        assert_eq!(cache.dirty_count(), 1);
440    }
441
442    // 3. Flush writes to store
443    #[test]
444    fn test_flush_writes_to_store() {
445        let store = MemStore::new();
446        let mut cache = WriteBehindCache::new(10, store.clone());
447        cache.put("x".to_string(), "42".to_string()).ok();
448        let flushed = cache.flush().ok();
449        assert_eq!(flushed, Some(1));
450        assert!(!cache.is_dirty(&"x".to_string()));
451        let snap = store.snapshot();
452        assert_eq!(snap.get("x"), Some(&"42".to_string()));
453    }
454
455    // 4. Flush clears dirty count
456    #[test]
457    fn test_flush_clears_dirty() {
458        let store = MemStore::new();
459        let mut cache = WriteBehindCache::new(10, store);
460        cache.put("a".to_string(), "1".to_string()).ok();
461        cache.put("b".to_string(), "2".to_string()).ok();
462        cache.flush().ok();
463        assert_eq!(cache.dirty_count(), 0);
464    }
465
466    // 5. flush_if_needed respects threshold
467    #[test]
468    fn test_flush_if_needed() {
469        let store = MemStore::new();
470        let mut cache = WriteBehindCache::new(10, store);
471        cache.put("a".to_string(), "1".to_string()).ok();
472        let flushed = cache.flush_if_needed(5).ok();
473        assert_eq!(flushed, Some(0)); // threshold not met
474        cache.put("b".to_string(), "2".to_string()).ok();
475        cache.put("c".to_string(), "3".to_string()).ok();
476        let flushed = cache.flush_if_needed(2).ok();
477        assert_eq!(flushed, Some(3)); // now all 3 dirty entries flushed
478    }
479
480    // 6. Eviction flushes dirty entries
481    #[test]
482    fn test_eviction_flushes_dirty() {
483        let store = MemStore::new();
484        let mut cache = WriteBehindCache::new(2, store.clone());
485        cache.put("a".to_string(), "1".to_string()).ok();
486        cache.put("b".to_string(), "2".to_string()).ok();
487        // This should evict "a" and flush it.
488        cache.put("c".to_string(), "3".to_string()).ok();
489        let snap = store.snapshot();
490        assert_eq!(snap.get("a"), Some(&"1".to_string()));
491    }
492
493    // 7. Delete removes from cache and store
494    #[test]
495    fn test_delete() {
496        let store = MemStore::new();
497        let mut cache = WriteBehindCache::new(10, store.clone());
498        cache.put("k".to_string(), "v".to_string()).ok();
499        cache.flush().ok();
500        let deleted = cache.delete(&"k".to_string()).ok();
501        assert_eq!(deleted, Some(true));
502        let snap = store.snapshot();
503        assert!(!snap.contains_key("k"));
504    }
505
506    // 8. Read-through on miss
507    #[test]
508    fn test_read_through() {
509        let store = MemStore::new();
510        {
511            let mut guard = store.data.lock().unwrap_or_else(|p| p.into_inner());
512            guard.insert("pre".to_string(), "existing".to_string());
513        }
514        let mut cache = WriteBehindCache::new(10, store);
515        let val = cache.get(&"pre".to_string()).ok().flatten();
516        assert_eq!(val, Some(&"existing".to_string()));
517        // Now it should be cached (clean).
518        assert!(!cache.is_dirty(&"pre".to_string()));
519    }
520
521    // 9. Update marks entry dirty again after flush
522    #[test]
523    fn test_update_re_dirties() {
524        let store = MemStore::new();
525        let mut cache = WriteBehindCache::new(10, store);
526        cache.put("a".to_string(), "1".to_string()).ok();
527        cache.flush().ok();
528        assert!(!cache.is_dirty(&"a".to_string()));
529        cache.put("a".to_string(), "2".to_string()).ok();
530        assert!(cache.is_dirty(&"a".to_string()));
531    }
532
533    // 10. Stats
534    #[test]
535    fn test_stats() {
536        let store = MemStore::new();
537        let mut cache = WriteBehindCache::new(10, store);
538        cache.put("a".to_string(), "1".to_string()).ok();
539        cache.put("b".to_string(), "2".to_string()).ok();
540        cache.flush().ok();
541        let s = cache.stats();
542        assert_eq!(s.entry_count, 2);
543        assert_eq!(s.dirty_count, 0);
544        assert_eq!(s.total_flushes, 1);
545        assert_eq!(s.total_entries_flushed, 2);
546    }
547
548    // 11. Delete absent key
549    #[test]
550    fn test_delete_absent() {
551        let store = MemStore::new();
552        let mut cache = WriteBehindCache::new(10, store);
553        let deleted = cache.delete(&"ghost".to_string()).ok();
554        assert_eq!(deleted, Some(false));
555    }
556
557    // 12. Get absent key returns None
558    #[test]
559    fn test_get_absent() {
560        let store = MemStore::new();
561        let mut cache = WriteBehindCache::new(10, store);
562        let val = cache.get(&"nope".to_string()).ok().flatten();
563        assert!(val.is_none());
564    }
565
566    // ── Enhanced write-behind tests ─────────────────────────────────────────
567
568    // 13. contains
569    #[test]
570    fn test_contains() {
571        let store = MemStore::new();
572        let mut cache = WriteBehindCache::new(10, store);
573        cache.put("x".to_string(), "val".to_string()).ok();
574        assert!(cache.contains(&"x".to_string()));
575        assert!(!cache.contains(&"y".to_string()));
576    }
577
578    // 14. len and is_empty
579    #[test]
580    fn test_len_and_is_empty() {
581        let store = MemStore::new();
582        let mut cache = WriteBehindCache::new(10, store);
583        assert!(cache.is_empty());
584        assert_eq!(cache.len(), 0);
585        cache.put("a".to_string(), "1".to_string()).ok();
586        cache.put("b".to_string(), "2".to_string()).ok();
587        assert_eq!(cache.len(), 2);
588        assert!(!cache.is_empty());
589    }
590
591    // 15. flush_older_than only flushes old entries
592    #[test]
593    fn test_flush_older_than() {
594        let store = MemStore::new();
595        let mut cache = WriteBehindCache::new(10, store.clone());
596        cache.put("old".to_string(), "old_val".to_string()).ok();
597        // Sleep to age the entry
598        std::thread::sleep(std::time::Duration::from_millis(50));
599        cache.put("new".to_string(), "new_val".to_string()).ok();
600        // Flush entries older than 30ms
601        let flushed = cache
602            .flush_older_than(std::time::Duration::from_millis(30))
603            .ok();
604        assert_eq!(flushed, Some(1));
605        // "old" should be clean, "new" still dirty
606        assert!(!cache.is_dirty(&"old".to_string()));
607        assert!(cache.is_dirty(&"new".to_string()));
608        // Store should have "old"
609        let snap = store.snapshot();
610        assert!(snap.contains_key("old"));
611    }
612
613    // 16. flush_older_than with zero duration flushes all dirty
614    #[test]
615    fn test_flush_older_than_zero() {
616        let store = MemStore::new();
617        let mut cache = WriteBehindCache::new(10, store);
618        cache.put("a".to_string(), "1".to_string()).ok();
619        cache.put("b".to_string(), "2".to_string()).ok();
620        let flushed = cache
621            .flush_older_than(std::time::Duration::from_millis(0))
622            .ok();
623        assert_eq!(flushed, Some(2));
624        assert_eq!(cache.dirty_count(), 0);
625    }
626
627    // 17. dirty_keys returns correct set
628    #[test]
629    fn test_dirty_keys() {
630        let store = MemStore::new();
631        let mut cache = WriteBehindCache::new(10, store);
632        cache.put("a".to_string(), "1".to_string()).ok();
633        cache.put("b".to_string(), "2".to_string()).ok();
634        cache.put("c".to_string(), "3".to_string()).ok();
635        cache.flush().ok();
636        // Re-dirty one entry
637        cache.put("b".to_string(), "updated".to_string()).ok();
638        let dirty = cache.dirty_keys();
639        assert_eq!(dirty.len(), 1);
640        assert_eq!(dirty[0], "b");
641    }
642
643    // 18. mark_clean without store write
644    #[test]
645    fn test_mark_clean() {
646        let store = MemStore::new();
647        let mut cache = WriteBehindCache::new(10, store.clone());
648        cache.put("x".to_string(), "val".to_string()).ok();
649        assert!(cache.is_dirty(&"x".to_string()));
650        assert!(cache.mark_clean(&"x".to_string()));
651        assert!(!cache.is_dirty(&"x".to_string()));
652        assert_eq!(cache.dirty_count(), 0);
653        // Store should NOT have the entry (mark_clean doesn't write)
654        let snap = store.snapshot();
655        assert!(!snap.contains_key("x"));
656    }
657
658    // 19. mark_clean on clean entry returns false
659    #[test]
660    fn test_mark_clean_already_clean() {
661        let store = MemStore::new();
662        let mut cache = WriteBehindCache::new(10, store);
663        cache.put("a".to_string(), "1".to_string()).ok();
664        cache.flush().ok();
665        assert!(!cache.mark_clean(&"a".to_string()));
666    }
667
668    // 20. mark_clean on absent entry returns false
669    #[test]
670    fn test_mark_clean_absent() {
671        let store = MemStore::new();
672        let mut cache = WriteBehindCache::new(10, store);
673        assert!(!cache.mark_clean(&"ghost".to_string()));
674    }
675
676    // 21. capacity getter
677    #[test]
678    fn test_capacity() {
679        let store = MemStore::new();
680        let cache: WriteBehindCache<MemStore> = WriteBehindCache::new(42, store);
681        assert_eq!(cache.capacity(), 42);
682    }
683
684    // 22. Multiple flushes accumulate stats
685    #[test]
686    fn test_multiple_flushes_stats() {
687        let store = MemStore::new();
688        let mut cache = WriteBehindCache::new(10, store);
689        cache.put("a".to_string(), "1".to_string()).ok();
690        cache.flush().ok();
691        cache.put("b".to_string(), "2".to_string()).ok();
692        cache.flush().ok();
693        let s = cache.stats();
694        assert_eq!(s.total_flushes, 2);
695        assert_eq!(s.total_entries_flushed, 2);
696    }
697
698    // 23. Eviction cascade: filling beyond capacity flushes all dirty entries
699    #[test]
700    fn test_eviction_cascade() {
701        let store = MemStore::new();
702        let mut cache = WriteBehindCache::new(3, store.clone());
703        for i in 0..5 {
704            cache.put(format!("k{i}"), format!("v{i}")).ok();
705        }
706        // At least the first 2 entries should have been evicted and flushed
707        let snap = store.snapshot();
708        assert!(snap.contains_key("k0"), "evicted k0 should be in store");
709        assert!(snap.contains_key("k1"), "evicted k1 should be in store");
710    }
711
712    // 24. Read-through caches as clean
713    #[test]
714    fn test_read_through_is_clean() {
715        let store = MemStore::new();
716        {
717            let mut guard = store.data.lock().unwrap_or_else(|p| p.into_inner());
718            guard.insert("existing".to_string(), "value".to_string());
719        }
720        let mut cache = WriteBehindCache::new(10, store);
721        cache.get(&"existing".to_string()).ok();
722        assert!(!cache.is_dirty(&"existing".to_string()));
723        assert_eq!(cache.dirty_count(), 0);
724    }
725
726    // 25. Store reference accessors
727    #[test]
728    fn test_store_accessors() {
729        let store = MemStore::new();
730        let cache = WriteBehindCache::new(10, store);
731        let _store_ref = cache.store();
732        // Just verify it compiles and doesn't panic
733    }
734}