Skip to main content

oxirs_arq/plan_cache/
cache.rs

1//! Bounded LRU plan cache with schema-version invalidation.
2//!
3//! [`PlanCache<V>`] is a thread-safe, bounded cache keyed by `u64` fingerprints
4//! (produced by [`crate::plan_cache::fingerprint::compute_fingerprint`]).  It
5//! uses [`parking_lot::RwLock`] for low-contention concurrent reads and an
6//! [`LruEviction`] to bound memory usage.
7//!
8//! ## Schema invalidation
9//! Callers can call [`PlanCache::invalidate_all`] when the schema changes (e.g.
10//! after a graph update that invalidates cardinality assumptions).  This bumps
11//! an internal `schema_version` counter and clears all entries.
12//!
13//! ## Thread safety
14//! [`PlanCache`] wraps its internals in `Arc<RwLock<…>>` and implements
15//! [`Clone`]; clones share the same backing store and therefore observe the
16//! same hits/misses/evictions.
17
18use parking_lot::RwLock;
19use std::collections::HashMap;
20use std::sync::Arc;
21
22use super::eviction::LruEviction;
23
24/// Hit/miss/eviction counters for a [`PlanCache`].
25///
26/// Returned by [`PlanCache::stats`] as a plain `(hits, misses, evictions)` tuple.
27pub struct CacheStats {
28    /// Number of successful cache lookups.
29    pub hits: u64,
30    /// Number of failed cache lookups.
31    pub misses: u64,
32    /// Number of entries evicted due to capacity pressure.
33    pub evictions: u64,
34}
35
36struct CacheInner<V> {
37    map: HashMap<u64, V>,
38    eviction: LruEviction,
39    stats: CacheStats,
40    /// Bumped by [`PlanCache::invalidate_all`].  Not currently stored per-entry
41    /// (full invalidation clears the map), but tracked for observability.
42    schema_version: u64,
43}
44
45/// A bounded LRU plan cache with schema-version invalidation.
46///
47/// `V` is typically [`crate::algebra::Algebra`] (the optimised plan) but the
48/// cache is generic so tests can use `String`, `u32`, etc.
49///
50/// ```rust
51/// use oxirs_arq::plan_cache::PlanCache;
52///
53/// let cache: PlanCache<String> = PlanCache::new(10);
54/// cache.insert(42, "plan-a".to_string());
55/// assert_eq!(cache.get(42).as_deref(), Some("plan-a"));
56///
57/// let (hits, misses, evictions) = cache.stats();
58/// assert_eq!(hits, 1);
59/// assert_eq!(misses, 0);
60/// assert_eq!(evictions, 0);
61/// ```
62pub struct PlanCache<V: Clone> {
63    inner: Arc<RwLock<CacheInner<V>>>,
64}
65
66impl<V: Clone> PlanCache<V> {
67    /// Create a new cache with the given maximum `capacity`.
68    pub fn new(capacity: usize) -> Self {
69        Self {
70            inner: Arc::new(RwLock::new(CacheInner {
71                map: HashMap::new(),
72                eviction: LruEviction::new(capacity),
73                stats: CacheStats {
74                    hits: 0,
75                    misses: 0,
76                    evictions: 0,
77                },
78                schema_version: 0,
79            })),
80        }
81    }
82
83    /// Look up `key`.  Returns a clone of the stored value on a hit, or `None`
84    /// on a miss.  Updates hit/miss counters and refreshes the LRU position.
85    pub fn get(&self, key: u64) -> Option<V> {
86        let mut inner = self.inner.write();
87        if let Some(val) = inner.map.get(&key).cloned() {
88            inner.eviction.on_access(key);
89            inner.stats.hits += 1;
90            Some(val)
91        } else {
92            inner.stats.misses += 1;
93            None
94        }
95    }
96
97    /// Insert `value` under `key`.  If the cache is at capacity, the
98    /// least-recently-used entry is evicted first.
99    pub fn insert(&self, key: u64, value: V) {
100        let mut inner = self.inner.write();
101        if let Some(evict_key) = inner.eviction.on_insert(key) {
102            inner.map.remove(&evict_key);
103            inner.stats.evictions += 1;
104        }
105        inner.map.insert(key, value);
106    }
107
108    /// Remove all entries and bump the schema version.
109    pub fn invalidate_all(&self) {
110        let mut inner = self.inner.write();
111        inner.map.clear();
112        // Reset eviction tracker to avoid stale ordering.
113        inner.eviction = LruEviction::new(inner.eviction.capacity());
114        inner.schema_version += 1;
115    }
116
117    /// Return `(hits, misses, evictions)` since construction.
118    pub fn stats(&self) -> (u64, u64, u64) {
119        let inner = self.inner.read();
120        (inner.stats.hits, inner.stats.misses, inner.stats.evictions)
121    }
122
123    /// Current number of entries in the cache.
124    pub fn len(&self) -> usize {
125        self.inner.read().map.len()
126    }
127
128    /// Returns `true` when the cache holds no entries.
129    pub fn is_empty(&self) -> bool {
130        self.inner.read().map.is_empty()
131    }
132
133    /// Current schema version (bumped by each [`invalidate_all`](Self::invalidate_all) call).
134    pub fn schema_version(&self) -> u64 {
135        self.inner.read().schema_version
136    }
137}
138
139impl<V: Clone> Clone for PlanCache<V> {
140    /// Clone returns a handle that shares the **same** backing store.
141    fn clone(&self) -> Self {
142        Self {
143            inner: Arc::clone(&self.inner),
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn hit_and_miss_counters() {
154        let cache: PlanCache<String> = PlanCache::new(10);
155        cache.insert(1, "a".into());
156        assert!(cache.get(1).is_some());
157        assert!(cache.get(2).is_none());
158        let (hits, misses, _) = cache.stats();
159        assert_eq!(hits, 1);
160        assert_eq!(misses, 1);
161    }
162
163    #[test]
164    fn capacity_evicts_lru() {
165        let cache: PlanCache<u32> = PlanCache::new(3);
166        cache.insert(1, 10);
167        cache.insert(2, 20);
168        cache.insert(3, 30);
169        cache.insert(4, 40); // evicts 1
170        assert!(cache.get(1).is_none(), "key 1 should be evicted");
171        assert_eq!(cache.get(4), Some(40));
172    }
173
174    #[test]
175    fn invalidate_all_clears_and_bumps_version() {
176        let cache: PlanCache<u32> = PlanCache::new(10);
177        cache.insert(1, 100);
178        assert_eq!(cache.schema_version(), 0);
179        cache.invalidate_all();
180        assert!(cache.get(1).is_none());
181        assert_eq!(cache.schema_version(), 1);
182    }
183
184    #[test]
185    fn clone_shares_backing_store() {
186        let cache: PlanCache<u32> = PlanCache::new(10);
187        let clone = cache.clone();
188        cache.insert(99, 42);
189        assert_eq!(clone.get(99), Some(42));
190    }
191
192    #[test]
193    fn is_empty_initially() {
194        let cache: PlanCache<u32> = PlanCache::new(10);
195        assert!(cache.is_empty());
196        cache.insert(1, 1);
197        assert!(!cache.is_empty());
198    }
199}