Skip to main content

oximedia_cache/
write_through.rs

1//! Write-through cache that synchronously persists every write to a backing store.
2//!
3//! Unlike write-*behind* (write-back) caching — where dirty entries are
4//! flushed lazily — a write-through cache propagates every [`put`] to the
5//! backing store **before** returning to the caller.  This guarantees that
6//! the backing store is always consistent with the cache at the cost of
7//! write latency.
8//!
9//! # Read path (write-allocate)
10//!
11//! On a cache miss, [`get`] falls back to the backing store.  If the store
12//! returns a value, the entry is inserted into the cache (write-allocate) so
13//! subsequent reads are served locally.
14//!
15//! # Example
16//!
17//! ```rust
18//! use oximedia_cache::write_through::{WriteThroughCache, InMemoryStore};
19//!
20//! let store: InMemoryStore<String, Vec<u8>> = InMemoryStore::new();
21//! let mut cache = WriteThroughCache::new(32, store);
22//!
23//! cache.put("key".to_string(), vec![1, 2, 3]).expect("put failed");
24//! assert!(cache.get(&"key".to_string()).is_some());
25//! ```
26//!
27//! [`put`]: WriteThroughCache::put
28//! [`get`]: WriteThroughCache::get
29
30use std::collections::HashMap;
31use std::fmt;
32
33// ── StoreError ────────────────────────────────────────────────────────────────
34
35/// Errors that can occur during backing-store operations.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum StoreError {
38    /// The backing store has no remaining capacity.
39    StoreFull,
40    /// The requested key does not exist in the store.
41    KeyNotFound,
42    /// A generic write failure with a descriptive message.
43    WriteFailure(String),
44}
45
46impl fmt::Display for StoreError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            StoreError::StoreFull => write!(f, "backing store is full"),
50            StoreError::KeyNotFound => write!(f, "key not found in backing store"),
51            StoreError::WriteFailure(msg) => write!(f, "write failure: {msg}"),
52        }
53    }
54}
55
56impl std::error::Error for StoreError {}
57
58// ── BackingStore trait ────────────────────────────────────────────────────────
59
60/// Abstraction over a synchronous persistent store.
61///
62/// Implementations are expected to be synchronous.  For async I/O the caller
63/// can provide a blocking adapter.
64pub trait BackingStore<K, V> {
65    /// Persist `(key, value)` to the backing store.
66    fn store(&mut self, key: &K, value: &V) -> Result<(), StoreError>;
67
68    /// Load the value associated with `key`, returning `None` if absent.
69    fn load(&self, key: &K) -> Option<V>;
70
71    /// Remove `key` from the backing store.
72    ///
73    /// Returns `true` if the key existed and was removed.
74    fn remove(&mut self, key: &K) -> bool;
75}
76
77// ── InMemoryStore ─────────────────────────────────────────────────────────────
78
79/// A simple in-memory [`BackingStore`] backed by a `HashMap`.
80///
81/// Useful for testing and as a reference implementation.
82#[derive(Debug, Clone, Default)]
83pub struct InMemoryStore<K, V>
84where
85    K: Eq + std::hash::Hash + Clone,
86    V: Clone,
87{
88    data: HashMap<K, V>,
89    /// Optional hard limit on number of entries (0 = unlimited).
90    max_entries: usize,
91    /// Number of times `store` was called.
92    write_count: u64,
93    /// Number of times `load` was called.
94    read_count: u64,
95}
96
97impl<K, V> InMemoryStore<K, V>
98where
99    K: Eq + std::hash::Hash + Clone,
100    V: Clone,
101{
102    /// Create an unbounded in-memory store.
103    pub fn new() -> Self {
104        Self {
105            data: HashMap::new(),
106            max_entries: 0,
107            write_count: 0,
108            read_count: 0,
109        }
110    }
111
112    /// Create an in-memory store with a hard capacity limit.
113    pub fn with_capacity(max_entries: usize) -> Self {
114        Self {
115            data: HashMap::with_capacity(max_entries),
116            max_entries,
117            write_count: 0,
118            read_count: 0,
119        }
120    }
121
122    /// Number of entries currently in the store.
123    pub fn len(&self) -> usize {
124        self.data.len()
125    }
126
127    /// Returns `true` when the store contains no entries.
128    pub fn is_empty(&self) -> bool {
129        self.data.is_empty()
130    }
131
132    /// Total number of write operations performed.
133    pub fn write_count(&self) -> u64 {
134        self.write_count
135    }
136
137    /// Total number of read operations performed.
138    pub fn read_count(&self) -> u64 {
139        self.read_count
140    }
141}
142
143impl<K, V> BackingStore<K, V> for InMemoryStore<K, V>
144where
145    K: Eq + std::hash::Hash + Clone,
146    V: Clone,
147{
148    fn store(&mut self, key: &K, value: &V) -> Result<(), StoreError> {
149        self.write_count += 1;
150        if self.max_entries > 0
151            && self.data.len() >= self.max_entries
152            && !self.data.contains_key(key)
153        {
154            return Err(StoreError::StoreFull);
155        }
156        self.data.insert(key.clone(), value.clone());
157        Ok(())
158    }
159
160    fn load(&self, key: &K) -> Option<V> {
161        self.data.get(key).cloned()
162    }
163
164    fn remove(&mut self, key: &K) -> bool {
165        self.data.remove(key).is_some()
166    }
167}
168
169// ── WriteThroughStats ─────────────────────────────────────────────────────────
170
171/// Snapshot of [`WriteThroughCache`] statistics.
172#[derive(Debug, Clone, Default)]
173pub struct WriteThroughStats {
174    /// Number of reads served directly from the in-process cache.
175    pub cache_hits: u64,
176    /// Number of reads served from the backing store (write-allocate path).
177    pub backing_store_hits: u64,
178    /// Number of reads that found no value in either cache or backing store.
179    pub misses: u64,
180    /// Total number of successful [`put`] operations (cache + store written).
181    ///
182    /// [`put`]: WriteThroughCache::put
183    pub writes: u64,
184    /// Number of [`put`] operations that failed due to a backing-store error.
185    pub write_errors: u64,
186}
187
188// ── WriteThroughCache ─────────────────────────────────────────────────────────
189
190/// Write-through cache that synchronously persists every write.
191///
192/// # Type parameters
193/// * `K` – key type; must implement `Eq + Hash + Clone`.
194/// * `V` – value type; must implement `Clone`.
195/// * `S` – backing store; must implement [`BackingStore<K, V>`].
196pub struct WriteThroughCache<K, V, S>
197where
198    K: Eq + std::hash::Hash + Clone,
199    V: Clone,
200    S: BackingStore<K, V>,
201{
202    capacity: usize,
203    cache: HashMap<K, V>,
204    /// Insertion order for FIFO eviction.
205    insertion_order: Vec<K>,
206    store: S,
207    stats: WriteThroughStats,
208    /// Phantom data to tie K and V to the struct without owning them.
209    _marker: std::marker::PhantomData<(K, V)>,
210}
211
212impl<K, V, S> WriteThroughCache<K, V, S>
213where
214    K: Eq + std::hash::Hash + Clone,
215    V: Clone,
216    S: BackingStore<K, V>,
217{
218    /// Create a new `WriteThroughCache` with the given `capacity` and backing
219    /// `store`.
220    ///
221    /// # Panics
222    ///
223    /// Panics if `capacity == 0`.
224    pub fn new(capacity: usize, store: S) -> Self {
225        assert!(capacity > 0, "WriteThroughCache capacity must be non-zero");
226        Self {
227            capacity,
228            cache: HashMap::with_capacity(capacity.min(1024)),
229            insertion_order: Vec::with_capacity(capacity.min(1024)),
230            store,
231            stats: WriteThroughStats::default(),
232            _marker: std::marker::PhantomData,
233        }
234    }
235
236    /// Write `(key, value)` to both the in-process cache and the backing store.
237    ///
238    /// If the write to the backing store fails, the cache is **not** updated and
239    /// an error is returned — ensuring both are consistent.
240    ///
241    /// When the cache has reached `capacity`, the oldest entry is evicted
242    /// (from the cache only; the backing store is unaffected).
243    pub fn put(&mut self, key: K, value: V) -> Result<(), StoreError> {
244        // Attempt backing-store write first so that failure is visible before
245        // we mutate the cache.
246        if let Err(e) = self.store.store(&key, &value) {
247            self.stats.write_errors += 1;
248            return Err(e);
249        }
250
251        // Evict oldest if needed (and key is new).
252        if !self.cache.contains_key(&key) && self.cache.len() >= self.capacity {
253            self.evict_oldest();
254        } else if self.cache.contains_key(&key) {
255            // Update in place; refresh insertion-order position.
256            self.insertion_order.retain(|k| k != &key);
257        }
258
259        self.cache.insert(key.clone(), value);
260        self.insertion_order.push(key);
261        self.stats.writes += 1;
262        Ok(())
263    }
264
265    /// Retrieve the value for `key`.
266    ///
267    /// Returns a reference to the cached value on a cache hit.  On a cache
268    /// miss, falls back to the backing store; if the store has the value it is
269    /// inserted into the cache (write-allocate) and a reference is returned.
270    ///
271    /// Returns `None` only when neither cache nor store contains the key.
272    pub fn get(&mut self, key: &K) -> Option<&V> {
273        if self.cache.contains_key(key) {
274            self.stats.cache_hits += 1;
275            return self.cache.get(key);
276        }
277
278        // Cache miss — try backing store.
279        match self.store.load(key) {
280            Some(value) => {
281                self.stats.backing_store_hits += 1;
282                // Write-allocate: populate the cache.
283                if self.cache.len() >= self.capacity {
284                    self.evict_oldest();
285                }
286                self.cache.insert(key.clone(), value);
287                self.insertion_order.push(key.clone());
288                self.cache.get(key)
289            }
290            None => {
291                self.stats.misses += 1;
292                None
293            }
294        }
295    }
296
297    /// Invalidate `key` from both the cache and the backing store.
298    ///
299    /// Returns `true` if the key was present in either location.
300    pub fn invalidate(&mut self, key: &K) -> bool {
301        let in_cache = self.cache.remove(key).is_some();
302        if in_cache {
303            self.insertion_order.retain(|k| k != key);
304        }
305        let in_store = self.store.remove(key);
306        in_cache || in_store
307    }
308
309    /// Return a statistics snapshot.
310    pub fn stats(&self) -> WriteThroughStats {
311        self.stats.clone()
312    }
313
314    /// Number of entries currently in the in-process cache.
315    pub fn cache_len(&self) -> usize {
316        self.cache.len()
317    }
318
319    /// Configured cache capacity.
320    pub fn capacity(&self) -> usize {
321        self.capacity
322    }
323
324    /// Shared reference to the backing store.
325    pub fn backing_store(&self) -> &S {
326        &self.store
327    }
328
329    // ── private helpers ───────────────────────────────────────────────────────
330
331    fn evict_oldest(&mut self) {
332        if self.insertion_order.is_empty() {
333            return;
334        }
335        let oldest = self.insertion_order.remove(0);
336        self.cache.remove(&oldest);
337    }
338}
339
340// ── Tests ─────────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn make_cache(
347        cap: usize,
348    ) -> WriteThroughCache<String, Vec<u8>, InMemoryStore<String, Vec<u8>>> {
349        let store = InMemoryStore::new();
350        WriteThroughCache::new(cap, store)
351    }
352
353    #[test]
354    fn test_put_and_get_from_cache() {
355        let mut cache = make_cache(10);
356        cache.put("key1".to_string(), vec![1, 2, 3]).expect("put");
357        let v = cache.get(&"key1".to_string());
358        assert_eq!(v, Some(&vec![1u8, 2, 3]));
359        assert_eq!(cache.stats().cache_hits, 1);
360    }
361
362    #[test]
363    fn test_put_persists_to_backing_store() {
364        let mut cache = make_cache(10);
365        cache.put("k".to_string(), vec![9]).expect("put");
366        // Peek into backing store directly.
367        let loaded = cache.backing_store().load(&"k".to_string());
368        assert_eq!(loaded, Some(vec![9u8]));
369    }
370
371    #[test]
372    fn test_get_fallback_to_backing_store() {
373        let store = InMemoryStore::new();
374        let mut cache: WriteThroughCache<String, u32, InMemoryStore<String, u32>> =
375            WriteThroughCache::new(10, store);
376
377        // Manually pre-populate the backing store via a put, then clear the
378        // in-process cache by creating a fresh cache wrapping the store data.
379        // Simulate backing-store hit by putting a value and then evicting it
380        // from cache by filling with other entries.
381        cache.put("target".to_string(), 42u32).expect("put");
382
383        // Fill cache beyond capacity so "target" gets evicted (cap=2 here).
384        let store2 = InMemoryStore::new();
385        let mut cache2: WriteThroughCache<String, u32, InMemoryStore<String, u32>> =
386            WriteThroughCache::new(2, store2);
387        cache2.put("target".to_string(), 42).expect("put");
388        cache2.put("a".to_string(), 1).expect("put");
389        cache2.put("b".to_string(), 2).expect("put"); // evicts "target" from cache
390
391        // "target" should now be served from backing store.
392        let v = cache2.get(&"target".to_string());
393        assert_eq!(v, Some(&42));
394        assert_eq!(cache2.stats().backing_store_hits, 1);
395    }
396
397    #[test]
398    fn test_invalidate_removes_from_both() {
399        let mut cache = make_cache(10);
400        cache.put("x".to_string(), vec![0]).expect("put");
401        let removed = cache.invalidate(&"x".to_string());
402        assert!(removed);
403        assert_eq!(cache.get(&"x".to_string()), None);
404        assert_eq!(cache.backing_store().load(&"x".to_string()), None);
405    }
406
407    #[test]
408    fn test_invalidate_absent_key_returns_false() {
409        let mut cache = make_cache(10);
410        assert!(!cache.invalidate(&"ghost".to_string()));
411    }
412
413    #[test]
414    fn test_stats_writes_and_errors() {
415        let store = InMemoryStore::<String, u32>::with_capacity(1);
416        let mut cache: WriteThroughCache<String, u32, InMemoryStore<String, u32>> =
417            WriteThroughCache::new(10, store);
418
419        cache.put("first".to_string(), 1).expect("first put ok");
420        // second distinct key overflows the store capacity of 1.
421        let result = cache.put("second".to_string(), 2);
422        assert_eq!(result, Err(StoreError::StoreFull));
423
424        let s = cache.stats();
425        assert_eq!(s.writes, 1);
426        assert_eq!(s.write_errors, 1);
427    }
428
429    #[test]
430    fn test_miss_when_not_in_either() {
431        let mut cache = make_cache(10);
432        assert_eq!(cache.get(&"nonexistent".to_string()), None);
433        assert_eq!(cache.stats().misses, 1);
434    }
435
436    #[test]
437    fn test_capacity_eviction() {
438        let mut cache = make_cache(3);
439        cache.put("a".to_string(), vec![1]).expect("put");
440        cache.put("b".to_string(), vec![2]).expect("put");
441        cache.put("c".to_string(), vec![3]).expect("put");
442        // "a" is oldest, will be evicted from in-process cache (not store).
443        cache.put("d".to_string(), vec![4]).expect("put");
444        // "a" should still be retrievable from the backing store.
445        assert_eq!(cache.stats().cache_hits, 0);
446        let v = cache.get(&"a".to_string());
447        assert_eq!(v, Some(&vec![1u8]));
448        assert_eq!(cache.stats().backing_store_hits, 1);
449    }
450
451    #[test]
452    fn test_overwrite_existing_key() {
453        let mut cache = make_cache(5);
454        cache.put("k".to_string(), vec![1]).expect("put");
455        cache.put("k".to_string(), vec![2]).expect("put"); // overwrite
456        assert_eq!(cache.get(&"k".to_string()), Some(&vec![2u8]));
457        assert_eq!(cache.cache_len(), 1);
458    }
459
460    #[test]
461    fn test_write_failure_does_not_pollute_cache() {
462        // capacity(1) means the store accepts exactly 1 entry; the 2nd distinct key fails.
463        let store = InMemoryStore::<String, i32>::with_capacity(1);
464        let mut cache: WriteThroughCache<String, i32, InMemoryStore<String, i32>> =
465            WriteThroughCache::new(10, store);
466        // First write succeeds.
467        cache.put("key1".to_string(), 1).expect("first ok");
468        // Second distinct key overflows the store's capacity of 1.
469        let result = cache.put("key2".to_string(), 2);
470        assert!(result.is_err(), "second put should fail");
471        // Cache must NOT contain "key2" because the backing-store write failed.
472        assert_eq!(cache.get(&"key2".to_string()), None);
473    }
474}