Skip to main content

rushdb_storage/
cache.rs

1//! Byte-accounted logical caches.
2//!
3//! A bounded, LRU-evicting key/value cache whose resident bytes are charged to
4//! the shared [`ByteBudget`] tree. Unlike an unbounded `HashMap`, growth is
5//! visible to the resource governor and a cache can never exceed its budget:
6//! `insert` evicts least-recently-used entries until the new entry fits, and
7//! skips caching a single entry larger than the whole budget.
8//!
9//! This is the shared mechanism behind the decoded-record, adjacency, and
10//! property-dictionary caches; callers supply each entry's byte weight so the
11//! accounting matches what they actually retain.
12
13use std::collections::{BTreeMap, HashMap};
14use std::hash::Hash;
15
16use crate::resources::{ByteBudget, ByteReservation, ResourceError};
17
18/// Point-in-time cache occupancy and hit-rate counters.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub struct CacheStats {
21    /// Number of live entries.
22    pub entries: u64,
23    /// Resident bytes charged to the budget.
24    pub bytes: u64,
25    /// Successful lookups.
26    pub hits: u64,
27    /// Lookups that missed.
28    pub misses: u64,
29    /// Entries evicted to stay within budget.
30    pub evictions: u64,
31}
32
33struct Entry<V> {
34    value: V,
35    bytes: usize,
36    seq: u64,
37}
38
39/// A bounded logical cache charged to a [`ByteBudget`].
40pub struct LogicalCache<K, V> {
41    budget: ByteBudget,
42    reserved: ByteReservation,
43    entries: HashMap<K, Entry<V>>,
44    /// LRU order: monotonically increasing sequence -> key. The smallest
45    /// sequence is the least-recently-used entry.
46    order: BTreeMap<u64, K>,
47    seq: u64,
48    hits: u64,
49    misses: u64,
50    evictions: u64,
51}
52
53impl<K, V> LogicalCache<K, V>
54where
55    K: Eq + Hash + Clone,
56{
57    /// Build a cache charged to `budget`. A zero-limit budget makes every
58    /// `insert` a no-op (the cache is effectively disabled).
59    ///
60    /// # Errors
61    /// Returns [`ResourceError`] if the initial zero-byte reservation cannot be
62    /// created (only when the budget is already over its limit).
63    pub fn new(budget: ByteBudget) -> Result<Self, ResourceError> {
64        let reserved = budget.try_reserve(0)?;
65        Ok(Self {
66            budget,
67            reserved,
68            entries: HashMap::new(),
69            order: BTreeMap::new(),
70            seq: 0,
71            hits: 0,
72            misses: 0,
73            evictions: 0,
74        })
75    }
76
77    fn next_seq(&mut self) -> u64 {
78        self.seq = self.seq.wrapping_add(1);
79        self.seq
80    }
81
82    /// Configured byte limit for this cache.
83    #[must_use]
84    pub fn limit(&self) -> usize {
85        self.budget.snapshot().limit
86    }
87
88    /// Look up `key`, marking it most-recently-used on a hit.
89    pub fn get(&mut self, key: &K) -> Option<&V> {
90        let seq = self.next_seq();
91        let Some(entry) = self.entries.get_mut(key) else {
92            self.misses = self.misses.saturating_add(1);
93            return None;
94        };
95        self.hits = self.hits.saturating_add(1);
96        self.order.remove(&entry.seq);
97        entry.seq = seq;
98        self.order.insert(seq, key.clone());
99        self.entries.get(key).map(|entry| &entry.value)
100    }
101
102    /// Insert or replace an entry whose retained size is `bytes`. Evicts LRU
103    /// entries until it fits; a value larger than the whole budget is not
104    /// cached. Returns whether the entry is now resident.
105    pub fn insert(&mut self, key: K, value: V, bytes: usize) -> bool {
106        // Never evict live entries to make room for one that cannot fit.
107        if bytes > self.limit() {
108            return false;
109        }
110        if let Some(old) = self.entries.remove(&key) {
111            self.order.remove(&old.seq);
112            let _ = self
113                .reserved
114                .resize(self.reserved.bytes().saturating_sub(old.bytes));
115        }
116        while self.reserved.bytes().saturating_add(bytes) > self.limit() {
117            if !self.evict_one() {
118                return false;
119            }
120        }
121        if self
122            .reserved
123            .resize(self.reserved.bytes().saturating_add(bytes))
124            .is_err()
125        {
126            return false;
127        }
128        let seq = self.next_seq();
129        self.order.insert(seq, key.clone());
130        self.entries.insert(key, Entry { value, bytes, seq });
131        true
132    }
133
134    /// Remove the least-recently-used entry. Returns false when empty.
135    fn evict_one(&mut self) -> bool {
136        let Some((&seq, key)) = self.order.iter().next() else {
137            return false;
138        };
139        let key = key.clone();
140        self.order.remove(&seq);
141        if let Some(entry) = self.entries.remove(&key) {
142            let _ = self
143                .reserved
144                .resize(self.reserved.bytes().saturating_sub(entry.bytes));
145            self.evictions = self.evictions.saturating_add(1);
146            return true;
147        }
148        false
149    }
150
151    /// Drop every entry `keep` rejects, releasing its bytes (counted as
152    /// evictions). Returns how many entries were dropped.
153    pub fn retain(&mut self, mut keep: impl FnMut(&K, &V) -> bool) -> usize {
154        let doomed: Vec<K> = self
155            .entries
156            .iter()
157            .filter(|(key, entry)| !keep(key, &entry.value))
158            .map(|(key, _)| key.clone())
159            .collect();
160        for key in &doomed {
161            if let Some(entry) = self.entries.remove(key) {
162                self.order.remove(&entry.seq);
163                let _ = self
164                    .reserved
165                    .resize(self.reserved.bytes().saturating_sub(entry.bytes));
166                self.evictions = self.evictions.saturating_add(1);
167            }
168        }
169        doomed.len()
170    }
171
172    /// Drop every entry, releasing all charged bytes.
173    pub fn clear(&mut self) {
174        self.entries.clear();
175        self.order.clear();
176        let _ = self.reserved.resize(0);
177    }
178
179    /// Occupancy and counters.
180    #[must_use]
181    pub fn stats(&self) -> CacheStats {
182        CacheStats {
183            entries: self.entries.len() as u64,
184            bytes: self.reserved.bytes() as u64,
185            hits: self.hits,
186            misses: self.misses,
187            evictions: self.evictions,
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn cache(limit: usize) -> LogicalCache<u64, String> {
197        LogicalCache::new(ByteBudget::new("test-cache", limit).unwrap()).unwrap()
198    }
199
200    #[test]
201    fn evicts_least_recently_used_when_over_budget() {
202        let mut c = cache(100);
203        assert!(c.insert(1, "a".into(), 40));
204        assert!(c.insert(2, "b".into(), 40));
205        // Touch 1 so 2 is the LRU.
206        assert_eq!(c.get(&1).map(String::as_str), Some("a"));
207        assert!(c.insert(3, "c".into(), 40)); // evicts 2
208        assert!(c.get(&2).is_none(), "LRU evicted");
209        assert_eq!(c.get(&1).map(String::as_str), Some("a"));
210        assert_eq!(c.get(&3).map(String::as_str), Some("c"));
211        let s = c.stats();
212        assert_eq!(s.entries, 2);
213        assert_eq!(s.bytes, 80);
214        assert_eq!(s.evictions, 1);
215        assert_eq!(s.hits, 3); // 1,1,3
216        assert_eq!(s.misses, 1);
217    }
218
219    #[test]
220    fn oversized_entry_is_not_cached_and_releases_nothing() {
221        let mut c = cache(100);
222        assert!(c.insert(1, "a".into(), 40));
223        assert!(!c.insert(2, "huge".into(), 500), "oversized rejected");
224        assert!(c.get(&2).is_none());
225        assert_eq!(c.stats().bytes, 40, "failed insert charges nothing");
226        assert_eq!(c.get(&1).map(String::as_str), Some("a"));
227    }
228
229    #[test]
230    fn replacing_releases_the_old_charge() {
231        let mut c = cache(100);
232        c.insert(1, "a".into(), 60);
233        c.insert(1, "bb".into(), 20);
234        let s = c.stats();
235        assert_eq!(s.entries, 1);
236        assert_eq!(s.bytes, 20);
237        assert_eq!(c.get(&1).map(String::as_str), Some("bb"));
238    }
239
240    #[test]
241    fn budget_too_small_to_hold_any_entry_caches_nothing() {
242        let mut c = cache(10);
243        assert!(!c.insert(1, "a".into(), 20));
244        assert!(c.get(&1).is_none());
245        assert_eq!(c.stats().bytes, 0);
246    }
247
248    #[test]
249    fn clear_releases_all_bytes() {
250        let mut c = cache(100);
251        c.insert(1, "a".into(), 40);
252        c.insert(2, "b".into(), 40);
253        c.clear();
254        assert_eq!(c.stats().entries, 0);
255        assert_eq!(c.stats().bytes, 0);
256    }
257}