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}