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}