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