Skip to main content

reddb_server/storage/query/planner/
cache.rs

1//! Query Plan Cache
2//!
3//! LRU cache for compiled query plans with TTL validation.
4//!
5//! # Features
6//!
7//! - LRU eviction policy
8//! - TTL-based invalidation
9//! - Thread-safe access
10//! - Statistics tracking
11
12use std::collections::HashMap;
13use std::time::{Duration, Instant};
14
15use super::{CacheStats, QueryPlan};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CacheEntryState {
19    Inactive,
20    Active,
21}
22
23/// A cached query plan with metadata
24#[derive(Debug, Clone)]
25pub struct CachedPlan {
26    /// The compiled query plan
27    pub plan: QueryPlan,
28    /// When this plan was cached
29    pub cached_at: Instant,
30    /// Number of times this plan was accessed
31    pub access_count: u64,
32    /// Last access time
33    pub last_accessed: Instant,
34    /// Query shape key used for parameter-insensitive cache grouping.
35    pub shape_key: Option<String>,
36    /// Last exact query string stored in this slot.
37    pub exact_query: Option<std::sync::Arc<str>>,
38    /// Runtime activation state inspired by Mongo's active/inactive plan cache.
39    pub state: CacheEntryState,
40    /// Moving expectation for storage reads (`rows_scanned`) on this shape.
41    pub expected_rows_scanned: Option<u64>,
42    /// Last observed runtime reads for the shape.
43    pub last_observed_rows_scanned: Option<u64>,
44    /// Number of literal binds expected by the cached shape skeleton.
45    pub parameter_count: usize,
46    /// When true, the next cache lookup forces a fresh replan.
47    pub replan_pending: bool,
48    /// Monotonic recency stamp assigned by the owning [`PlanCache`] on every
49    /// insert and hit. The least-recently-used entry is the one with the
50    /// smallest stamp; this replaces the old `Vec<String>` scan so promotion is
51    /// O(1) and allocation-free on the cache-hit hot path.
52    lru_seq: u64,
53}
54
55impl CachedPlan {
56    /// Create a new cached plan
57    pub fn new(plan: QueryPlan) -> Self {
58        let now = Instant::now();
59        Self {
60            plan,
61            cached_at: now,
62            access_count: 0,
63            last_accessed: now,
64            shape_key: None,
65            exact_query: None,
66            state: CacheEntryState::Inactive,
67            expected_rows_scanned: None,
68            last_observed_rows_scanned: None,
69            parameter_count: 0,
70            replan_pending: false,
71            lru_seq: 0,
72        }
73    }
74
75    pub fn with_shape_key(mut self, shape_key: impl Into<String>) -> Self {
76        self.shape_key = Some(shape_key.into());
77        self
78    }
79
80    pub fn with_exact_query(mut self, query: impl Into<String>) -> Self {
81        let s: String = query.into();
82        self.exact_query = Some(std::sync::Arc::<str>::from(s));
83        self
84    }
85
86    pub fn with_parameter_count(mut self, parameter_count: usize) -> Self {
87        self.parameter_count = parameter_count;
88        self
89    }
90
91    /// Check if the plan has expired
92    pub fn is_expired(&self, ttl: Duration) -> bool {
93        self.cached_at.elapsed() > ttl
94    }
95
96    /// Record an access
97    pub fn touch(&mut self) {
98        self.access_count += 1;
99        self.last_accessed = Instant::now();
100    }
101
102    pub fn matches_exact_query(&self, query: &str) -> bool {
103        self.exact_query.as_deref() == Some(query)
104    }
105
106    pub fn needs_replan(&self) -> bool {
107        self.replan_pending
108    }
109
110    pub fn record_observation(&mut self, rows_scanned: u64) {
111        self.last_observed_rows_scanned = Some(rows_scanned);
112        match (self.state, self.expected_rows_scanned) {
113            (_, None) => {
114                self.expected_rows_scanned = Some(rows_scanned.max(1));
115                self.replan_pending = false;
116            }
117            (CacheEntryState::Inactive, Some(expected)) => {
118                if rows_scanned <= expected {
119                    self.state = CacheEntryState::Active;
120                    self.expected_rows_scanned = Some(rows_scanned.max(1));
121                    self.replan_pending = false;
122                } else {
123                    self.expected_rows_scanned = Some(rows_scanned.min(expected.saturating_mul(2)));
124                }
125            }
126            (CacheEntryState::Active, Some(expected)) => {
127                if rows_scanned > expected.saturating_mul(10).max(10) {
128                    self.state = CacheEntryState::Inactive;
129                    self.expected_rows_scanned = Some(rows_scanned.max(1));
130                    self.replan_pending = true;
131                } else if rows_scanned < expected {
132                    self.expected_rows_scanned = Some(rows_scanned.max(1));
133                    self.replan_pending = false;
134                }
135            }
136        }
137    }
138}
139
140/// LRU cache for query plans
141///
142/// Recency is tracked by a monotonic `clock` counter stamped onto each entry's
143/// `lru_seq` on insert and hit, rather than a `Vec<String>` that had to be
144/// linearly scanned (`position()` + `remove()`) on every promotion and removal.
145/// Promotion (the cache-hit hot path) is now O(1) and allocation-free; eviction
146/// picks the entry with the smallest stamp, which is the exact same victim the
147/// old front-of-`Vec` policy would have chosen for any given access sequence.
148pub struct PlanCache {
149    /// Cached plans by key
150    entries: HashMap<String, CachedPlan>,
151    /// Monotonic recency clock; larger means more recently used.
152    clock: u64,
153    /// Maximum cache size
154    capacity: usize,
155    /// Time-to-live for entries
156    ttl: Duration,
157    /// Cache statistics
158    hits: u64,
159    misses: u64,
160}
161
162impl PlanCache {
163    /// Create a new plan cache with the given capacity
164    ///
165    /// A capacity of 0 is meaningless and unreachable from every call site
166    /// today. If one ever appears, the LRU keeps a single entry rather than
167    /// evicting forever (the pre-#2012 code looped indefinitely on it).
168    pub fn new(capacity: usize) -> Self {
169        debug_assert!(capacity > 0, "plan cache capacity must be positive");
170        Self {
171            entries: HashMap::with_capacity(capacity),
172            clock: 0,
173            capacity,
174            ttl: Duration::from_secs(3600), // 1 hour default TTL
175            hits: 0,
176            misses: 0,
177        }
178    }
179
180    /// Advance the recency clock and return the fresh stamp.
181    fn next_seq(&mut self) -> u64 {
182        self.clock += 1;
183        self.clock
184    }
185
186    /// Set the TTL for cache entries
187    pub fn with_ttl(mut self, ttl: Duration) -> Self {
188        self.ttl = ttl;
189        self
190    }
191
192    /// Read-only cache probe — no LRU promotion, no mutation.
193    ///
194    /// Use this with a `read()` lock in the hot query path so concurrent
195    /// readers don't serialize on a write lock. Falls back to `None` when
196    /// the entry is expired or has a pending replan; the caller must then
197    /// re-acquire a `write()` lock and call `get()` to handle eviction and
198    /// miss insertion.
199    pub fn peek(&self, key: &str) -> Option<&CachedPlan> {
200        let entry = self.entries.get(key)?;
201        if entry.needs_replan() || entry.is_expired(self.ttl) {
202            return None;
203        }
204        Some(entry)
205    }
206
207    /// Get a cached plan by key
208    pub fn get(&mut self, key: &str) -> Option<&CachedPlan> {
209        if self
210            .entries
211            .get(key)
212            .is_some_and(|entry| entry.needs_replan())
213        {
214            self.remove(key);
215            self.misses += 1;
216            return None;
217        }
218
219        // Check if entry exists and is not expired
220        let expired = match self.entries.get(key) {
221            Some(entry) => entry.is_expired(self.ttl),
222            None => {
223                self.misses += 1;
224                return None;
225            }
226        };
227        if expired {
228            // Remove expired entry
229            self.remove(key);
230            self.misses += 1;
231            return None;
232        }
233
234        // Hit: stamp the entry as most-recently-used. O(1), no allocation.
235        let seq = self.next_seq();
236        if let Some(entry) = self.entries.get_mut(key) {
237            entry.touch();
238            entry.lru_seq = seq;
239        }
240        self.hits += 1;
241        self.entries.get(key)
242    }
243
244    /// Insert a plan into the cache
245    pub fn insert(&mut self, key: String, mut plan: CachedPlan) {
246        // Remove existing entry if present
247        if self.entries.contains_key(&key) {
248            self.remove(&key);
249        }
250
251        // Evict if at capacity
252        while self.entries.len() >= self.capacity && !self.entries.is_empty() {
253            self.evict_lru();
254        }
255
256        // Insert new entry, stamped as most-recently-used.
257        plan.lru_seq = self.next_seq();
258        self.entries.insert(key, plan);
259    }
260
261    /// Remove an entry from the cache
262    pub fn remove(&mut self, key: &str) -> Option<CachedPlan> {
263        self.entries.remove(key)
264    }
265
266    /// Invalidate entries matching a predicate
267    pub fn invalidate<F>(&mut self, predicate: F)
268    where
269        F: Fn(&str) -> bool,
270    {
271        let keys_to_remove: Vec<String> = self
272            .entries
273            .keys()
274            .filter(|k| predicate(k))
275            .cloned()
276            .collect();
277
278        for key in keys_to_remove {
279            self.remove(&key);
280        }
281    }
282
283    /// Clear all entries
284    pub fn clear(&mut self) {
285        self.entries.clear();
286    }
287
288    /// Get cache statistics
289    pub fn stats(&self) -> CacheStats {
290        CacheStats {
291            hits: self.hits,
292            misses: self.misses,
293            size: self.entries.len(),
294            capacity: self.capacity,
295        }
296    }
297
298    /// Evict the least recently used entry (smallest recency stamp).
299    fn evict_lru(&mut self) {
300        let victim = self
301            .entries
302            .iter()
303            .min_by_key(|(_, entry)| entry.lru_seq)
304            .map(|(key, _)| key.clone());
305        if let Some(key) = victim {
306            self.remove(&key);
307        }
308    }
309
310    /// Prune expired entries
311    pub fn prune_expired(&mut self) {
312        let expired: Vec<String> = self
313            .entries
314            .iter()
315            .filter(|(_, v)| v.is_expired(self.ttl))
316            .map(|(k, _)| k.clone())
317            .collect();
318
319        for key in expired {
320            self.remove(&key);
321        }
322    }
323
324    pub fn record_observation(&mut self, key: &str, rows_scanned: u64) {
325        if let Some(entry) = self.entries.get_mut(key) {
326            entry.record_observation(rows_scanned);
327        }
328    }
329}
330
331impl Default for PlanCache {
332    fn default() -> Self {
333        Self::new(1000)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::storage::query::ast::{Projection, QueryExpr, TableQuery};
341    use crate::storage::query::planner::cost::PlanCost;
342
343    fn make_test_plan() -> QueryPlan {
344        QueryPlan::new(
345            QueryExpr::Table(TableQuery {
346                table: "test".to_string(),
347                source: None,
348                alias: None,
349                select_items: Vec::new(),
350                columns: vec![Projection::All],
351                where_expr: None,
352                filter: None,
353                group_by_exprs: Vec::new(),
354                group_by: Vec::new(),
355                having_expr: None,
356                having: None,
357                order_by: vec![],
358                limit: None,
359                limit_param: None,
360                offset: None,
361                offset_param: None,
362                expand: None,
363                as_of: None,
364                sessionize: None,
365                distinct: false,
366            }),
367            QueryExpr::Table(TableQuery {
368                table: "test".to_string(),
369                source: None,
370                alias: None,
371                select_items: Vec::new(),
372                columns: vec![Projection::All],
373                where_expr: None,
374                filter: None,
375                group_by_exprs: Vec::new(),
376                group_by: Vec::new(),
377                having_expr: None,
378                having: None,
379                order_by: vec![],
380                limit: None,
381                limit_param: None,
382                offset: None,
383                offset_param: None,
384                expand: None,
385                as_of: None,
386                sessionize: None,
387                distinct: false,
388            }),
389            PlanCost::default(),
390        )
391    }
392
393    #[test]
394    fn test_cache_insert_and_get() {
395        let mut cache = PlanCache::new(10);
396        let plan = CachedPlan::new(make_test_plan());
397
398        cache.insert("query1".to_string(), plan);
399        assert!(cache.get("query1").is_some());
400        assert!(cache.get("query2").is_none());
401    }
402
403    #[test]
404    fn test_cache_lru_eviction() {
405        let mut cache = PlanCache::new(2);
406
407        cache.insert("q1".to_string(), CachedPlan::new(make_test_plan()));
408        cache.insert("q2".to_string(), CachedPlan::new(make_test_plan()));
409
410        // Access q1 to make it most recently used
411        let _ = cache.get("q1");
412
413        // Insert q3 - should evict q2 (LRU)
414        cache.insert("q3".to_string(), CachedPlan::new(make_test_plan()));
415
416        assert!(cache.get("q1").is_some());
417        assert!(cache.get("q2").is_none()); // Evicted
418        assert!(cache.get("q3").is_some());
419    }
420
421    #[test]
422    fn test_cache_stats() {
423        let mut cache = PlanCache::new(10);
424        cache.insert("q1".to_string(), CachedPlan::new(make_test_plan()));
425
426        let _ = cache.get("q1"); // Hit
427        let _ = cache.get("q2"); // Miss
428        let _ = cache.get("q1"); // Hit
429
430        let stats = cache.stats();
431        assert_eq!(stats.hits, 2);
432        assert_eq!(stats.misses, 1);
433    }
434
435    #[test]
436    fn test_cache_invalidation() {
437        let mut cache = PlanCache::new(10);
438        cache.insert(
439            "hosts_query1".to_string(),
440            CachedPlan::new(make_test_plan()),
441        );
442        cache.insert(
443            "hosts_query2".to_string(),
444            CachedPlan::new(make_test_plan()),
445        );
446        cache.insert("users_query".to_string(), CachedPlan::new(make_test_plan()));
447
448        // Invalidate all hosts queries
449        cache.invalidate(|k| k.starts_with("hosts_"));
450
451        assert!(cache.get("hosts_query1").is_none());
452        assert!(cache.get("hosts_query2").is_none());
453        assert!(cache.get("users_query").is_some());
454    }
455
456    #[test]
457    fn lru_eviction_picks_least_recently_used_victim() {
458        // Regression guard for the #2011 recency-counter LRU: eviction must pick
459        // the same victim the old front-of-`Vec` policy would have, for a given
460        // access sequence. `peek` is used for verification so it does not itself
461        // perturb recency.
462        let mut cache = PlanCache::new(3);
463        for k in ["a", "b", "c"] {
464            cache.insert(k.to_string(), CachedPlan::new(make_test_plan()));
465        }
466        // Touch a and c, leaving b as the least-recently-used entry.
467        assert!(cache.get("a").is_some());
468        assert!(cache.get("c").is_some());
469
470        // Inserting a fourth entry at capacity evicts b.
471        cache.insert("d".to_string(), CachedPlan::new(make_test_plan()));
472
473        assert!(
474            cache.peek("b").is_none(),
475            "b was least-recently-used and must be the eviction victim"
476        );
477        assert!(cache.peek("a").is_some());
478        assert!(cache.peek("c").is_some());
479        assert!(cache.peek("d").is_some());
480    }
481
482    #[test]
483    fn active_entry_forces_replan_after_large_regression() {
484        let mut cache = PlanCache::new(10);
485        cache.insert("q1".to_string(), CachedPlan::new(make_test_plan()));
486
487        cache.record_observation("q1", 10);
488        cache.record_observation("q1", 10);
489        cache.record_observation("q1", 500);
490
491        assert!(cache.get("q1").is_none());
492    }
493}