Skip to main content

oxistore_cache/
bounded.rs

1//! Bounded-memory cache wrapper.
2//!
3//! [`BoundedCache`] wraps any `Cache<Vec<u8>, Vec<u8>>` and enforces a hard
4//! byte-budget cap.  When a new entry would push `current_bytes` over `max_bytes`,
5//! entries are evicted in insertion order (oldest first) until the budget allows
6//! the new entry to be inserted.
7
8use std::collections::VecDeque;
9
10use crate::Cache;
11
12/// A cache wrapper that caps total memory consumption by byte count.
13///
14/// Memory is tracked as `key.len() + value.len()` for every live entry.
15/// When inserting a new entry would exceed `max_bytes`, the oldest entries
16/// (tracked in insertion order) are evicted until there is enough budget.
17///
18/// # Type parameters
19///
20/// - `C`: an inner `Cache<Vec<u8>, Vec<u8>>` implementation.
21pub struct BoundedCache<C> {
22    inner: C,
23    max_bytes: usize,
24    current_bytes: usize,
25    /// Insertion-order tracking for eviction (front = oldest).
26    order: VecDeque<Vec<u8>>,
27}
28
29impl<C> BoundedCache<C>
30where
31    C: Cache<Vec<u8>, Vec<u8>>,
32{
33    /// Create a new `BoundedCache` wrapping `inner` with a hard byte budget.
34    ///
35    /// `max_bytes` is the maximum combined byte size of all keys and values.
36    pub fn new(inner: C, max_bytes: usize) -> Self {
37        BoundedCache {
38            inner,
39            max_bytes,
40            current_bytes: 0,
41            order: VecDeque::new(),
42        }
43    }
44
45    /// Return the current byte usage.
46    #[must_use]
47    pub fn current_bytes(&self) -> usize {
48        self.current_bytes
49    }
50
51    /// Return the maximum byte budget.
52    #[must_use]
53    pub fn max_bytes(&self) -> usize {
54        self.max_bytes
55    }
56
57    /// Evict entries in insertion order until there is at least `needed` bytes of
58    /// headroom, or until no more entries remain.
59    fn evict_until_fits(&mut self, needed: usize) {
60        while self.current_bytes + needed > self.max_bytes {
61            let oldest = match self.order.pop_front() {
62                Some(k) => k,
63                None => break,
64            };
65            if let Some(old_val) = self.inner.remove(&oldest) {
66                let freed = oldest.len() + old_val.len();
67                self.current_bytes = self.current_bytes.saturating_sub(freed);
68            }
69        }
70    }
71}
72
73impl<C> Cache<Vec<u8>, Vec<u8>> for BoundedCache<C>
74where
75    C: Cache<Vec<u8>, Vec<u8>>,
76{
77    fn get(&mut self, key: &Vec<u8>) -> Option<&Vec<u8>> {
78        self.inner.get(key)
79    }
80
81    fn put(&mut self, key: Vec<u8>, value: Vec<u8>) -> Option<Vec<u8>> {
82        let entry_size = key.len() + value.len();
83
84        // If inserting would exceed budget, evict oldest entries first.
85        self.evict_until_fits(entry_size);
86
87        // If the key already exists, subtract its old byte cost before re-inserting.
88        if let Some(existing) = self.inner.peek(&key) {
89            let old_size = key.len() + existing.len();
90            self.current_bytes = self.current_bytes.saturating_sub(old_size);
91            // Remove from order tracking — we'll re-add at the back.
92            self.order.retain(|k| k != &key);
93        }
94
95        self.current_bytes += entry_size;
96        self.order.push_back(key.clone());
97        self.inner.put(key, value)
98    }
99
100    fn put_with_ttl(
101        &mut self,
102        key: Vec<u8>,
103        value: Vec<u8>,
104        ttl: std::time::Duration,
105    ) -> Option<Vec<u8>> {
106        let entry_size = key.len() + value.len();
107        self.evict_until_fits(entry_size);
108
109        if let Some(existing) = self.inner.peek(&key) {
110            let old_size = key.len() + existing.len();
111            self.current_bytes = self.current_bytes.saturating_sub(old_size);
112            self.order.retain(|k| k != &key);
113        }
114
115        self.current_bytes += entry_size;
116        self.order.push_back(key.clone());
117        self.inner.put_with_ttl(key, value, ttl)
118    }
119
120    fn len(&self) -> usize {
121        self.inner.len()
122    }
123
124    fn cap(&self) -> usize {
125        self.inner.cap()
126    }
127
128    fn remove(&mut self, key: &Vec<u8>) -> Option<Vec<u8>> {
129        if let Some(val) = self.inner.remove(key) {
130            let freed = key.len() + val.len();
131            self.current_bytes = self.current_bytes.saturating_sub(freed);
132            self.order.retain(|k| k != key);
133            Some(val)
134        } else {
135            None
136        }
137    }
138
139    fn clear(&mut self) {
140        self.inner.clear();
141        self.current_bytes = 0;
142        self.order.clear();
143    }
144
145    fn peek(&self, key: &Vec<u8>) -> Option<&Vec<u8>> {
146        self.inner.peek(key)
147    }
148
149    fn contains_key(&self, key: &Vec<u8>) -> bool {
150        self.inner.contains_key(key)
151    }
152
153    fn resize(&mut self, new_cap: usize) {
154        self.inner.resize(new_cap);
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::LruCache;
162
163    fn make_bounded(max_bytes: usize, cap: usize) -> BoundedCache<LruCache<Vec<u8>, Vec<u8>>> {
164        BoundedCache::new(LruCache::new(cap), max_bytes)
165    }
166
167    #[test]
168    fn bounded_under_budget() {
169        let mut cache = make_bounded(100, 10);
170        cache.put(b"key1".to_vec(), b"val1".to_vec());
171        cache.put(b"key2".to_vec(), b"val2".to_vec());
172        assert!(cache.current_bytes() <= 100);
173        assert_eq!(cache.get(&b"key1".to_vec()), Some(&b"val1".to_vec()));
174    }
175
176    #[test]
177    fn bounded_evicts_when_over_budget() {
178        // max_bytes = 16 means we can hold two entries of 4+4=8 bytes each.
179        let mut cache = make_bounded(16, 100);
180        cache.put(b"key1".to_vec(), b"val1".to_vec()); // 8 bytes
181        cache.put(b"key2".to_vec(), b"val2".to_vec()); // 8 bytes — total 16
182        assert_eq!(cache.current_bytes(), 16);
183
184        // Adding a third entry (8 bytes) should evict the oldest (key1).
185        cache.put(b"key3".to_vec(), b"val3".to_vec());
186        assert!(cache.current_bytes() <= 16);
187        assert!(cache.get(&b"key1".to_vec()).is_none());
188    }
189
190    #[test]
191    fn bounded_remove_updates_bytes() {
192        let mut cache = make_bounded(100, 10);
193        cache.put(b"hello".to_vec(), b"world".to_vec());
194        let before = cache.current_bytes();
195        cache.remove(&b"hello".to_vec());
196        assert_eq!(cache.current_bytes(), before - 10); // 5 + 5 = 10 bytes freed
197    }
198
199    #[test]
200    fn bounded_clear_resets_bytes() {
201        let mut cache = make_bounded(100, 10);
202        cache.put(b"a".to_vec(), b"b".to_vec());
203        cache.clear();
204        assert_eq!(cache.current_bytes(), 0);
205    }
206
207    #[test]
208    fn bounded_update_existing_key() {
209        let mut cache = make_bounded(100, 10);
210        cache.put(b"key".to_vec(), b"short".to_vec()); // 3+5=8 bytes
211        cache.put(b"key".to_vec(), b"longer_value".to_vec()); // 3+12=15 bytes
212        assert!(cache.current_bytes() <= 100);
213        assert_eq!(cache.get(&b"key".to_vec()), Some(&b"longer_value".to_vec()));
214    }
215}