Skip to main content

oxisql_parse/
plan_cache.rs

1//! Parameterized-plan cache: an LRU cache keyed by the parameterized SQL
2//! template and a schema-generation counter.
3//!
4//! Two SQL strings that differ only in their literal values (e.g. `WHERE id = 1`
5//! vs `WHERE id = 2`) map to the same template (`WHERE id = ?`) and therefore
6//! share a single cache entry.  The generation counter allows all existing
7//! entries to be cheaply invalidated whenever the schema changes.
8
9use std::num::NonZeroUsize;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12
13use lru::LruCache;
14use oxisql_core::OxiSqlError;
15
16use crate::decorrelate::PlannerOptions;
17use crate::parameterize::parameterize;
18use crate::{optimizer, parse_one, plan_statement_with_opts, LogicalPlan};
19
20// ── Cache key ─────────────────────────────────────────────────────────────────
21
22/// The key used to look up a plan in the cache.
23///
24/// Two queries that differ only in literal values share the same `template`
25/// (produced by [`parameterize`]).  The `generation` field is the value of
26/// the schema-generation counter at the time the entry was stored; entries
27/// from an older generation are never returned.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29struct PlanCacheKey {
30    template: String,
31    generation: u64,
32}
33
34// ── PlanCache ─────────────────────────────────────────────────────────────────
35
36/// Thread-safe LRU cache that maps parameterized SQL templates to
37/// [`Arc<LogicalPlan>`] values.
38///
39/// # Schema invalidation
40///
41/// Call [`PlanCache::invalidate_schema`] whenever the schema changes (e.g. a
42/// table is created or dropped).  This increments an internal generation
43/// counter; all existing cache entries are associated with the previous
44/// generation and will never be returned again, making them eligible for
45/// eviction.
46///
47/// # Example
48///
49/// ```rust,no_run
50/// use oxisql_parse::PlanCache;
51///
52/// let cache = PlanCache::new(64);
53/// let plan1 = cache.plan("SELECT * FROM t WHERE id = 1").unwrap();
54/// let plan2 = cache.plan("SELECT * FROM t WHERE id = 2").unwrap();
55/// // Both queries parameterize to the same template, so only one cache entry
56/// // was created.
57/// assert_eq!(cache.len(), 1);
58/// ```
59pub struct PlanCache {
60    inner: Mutex<LruCache<PlanCacheKey, Arc<LogicalPlan>>>,
61    /// Monotonically increasing counter; invalidated entries belong to older
62    /// generations.
63    generation: AtomicU64,
64}
65
66impl PlanCache {
67    /// Create a new cache with the given LRU capacity.
68    ///
69    /// If `capacity` is 0 it is silently promoted to 1 so the cache is always
70    /// functional.
71    pub fn new(capacity: usize) -> Self {
72        let cap = NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN);
73        Self {
74            inner: Mutex::new(LruCache::new(cap)),
75            generation: AtomicU64::new(0),
76        }
77    }
78
79    /// Invalidate all existing cache entries by incrementing the schema
80    /// generation counter.
81    ///
82    /// Existing entries are not removed immediately; they simply belong to the
83    /// previous generation and will never be returned, so they age out of the
84    /// LRU naturally.
85    pub fn invalidate_schema(&self) {
86        self.generation.fetch_add(1, Ordering::Release);
87    }
88
89    /// Parse, plan, optimize and cache `sql` using the default
90    /// [`crate::PlannerOptions`] (decorrelation enabled).
91    ///
92    /// Returns a shared [`Arc<LogicalPlan>`] that may be cloned cheaply for
93    /// concurrent readers.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`OxiSqlError`] if parsing or planning fails.
98    pub fn plan(&self, sql: &str) -> Result<Arc<LogicalPlan>, OxiSqlError> {
99        self.plan_with(sql, &PlannerOptions::default())
100    }
101
102    /// Parse, plan, optimize and cache `sql` using the supplied
103    /// [`crate::PlannerOptions`].
104    ///
105    /// Two calls with the same `sql` structure but different literal values
106    /// will map to the same cache key and share one entry.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`OxiSqlError`] if parsing or planning fails.
111    pub fn plan_with(
112        &self,
113        sql: &str,
114        opts: &PlannerOptions,
115    ) -> Result<Arc<LogicalPlan>, OxiSqlError> {
116        let template = parameterize(sql).template;
117        let generation = self.generation.load(Ordering::Acquire);
118        let key = PlanCacheKey {
119            template,
120            generation,
121        };
122
123        // Cache lookup (short critical section).
124        {
125            let mut cache = self
126                .inner
127                .lock()
128                .unwrap_or_else(|poisoned| poisoned.into_inner());
129            if let Some(plan) = cache.get(&key) {
130                return Ok(Arc::clone(plan));
131            }
132        }
133
134        // Cache miss: parse + plan + optimize (outside the lock).
135        let stmt = parse_one(sql)?;
136        let raw = plan_statement_with_opts(&stmt, opts)?;
137        let optimized = Arc::new(optimizer::optimize(raw));
138
139        // Store and return.
140        {
141            let mut cache = self
142                .inner
143                .lock()
144                .unwrap_or_else(|poisoned| poisoned.into_inner());
145            // Another thread may have raced us; prefer the cached version if so.
146            let entry = cache.get_or_insert(key, || Arc::clone(&optimized));
147            Ok(Arc::clone(entry))
148        }
149    }
150
151    /// Return the number of currently cached entries.
152    pub fn len(&self) -> usize {
153        self.inner
154            .lock()
155            .unwrap_or_else(|poisoned| poisoned.into_inner())
156            .len()
157    }
158
159    /// Return `true` if the cache holds no entries.
160    pub fn is_empty(&self) -> bool {
161        self.len() == 0
162    }
163
164    /// Evict all cached entries.
165    pub fn clear(&self) {
166        self.inner
167            .lock()
168            .unwrap_or_else(|poisoned| poisoned.into_inner())
169            .clear();
170    }
171}
172
173impl std::fmt::Debug for PlanCache {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        let len = self.len();
176        let gen = self.generation.load(Ordering::Relaxed);
177        write!(f, "PlanCache(len={len}, generation={gen})")
178    }
179}
180
181// ── Tests ─────────────────────────────────────────────────────────────────────
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    // Two queries differing only in literals share one cache entry.
188    #[test]
189    fn test_same_template_single_entry() {
190        let cache = PlanCache::new(16);
191        cache.plan("SELECT * FROM t WHERE id = 1").unwrap();
192        cache.plan("SELECT * FROM t WHERE id = 2").unwrap();
193        assert_eq!(
194            cache.len(),
195            1,
196            "both queries should map to the same template"
197        );
198    }
199
200    // Two structurally different queries → two entries.
201    #[test]
202    fn test_different_structure_two_entries() {
203        let cache = PlanCache::new(16);
204        cache.plan("SELECT * FROM t WHERE id = 1").unwrap();
205        cache.plan("SELECT * FROM t WHERE name = 'x'").unwrap();
206        // Both produce template "SELECT * FROM t WHERE ... = ?" but with different
207        // column names (id vs name), so the templates differ.
208        assert_eq!(cache.len(), 2);
209    }
210
211    // invalidate_schema causes a cache miss on the next call.
212    #[test]
213    fn test_invalidate_schema_causes_miss() {
214        let cache = PlanCache::new(16);
215        cache.plan("SELECT * FROM t WHERE id = 1").unwrap();
216        assert_eq!(cache.len(), 1);
217
218        cache.invalidate_schema();
219        cache.plan("SELECT * FROM t WHERE id = 1").unwrap();
220        // The new entry has a different generation, so there are now 2 entries.
221        assert_eq!(cache.len(), 2);
222    }
223
224    // Thread safety: 8 threads calling plan() concurrently.
225    #[test]
226    fn test_concurrent_access() {
227        use std::sync::Arc;
228        use std::thread;
229
230        let cache = Arc::new(PlanCache::new(16));
231        let sql = "SELECT * FROM t WHERE id = 42";
232
233        let handles: Vec<_> = (0..8)
234            .map(|_| {
235                let c = Arc::clone(&cache);
236                thread::spawn(move || c.plan(sql).expect("plan should succeed in thread"))
237            })
238            .collect();
239
240        for h in handles {
241            h.join().expect("thread should not panic");
242        }
243        // All 8 threads raced on the same template → exactly 1 entry.
244        assert_eq!(cache.len(), 1);
245    }
246
247    // Clear removes all entries.
248    #[test]
249    fn test_clear() {
250        let cache = PlanCache::new(16);
251        cache.plan("SELECT * FROM t WHERE id = 1").unwrap();
252        assert_eq!(cache.len(), 1);
253        cache.clear();
254        assert!(cache.is_empty());
255    }
256
257    // plan_with with decorrelate=false should still cache.
258    #[test]
259    fn test_plan_with_custom_opts() {
260        let cache = PlanCache::new(16);
261        let opts = PlannerOptions { decorrelate: false };
262        cache
263            .plan_with("SELECT * FROM t WHERE id = 99", &opts)
264            .unwrap();
265        assert_eq!(cache.len(), 1);
266    }
267}