Skip to main content

batuta/oracle/rag/
query_cache.rs

1//! Query Plan Cache
2//!
3//! LRU cache for query plans to speed up repeated queries.
4
5use std::collections::{HashMap, VecDeque};
6use std::time::{Duration, Instant};
7
8/// Query plan cache
9pub struct QueryPlanCache {
10    /// Cache mapping query hash to plan
11    cache: HashMap<u64, CachedPlan>,
12    /// LRU order tracking (front = most recently used)
13    order: VecDeque<u64>,
14    /// Maximum capacity
15    capacity: usize,
16    /// Cache hit counter
17    hits: u64,
18    /// Cache miss counter
19    misses: u64,
20    /// TTL for cache entries
21    ttl: Duration,
22}
23
24/// A cached query plan
25#[derive(Debug, Clone)]
26pub struct CachedPlan {
27    /// Tokenized query terms
28    pub terms: Vec<String>,
29    /// Term weights
30    pub term_weights: Vec<f32>,
31    /// Candidate document IDs (pre-filtered)
32    pub candidate_docs: Vec<u32>,
33    /// Component boosts detected
34    pub component_boosts: Vec<(String, f32)>,
35    /// When this plan was created
36    pub created_at: Instant,
37}
38
39impl QueryPlanCache {
40    /// Create a new cache with given capacity
41    pub fn new(capacity: usize) -> Self {
42        let cap = if capacity == 0 { 1000 } else { capacity };
43        Self {
44            cache: HashMap::with_capacity(cap),
45            order: VecDeque::with_capacity(cap),
46            capacity: cap,
47            hits: 0,
48            misses: 0,
49            ttl: Duration::from_secs(300), // 5 minutes default
50        }
51    }
52
53    /// Create with custom TTL
54    pub fn with_ttl(capacity: usize, ttl: Duration) -> Self {
55        let mut cache = Self::new(capacity);
56        cache.ttl = ttl;
57        cache
58    }
59
60    /// Hash a query string
61    fn hash_query(&self, query: &str) -> u64 {
62        use std::collections::hash_map::DefaultHasher;
63        use std::hash::{Hash, Hasher};
64
65        let mut hasher = DefaultHasher::new();
66        query.to_lowercase().hash(&mut hasher);
67        hasher.finish()
68    }
69
70    /// Move a key to the front of the LRU order
71    fn touch(&mut self, hash: u64) {
72        // Remove from current position if exists
73        self.order.retain(|&h| h != hash);
74        // Add to front
75        self.order.push_front(hash);
76    }
77
78    /// Evict oldest entries if over capacity
79    fn evict_if_needed(&mut self) {
80        while self.order.len() > self.capacity {
81            if let Some(old_hash) = self.order.pop_back() {
82                self.cache.remove(&old_hash);
83            }
84        }
85    }
86
87    /// Get a cached plan
88    pub fn get(&mut self, query: &str) -> Option<&CachedPlan> {
89        let hash = self.hash_query(query);
90
91        if let Some(plan) = self.cache.get(&hash) {
92            // Check TTL
93            if plan.created_at.elapsed() < self.ttl {
94                self.hits += 1;
95                self.touch(hash);
96                // Re-borrow after touch
97                return self.cache.get(&hash);
98            }
99            // Expired - will be replaced on next put
100            self.misses += 1;
101            return None;
102        }
103
104        self.misses += 1;
105        None
106    }
107
108    /// Get a cloned plan (for modification)
109    pub fn get_clone(&mut self, query: &str) -> Option<CachedPlan> {
110        let hash = self.hash_query(query);
111
112        if let Some(plan) = self.cache.get(&hash) {
113            // Check TTL
114            if plan.created_at.elapsed() < self.ttl {
115                self.hits += 1;
116                self.touch(hash);
117                return self.cache.get(&hash).cloned();
118            }
119            self.misses += 1;
120            return None;
121        }
122
123        self.misses += 1;
124        None
125    }
126
127    /// Insert a plan
128    pub fn put(&mut self, query: &str, plan: CachedPlan) {
129        let hash = self.hash_query(query);
130        self.cache.insert(hash, plan);
131        self.touch(hash);
132        self.evict_if_needed();
133    }
134
135    /// Create and insert a new plan
136    pub fn create_plan(
137        &mut self,
138        query: &str,
139        terms: Vec<String>,
140        term_weights: Vec<f32>,
141        candidate_docs: Vec<u32>,
142        component_boosts: Vec<(String, f32)>,
143    ) -> &CachedPlan {
144        let plan = CachedPlan {
145            terms,
146            term_weights,
147            candidate_docs,
148            component_boosts,
149            created_at: crate::timing::start_timer(),
150        };
151
152        let hash = self.hash_query(query);
153        self.cache.insert(hash, plan);
154        self.touch(hash);
155        self.evict_if_needed();
156        // SAFETY: touch() moves key to front, evict removes from back
157        // so freshly inserted entry is never evicted
158        self.cache.get(&hash).expect("freshly inserted cache entry must exist after LRU eviction")
159    }
160
161    /// Clear the cache
162    pub fn clear(&mut self) {
163        self.cache.clear();
164        self.order.clear();
165    }
166
167    /// Get cache statistics
168    pub fn stats(&self) -> CacheStats {
169        let total = self.hits + self.misses;
170        CacheStats {
171            hits: self.hits,
172            misses: self.misses,
173            hit_rate: if total > 0 { self.hits as f64 / total as f64 } else { 0.0 },
174            size: self.cache.len(),
175            capacity: self.capacity,
176        }
177    }
178
179    /// Reset statistics
180    pub fn reset_stats(&mut self) {
181        self.hits = 0;
182        self.misses = 0;
183    }
184}
185
186impl Default for QueryPlanCache {
187    fn default() -> Self {
188        Self::new(1000)
189    }
190}
191
192/// Cache statistics
193#[derive(Debug, Clone, Copy)]
194pub struct CacheStats {
195    pub hits: u64,
196    pub misses: u64,
197    pub hit_rate: f64,
198    pub size: usize,
199    pub capacity: usize,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// Create a test plan without directly calling Instant::now in test code
207    fn test_plan(
208        terms: Vec<&str>,
209        weights: Vec<f32>,
210        docs: Vec<u32>,
211        boosts: Vec<(&str, f32)>,
212    ) -> CachedPlan {
213        CachedPlan {
214            terms: terms.into_iter().map(String::from).collect(),
215            term_weights: weights,
216            candidate_docs: docs,
217            component_boosts: boosts.into_iter().map(|(s, v)| (s.to_string(), v)).collect(),
218            created_at: crate::timing::start_timer(),
219        }
220    }
221
222    #[test]
223    fn test_cache_creation() {
224        let cache = QueryPlanCache::new(100);
225        assert_eq!(cache.stats().capacity, 100);
226    }
227
228    #[test]
229    fn test_cache_put_get() {
230        let mut cache = QueryPlanCache::new(100);
231
232        let plan =
233            test_plan(vec!["hello", "world"], vec![1.0, 1.0], vec![1, 2, 3], vec![("trueno", 1.5)]);
234
235        cache.put("hello world", plan);
236
237        let retrieved = cache.get("hello world");
238        assert!(retrieved.is_some());
239        assert_eq!(retrieved.expect("unexpected failure").terms.len(), 2);
240    }
241
242    #[test]
243    fn test_cache_hit_miss() {
244        let mut cache = QueryPlanCache::new(100);
245
246        // Miss
247        let _ = cache.get("query1");
248        assert_eq!(cache.stats().misses, 1);
249
250        // Put and hit
251        cache.create_plan("query1", vec![], vec![], vec![], vec![]);
252        let _ = cache.get("query1");
253        assert_eq!(cache.stats().hits, 1);
254    }
255
256    #[test]
257    fn test_cache_case_insensitive() {
258        let mut cache = QueryPlanCache::new(100);
259
260        cache.create_plan("Hello World", vec![], vec![], vec![], vec![]);
261
262        assert!(cache.get("hello world").is_some());
263        assert!(cache.get("HELLO WORLD").is_some());
264    }
265
266    #[test]
267    fn test_cache_ttl() {
268        let mut cache = QueryPlanCache::with_ttl(100, Duration::from_millis(1));
269
270        cache.create_plan("query", vec![], vec![], vec![], vec![]);
271
272        // Immediately should be valid
273        assert!(cache.get("query").is_some());
274
275        // After TTL should be invalid
276        std::thread::sleep(Duration::from_millis(10));
277        assert!(cache.get("query").is_none());
278    }
279
280    #[test]
281    fn test_cache_lru_eviction() {
282        let mut cache = QueryPlanCache::new(3);
283
284        // Fill cache
285        cache.create_plan("query1", vec![], vec![], vec![], vec![]);
286        cache.create_plan("query2", vec![], vec![], vec![], vec![]);
287        cache.create_plan("query3", vec![], vec![], vec![], vec![]);
288
289        assert_eq!(cache.stats().size, 3);
290
291        // Add one more, should evict oldest (query1)
292        cache.create_plan("query4", vec![], vec![], vec![], vec![]);
293
294        assert_eq!(cache.stats().size, 3);
295        assert!(cache.get_clone("query1").is_none()); // Should be evicted
296        assert!(cache.get_clone("query2").is_some());
297        assert!(cache.get_clone("query3").is_some());
298        assert!(cache.get_clone("query4").is_some());
299    }
300
301    #[test]
302    fn test_cache_lru_touch() {
303        let mut cache = QueryPlanCache::new(3);
304
305        // Fill cache
306        cache.create_plan("query1", vec![], vec![], vec![], vec![]);
307        cache.create_plan("query2", vec![], vec![], vec![], vec![]);
308        cache.create_plan("query3", vec![], vec![], vec![], vec![]);
309
310        // Touch query1, making it most recently used
311        let _ = cache.get("query1");
312
313        // Add new item, should evict query2 (now oldest)
314        cache.create_plan("query4", vec![], vec![], vec![], vec![]);
315
316        assert!(cache.get_clone("query1").is_some()); // Should still exist
317        assert!(cache.get_clone("query2").is_none()); // Should be evicted
318    }
319
320    #[test]
321    fn test_cache_clear() {
322        let mut cache = QueryPlanCache::new(100);
323        cache.create_plan("query1", vec![], vec![], vec![], vec![]);
324        cache.create_plan("query2", vec![], vec![], vec![], vec![]);
325
326        assert_eq!(cache.stats().size, 2);
327
328        cache.clear();
329
330        assert_eq!(cache.stats().size, 0);
331    }
332
333    #[test]
334    fn test_cache_stats_reset() {
335        let mut cache = QueryPlanCache::new(100);
336        cache.create_plan("query", vec![], vec![], vec![], vec![]);
337        let _ = cache.get("query"); // hit
338        let _ = cache.get("nonexistent"); // miss
339
340        let stats = cache.stats();
341        assert_eq!(stats.hits, 1);
342        assert_eq!(stats.misses, 1);
343
344        cache.reset_stats();
345
346        let stats = cache.stats();
347        assert_eq!(stats.hits, 0);
348        assert_eq!(stats.misses, 0);
349    }
350
351    #[test]
352    fn test_cache_hit_rate() {
353        let mut cache = QueryPlanCache::new(100);
354        cache.create_plan("query", vec![], vec![], vec![], vec![]);
355
356        // 2 hits
357        let _ = cache.get("query");
358        let _ = cache.get("query");
359        // 1 miss
360        let _ = cache.get("nonexistent");
361
362        let stats = cache.stats();
363        assert!((stats.hit_rate - 0.666).abs() < 0.01);
364    }
365
366    #[test]
367    fn test_cache_default() {
368        let cache = QueryPlanCache::default();
369        assert_eq!(cache.stats().capacity, 1000);
370    }
371
372    #[test]
373    fn test_cache_zero_capacity() {
374        // Zero capacity should default to 1000
375        let cache = QueryPlanCache::new(0);
376        assert_eq!(cache.stats().capacity, 1000);
377    }
378
379    #[test]
380    fn test_cached_plan_fields() {
381        let plan = test_plan(vec!["test"], vec![0.5], vec![1, 2, 3], vec![("boost", 1.2)]);
382        assert_eq!(plan.terms.len(), 1);
383        assert_eq!(plan.term_weights.len(), 1);
384        assert_eq!(plan.candidate_docs.len(), 3);
385        assert_eq!(plan.component_boosts.len(), 1);
386    }
387
388    #[test]
389    fn test_cache_stats_fields() {
390        let stats = CacheStats { hits: 10, misses: 5, hit_rate: 0.666, size: 100, capacity: 1000 };
391        assert_eq!(stats.hits, 10);
392        assert_eq!(stats.misses, 5);
393        assert_eq!(stats.size, 100);
394        assert_eq!(stats.capacity, 1000);
395    }
396
397    #[test]
398    fn test_get_clone_returns_owned() {
399        let mut cache = QueryPlanCache::new(100);
400        cache.create_plan("query", vec!["term".to_string()], vec![1.0], vec![1], vec![]);
401
402        let cloned = cache.get_clone("query");
403        assert!(cloned.is_some());
404        let plan = cloned.expect("unexpected failure");
405        assert_eq!(plan.terms, vec!["term".to_string()]);
406    }
407
408    #[test]
409    fn test_get_clone_miss() {
410        let mut cache = QueryPlanCache::new(100);
411        let result = cache.get_clone("nonexistent");
412        assert!(result.is_none());
413    }
414
415    #[test]
416    fn test_get_clone_expired() {
417        let mut cache = QueryPlanCache::with_ttl(100, Duration::from_millis(1));
418        cache.create_plan("query", vec![], vec![], vec![], vec![]);
419
420        std::thread::sleep(Duration::from_millis(10));
421        let result = cache.get_clone("query");
422        assert!(result.is_none());
423    }
424
425    #[test]
426    fn test_put_replaces_existing() {
427        let mut cache = QueryPlanCache::new(100);
428
429        cache.put("query", test_plan(vec!["old"], vec![], vec![], vec![]));
430        cache.put("query", test_plan(vec!["new"], vec![], vec![], vec![]));
431
432        let retrieved = cache.get("query").expect("key not found");
433        assert_eq!(retrieved.terms, vec!["new".to_string()]);
434    }
435
436    #[test]
437    fn test_hit_rate_no_accesses() {
438        let cache = QueryPlanCache::new(100);
439        let stats = cache.stats();
440        assert_eq!(stats.hit_rate, 0.0);
441    }
442
443    #[test]
444    fn test_create_plan_returns_reference() {
445        let mut cache = QueryPlanCache::new(100);
446        let plan = cache.create_plan(
447            "query",
448            vec!["term".to_string()],
449            vec![1.0, 2.0],
450            vec![1, 2, 3],
451            vec![("boost".to_string(), 1.5)],
452        );
453        assert_eq!(plan.terms.len(), 1);
454        assert_eq!(plan.term_weights.len(), 2);
455        assert_eq!(plan.candidate_docs.len(), 3);
456    }
457
458    #[test]
459    fn test_with_ttl_custom_duration() {
460        let cache = QueryPlanCache::with_ttl(50, Duration::from_secs(60));
461        assert_eq!(cache.stats().capacity, 50);
462    }
463
464    #[test]
465    fn test_cached_plan_clone() {
466        let plan = test_plan(vec!["a", "b"], vec![1.0, 2.0], vec![10, 20], vec![]);
467        let cloned = plan.clone();
468        assert_eq!(cloned.terms, plan.terms);
469        assert_eq!(cloned.candidate_docs, plan.candidate_docs);
470    }
471}