Skip to main content

hawdb_plan_cache/
lib.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16use std::hash::Hash;
17
18mod template;
19
20pub use template::{
21    bind_physical_plan_parameters, parameter_scalar_types, parameterize_logical_plan,
22    parameterize_value_list, resolve_plan_template_value, ParameterizedLogicalPlan,
23    PlanParameterCacheKey,
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PlanCacheLookup {
28    Hit,
29    Miss,
30    Bypass(PlanCacheBypassReason),
31}
32
33impl PlanCacheLookup {
34    pub const fn as_str(self) -> &'static str {
35        match self {
36            Self::Hit => "hit",
37            Self::Miss => "miss",
38            Self::Bypass(_) => "bypass",
39        }
40    }
41
42    pub const fn bypass_reason(self) -> Option<PlanCacheBypassReason> {
43        match self {
44            Self::Bypass(reason) => Some(reason),
45            Self::Hit | Self::Miss => None,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PlanCacheBypassReason {
52    MutationPlanning,
53    OptimizerDirective,
54    StatementNotCacheable,
55}
56
57impl PlanCacheBypassReason {
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::MutationPlanning => "mutation_planning",
61            Self::OptimizerDirective => "optimizer_directive",
62            Self::StatementNotCacheable => "statement_not_cacheable",
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct PlanCacheStats {
69    pub max_entries: Option<usize>,
70    pub entries: usize,
71    pub hits: u64,
72    pub misses: u64,
73    pub admissions: u64,
74    pub disabled_misses: u64,
75    pub bypasses: u64,
76    pub evictions: u64,
77    pub memory_pressure_events: u64,
78}
79
80#[derive(Debug, Clone, PartialEq)]
81struct LfuCacheEntry<V> {
82    value: V,
83    frequency: u64,
84    last_access_tick: u64,
85}
86
87#[derive(Debug, Clone)]
88pub struct LfuCache<K, V> {
89    max_entries: Option<usize>,
90    entries: HashMap<K, LfuCacheEntry<V>>,
91    access_tick: u64,
92    hits: u64,
93    misses: u64,
94    admissions: u64,
95    disabled_misses: u64,
96    bypasses: u64,
97    evictions: u64,
98    memory_pressure_events: u64,
99}
100
101impl<K, V> LfuCache<K, V>
102where
103    K: Eq + Hash + Ord + Clone,
104    V: Clone,
105{
106    pub fn new(max_entries: Option<usize>) -> Self {
107        Self {
108            max_entries,
109            entries: HashMap::new(),
110            access_tick: 0,
111            hits: 0,
112            misses: 0,
113            admissions: 0,
114            disabled_misses: 0,
115            bypasses: 0,
116            evictions: 0,
117            memory_pressure_events: 0,
118        }
119    }
120
121    pub fn get(&mut self, key: &K) -> Option<V> {
122        if self.max_entries == Some(0) {
123            self.misses += 1;
124            self.disabled_misses += 1;
125            return None;
126        }
127        self.access_tick = self.access_tick.saturating_add(1);
128        let Some(entry) = self.entries.get_mut(key) else {
129            self.misses += 1;
130            return None;
131        };
132        self.hits += 1;
133        entry.frequency = entry.frequency.saturating_add(1);
134        entry.last_access_tick = self.access_tick;
135        Some(entry.value.clone())
136    }
137
138    pub fn record_bypass(&mut self) {
139        self.bypasses = self.bypasses.saturating_add(1);
140    }
141
142    pub fn insert(&mut self, key: K, value: V) {
143        let Some(max_entries) = self.max_entries else {
144            self.insert_entry(key, value);
145            self.admissions = self.admissions.saturating_add(1);
146            return;
147        };
148        if max_entries == 0 {
149            return;
150        }
151        let new_key = !self.entries.contains_key(&key);
152        if new_key && self.entries.len() >= max_entries {
153            self.memory_pressure_events = self.memory_pressure_events.saturating_add(1);
154        }
155        self.insert_entry(key, value);
156        self.admissions = self.admissions.saturating_add(1);
157        while self.entries.len() > max_entries {
158            let Some(evicted) = self.lfu_victim_key() else {
159                break;
160            };
161            if self.entries.remove(&evicted).is_some() {
162                self.evictions += 1;
163            }
164        }
165    }
166
167    fn insert_entry(&mut self, key: K, value: V) {
168        self.access_tick = self.access_tick.saturating_add(1);
169        if let Some(entry) = self.entries.get_mut(&key) {
170            entry.value = value;
171            entry.frequency = entry.frequency.saturating_add(1);
172            entry.last_access_tick = self.access_tick;
173            return;
174        }
175        self.entries.insert(
176            key,
177            LfuCacheEntry {
178                value,
179                frequency: 1,
180                last_access_tick: self.access_tick,
181            },
182        );
183    }
184
185    fn lfu_victim_key(&self) -> Option<K> {
186        self.entries
187            .iter()
188            .min_by(|(left_key, left), (right_key, right)| {
189                (left.frequency, left.last_access_tick, *left_key).cmp(&(
190                    right.frequency,
191                    right.last_access_tick,
192                    *right_key,
193                ))
194            })
195            .map(|(key, _)| key.clone())
196    }
197
198    pub fn stats(&self) -> PlanCacheStats {
199        PlanCacheStats {
200            max_entries: self.max_entries,
201            entries: self.entries.len(),
202            hits: self.hits,
203            misses: self.misses,
204            admissions: self.admissions,
205            disabled_misses: self.disabled_misses,
206            bypasses: self.bypasses,
207            evictions: self.evictions,
208            memory_pressure_events: self.memory_pressure_events,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::{LfuCache, PlanCacheBypassReason, PlanCacheLookup};
216    use std::sync::{Arc, Barrier, Mutex};
217    use std::thread;
218
219    #[test]
220    fn lfu_cache_evicts_least_frequently_used_entry() {
221        let mut cache = LfuCache::new(Some(2));
222        cache.insert("hot", 1);
223        cache.insert("cold", 2);
224
225        assert_eq!(cache.get(&"hot"), Some(1));
226        cache.insert("new", 3);
227
228        assert_eq!(cache.get(&"hot"), Some(1));
229        assert_eq!(cache.get(&"new"), Some(3));
230        assert_eq!(cache.get(&"cold"), None);
231        assert_eq!(cache.stats().admissions, 3);
232        assert_eq!(cache.stats().evictions, 1);
233        assert_eq!(cache.stats().memory_pressure_events, 1);
234    }
235
236    #[test]
237    fn lfu_cache_uses_oldest_access_as_tie_breaker() {
238        let mut cache = LfuCache::new(Some(2));
239        cache.insert("older", 1);
240        cache.insert("newer", 2);
241        cache.insert("third", 3);
242
243        assert_eq!(cache.get(&"older"), None);
244        assert_eq!(cache.get(&"newer"), Some(2));
245        assert_eq!(cache.get(&"third"), Some(3));
246    }
247
248    #[test]
249    fn zero_capacity_cache_records_misses_without_entries() {
250        let mut cache = LfuCache::new(Some(0));
251        cache.insert("ignored", 1);
252
253        assert_eq!(cache.get(&"ignored"), None);
254        assert_eq!(cache.stats().entries, 0);
255        assert_eq!(cache.stats().misses, 1);
256        assert_eq!(cache.stats().disabled_misses, 1);
257        assert_eq!(cache.stats().admissions, 0);
258        assert_eq!(cache.stats().memory_pressure_events, 0);
259    }
260
261    #[test]
262    fn lookup_reports_cacheability_and_bypass_reason() {
263        let bypass = PlanCacheLookup::Bypass(PlanCacheBypassReason::OptimizerDirective);
264
265        assert_eq!(PlanCacheLookup::Hit.as_str(), "hit");
266        assert_eq!(bypass.as_str(), "bypass");
267        assert_eq!(
268            bypass.bypass_reason(),
269            Some(PlanCacheBypassReason::OptimizerDirective)
270        );
271        assert_eq!(
272            PlanCacheBypassReason::StatementNotCacheable.as_str(),
273            "statement_not_cacheable"
274        );
275    }
276
277    #[test]
278    fn hash_lookup_updates_lfu_accounting_once() {
279        let mut cache = LfuCache::new(Some(2));
280        cache.insert("query-a", 1);
281        cache.insert("query-b", 2);
282
283        assert_eq!(cache.get(&"query-b"), Some(2));
284        assert_eq!(cache.get(&"query-c"), None);
285        assert_eq!(cache.stats().hits, 1);
286        assert_eq!(cache.stats().misses, 1);
287    }
288
289    #[test]
290    fn duplicate_hash_keys_update_one_entry() {
291        let mut cache = LfuCache::new(Some(2));
292        cache.insert("query", 1);
293        cache.insert("query", 2);
294
295        assert_eq!(cache.stats().entries, 1);
296        assert_eq!(cache.get(&"query"), Some(2));
297        assert_eq!(cache.stats().entries, 1);
298    }
299
300    #[test]
301    fn cache_records_bypassed_plans_separately_from_misses() {
302        let mut cache: LfuCache<&str, i32> = LfuCache::new(Some(2));
303
304        cache.record_bypass();
305
306        assert_eq!(cache.stats().bypasses, 1);
307        assert_eq!(cache.stats().misses, 0);
308        assert_eq!(cache.stats().hits, 0);
309        assert_eq!(cache.stats().admissions, 0);
310    }
311
312    #[test]
313    fn unbounded_cache_records_admissions_without_pressure() {
314        let mut cache = LfuCache::new(None);
315
316        cache.insert("first", 1);
317        cache.insert("second", 2);
318
319        assert_eq!(cache.stats().entries, 2);
320        assert_eq!(cache.stats().admissions, 2);
321        assert_eq!(cache.stats().memory_pressure_events, 0);
322        assert_eq!(cache.stats().evictions, 0);
323    }
324
325    #[test]
326    fn cache_remains_bounded_under_mutex_protected_concurrent_access() {
327        let cache = Arc::new(Mutex::new(LfuCache::new(Some(8))));
328        let start = Arc::new(Barrier::new(4));
329
330        let handles = (0..4)
331            .map(|worker| {
332                let cache = Arc::clone(&cache);
333                let start = Arc::clone(&start);
334                thread::spawn(move || {
335                    start.wait();
336                    for offset in 0..64 {
337                        let key = format!("key-{}", (worker + offset) % 16);
338                        let mut cache = cache.lock().unwrap();
339                        cache.insert(key.clone(), worker * 100 + offset);
340                        let _ = cache.get(&key);
341                    }
342                })
343            })
344            .collect::<Vec<_>>();
345
346        for handle in handles {
347            handle.join().unwrap();
348        }
349
350        let stats = cache.lock().unwrap().stats();
351        assert_eq!(stats.entries, 8);
352        assert_eq!(stats.admissions, 256);
353        assert!(stats.hits > 0);
354        assert!(stats.evictions > 0);
355        assert!(stats.memory_pressure_events > 0);
356    }
357}
358
359#[cfg(all(test, feature = "loom-tests"))]
360mod loom_tests {
361    use super::LfuCache;
362    use loom::sync::{Arc, Mutex};
363    use loom::thread;
364
365    #[test]
366    fn lfu_cache_preserves_bounds_under_modeled_concurrent_access() {
367        loom::model(|| {
368            let cache = Arc::new(Mutex::new(LfuCache::new(Some(2))));
369
370            let writer = {
371                let cache = Arc::clone(&cache);
372                thread::spawn(move || {
373                    let mut cache = cache.lock().unwrap();
374                    cache.insert("first", 1);
375                    cache.insert("second", 2);
376                })
377            };
378
379            let reader_writer = {
380                let cache = Arc::clone(&cache);
381                thread::spawn(move || {
382                    let mut cache = cache.lock().unwrap();
383                    let _ = cache.get(&"first");
384                    cache.insert("third", 3);
385                })
386            };
387
388            writer.join().unwrap();
389            reader_writer.join().unwrap();
390
391            let stats = cache.lock().unwrap().stats();
392            assert!(stats.entries <= 2);
393            assert!(stats.admissions <= 3);
394            assert!(stats.evictions <= 1);
395            assert_eq!(stats.max_entries, Some(2));
396        });
397    }
398}