Skip to main content

akar_main/connection/
plan_cache.rs

1//! PlanCache — LRU cache of optimized query plans keyed by normalized query.
2//!
3//! Repeated calls to `Connection::query()` with the same statement currently
4//! re-run the full parse → bind → plan → optimize pipeline. Caching the
5//! optimized plan skips all four steps on a cache hit.
6//!
7//! Plans are invalidated implicitly: every entry records the catalog version
8//! at build time, and lookups discard entries whose version no longer matches
9//! the live catalog (any DDL bumps the version).
10
11use akar_binder::bound_statement::BoundStatement;
12use akar_planner::logical_operator::LogicalOperator;
13use std::collections::{HashMap, VecDeque};
14use std::sync::Arc;
15
16/// A cached entry: the bound statement plus the optimized logical plan, both
17/// tied to the catalog version they were built against.
18///
19/// The plan and bound statement are stored behind `Arc` so a cache hit only
20/// bumps a reference count instead of deep-cloning the full operator tree on
21/// every query (P51.47).
22pub(crate) struct CachedPlan {
23    pub bound: Arc<BoundStatement>,
24    pub plan: Arc<Vec<LogicalOperator>>,
25    pub catalog_version: u64,
26}
27
28/// A small LRU cache. `get`/`insert` move the key to the most-recently-used
29/// end; when the cache is full, the least-recently-used entry is evicted.
30pub(crate) struct PlanCache<T> {
31    map: HashMap<String, T>,
32    order: VecDeque<String>,
33    capacity: usize,
34}
35
36impl<T> PlanCache<T> {
37    pub fn new(capacity: usize) -> Self {
38        Self {
39            map: HashMap::new(),
40            order: VecDeque::new(),
41            capacity: capacity.max(1),
42        }
43    }
44
45    pub fn get(&mut self, key: &str) -> Option<&T> {
46        if self.map.contains_key(key) {
47            if let Some(pos) = self.order.iter().position(|k| k == key) {
48                if pos + 1 != self.order.len() {
49                    self.order.remove(pos);
50                    self.order.push_back(key.to_string());
51                }
52            }
53        }
54        self.map.get(key)
55    }
56
57    pub fn insert(&mut self, key: String, value: T) {
58        if let Some(pos) = self.order.iter().position(|k| k.as_str() == key.as_str()) {
59            self.order.remove(pos);
60        } else if self.map.len() >= self.capacity {
61            if let Some(oldest) = self.order.pop_front() {
62                self.map.remove(&oldest);
63            }
64        }
65        self.order.push_back(key.clone());
66        self.map.insert(key, value);
67    }
68
69    pub fn clear(&mut self) {
70        self.map.clear();
71        self.order.clear();
72    }
73
74    pub fn len(&self) -> usize {
75        self.map.len()
76    }
77
78    /// Maximum number of entries before least-recently-used eviction.
79    #[allow(dead_code)]
80    pub fn capacity(&self) -> usize {
81        self.capacity
82    }
83}
84
85/// Normalize a query string into a stable cache key: trim surrounding
86/// whitespace and collapse horizontal whitespace runs into a single space.
87///
88/// Content inside single-quoted strings, double-quoted strings, and
89/// backtick-quoted identifiers is preserved verbatim, and newlines are kept,
90/// so normalization never changes a query's semantics (e.g. inside string
91/// literals or line comments).
92pub fn normalize_query(query: &str) -> String {
93    let trimmed = query.trim();
94    let mut result = String::with_capacity(trimmed.len());
95    let mut prev_space = true;
96    let mut in_single = false;
97    let mut in_double = false;
98    let mut in_backtick = false;
99
100    for ch in trimmed.chars() {
101        if in_single {
102            result.push(ch);
103            if ch == '\'' {
104                in_single = false;
105            }
106            prev_space = false;
107            continue;
108        }
109        if in_double {
110            result.push(ch);
111            if ch == '"' {
112                in_double = false;
113            }
114            prev_space = false;
115            continue;
116        }
117        if in_backtick {
118            result.push(ch);
119            if ch == '`' {
120                in_backtick = false;
121            }
122            prev_space = false;
123            continue;
124        }
125
126        match ch {
127            '\'' => {
128                in_single = true;
129                result.push(ch);
130                prev_space = false;
131            }
132            '"' => {
133                in_double = true;
134                result.push(ch);
135                prev_space = false;
136            }
137            '`' => {
138                in_backtick = true;
139                result.push(ch);
140                prev_space = false;
141            }
142            '\n' | '\r' => {
143                // Preserve newlines (line comments depend on them)
144                result.push('\n');
145                prev_space = false;
146            }
147            c if c.is_whitespace() => {
148                if !prev_space {
149                    result.push(' ');
150                }
151                prev_space = true;
152            }
153            other => {
154                result.push(other);
155                prev_space = false;
156            }
157        }
158    }
159    result
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_normalize_query_trims_and_collapses() {
168        assert_eq!(normalize_query("  MATCH (p)   RETURN  p  "), "MATCH (p) RETURN p");
169        assert_eq!(
170            normalize_query("MATCH\n  (p)\tWHERE p.x  >  5"),
171            "MATCH\n (p) WHERE p.x > 5"
172        );
173        assert_eq!(normalize_query(""), "");
174        assert_eq!(normalize_query("   "), "");
175    }
176
177    #[test]
178    fn test_normalize_preserves_string_literals() {
179        assert_eq!(normalize_query("RETURN 'a  b'   AS x"), "RETURN 'a  b' AS x");
180        assert_eq!(normalize_query("RETURN \"a  b\"   AS x"), "RETURN \"a  b\" AS x");
181        assert_eq!(
182            normalize_query("MATCH (`a  b`) RETURN `c d`"),
183            "MATCH (`a  b`) RETURN `c d`"
184        );
185    }
186
187    #[test]
188    fn test_normalize_keeps_newlines_for_comments() {
189        assert_eq!(normalize_query("MATCH (p) // c\nRETURN p"), "MATCH (p) // c\nRETURN p");
190    }
191
192    #[test]
193    fn test_lru_eviction() {
194        let mut cache: PlanCache<u32> = PlanCache::new(2);
195        cache.insert("a".into(), 1);
196        cache.insert("b".into(), 2);
197        cache.insert("c".into(), 3);
198        assert_eq!(cache.len(), 2);
199        assert!(cache.get("a").is_none());
200        assert_eq!(cache.get("b"), Some(&2));
201        assert_eq!(cache.get("c"), Some(&3));
202    }
203
204    #[test]
205    fn test_lru_touch_moves_to_back() {
206        let mut cache: PlanCache<u32> = PlanCache::new(2);
207        cache.insert("a".into(), 1);
208        cache.insert("b".into(), 2);
209        // Access "a" → it becomes most-recently-used; "b" evicted next
210        assert_eq!(cache.get("a"), Some(&1));
211        cache.insert("c".into(), 3);
212        assert!(cache.get("b").is_none());
213        assert_eq!(cache.get("a"), Some(&1));
214        assert_eq!(cache.get("c"), Some(&3));
215    }
216
217    #[test]
218    fn test_insert_existing_refreshes() {
219        let mut cache: PlanCache<u32> = PlanCache::new(2);
220        cache.insert("a".into(), 1);
221        cache.insert("b".into(), 2);
222        cache.insert("a".into(), 10);
223        assert_eq!(cache.get("a"), Some(&10));
224        assert_eq!(cache.get("b"), Some(&2));
225    }
226
227    #[test]
228    fn test_clear() {
229        let mut cache: PlanCache<u32> = PlanCache::new(4);
230        cache.insert("a".into(), 1);
231        cache.insert("b".into(), 2);
232        cache.clear();
233        assert_eq!(cache.len(), 0);
234        assert!(cache.get("a").is_none());
235    }
236
237    #[test]
238    fn test_capacity_min_one() {
239        let cache: PlanCache<u32> = PlanCache::new(0);
240        assert_eq!(cache.capacity(), 1);
241    }
242}