Skip to main content

grafeo_engine/query/
cache.rs

1//! Query cache for parsed and planned queries.
2//!
3//! This module provides an LRU cache for query plans to avoid repeated
4//! parsing and optimization of frequently executed queries.
5//!
6//! ## Cache Levels
7//!
8//! - **Parsed cache**: Caches logical plans after translation (language-specific parsing)
9//! - **Optimized cache**: Caches logical plans after optimization
10//!
11//! ## Usage
12//!
13//! ```no_run
14//! use grafeo_engine::query::cache::{QueryCache, CacheKey};
15//! use grafeo_engine::query::processor::QueryLanguage;
16//! use grafeo_engine::query::plan::{LogicalPlan, LogicalOperator};
17//!
18//! let cache = QueryCache::new(1000);
19//! let cache_key = CacheKey::new("MATCH (n) RETURN n", QueryLanguage::Gql);
20//!
21//! // Check cache first
22//! if let Some(plan) = cache.get_optimized(&cache_key) {
23//!     // use cached plan
24//! }
25//!
26//! // Parse and optimize, then cache
27//! let plan = LogicalPlan::new(LogicalOperator::Empty);
28//! cache.put_optimized(cache_key, plan);
29//! ```
30
31use parking_lot::Mutex;
32use std::collections::HashMap;
33use std::hash::Hash;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::time::Instant;
36
37use crate::query::plan::LogicalPlan;
38use crate::query::processor::QueryLanguage;
39
40/// Cache key combining query text and language.
41#[derive(Clone, Eq, PartialEq, Hash)]
42pub struct CacheKey {
43    /// The query string (normalized).
44    query: String,
45    /// The query language.
46    language: QueryLanguage,
47}
48
49impl CacheKey {
50    /// Creates a new cache key.
51    #[must_use]
52    pub fn new(query: impl Into<String>, language: QueryLanguage) -> Self {
53        Self {
54            query: normalize_query(&query.into()),
55            language,
56        }
57    }
58
59    /// Returns the query string.
60    #[must_use]
61    pub fn query(&self) -> &str {
62        &self.query
63    }
64
65    /// Returns the query language.
66    #[must_use]
67    pub fn language(&self) -> QueryLanguage {
68        self.language
69    }
70}
71
72/// Normalizes a query string for caching.
73///
74/// Removes extra whitespace and normalizes case for keywords.
75fn normalize_query(query: &str) -> String {
76    // Simple normalization: collapse whitespace
77    query.split_whitespace().collect::<Vec<_>>().join(" ")
78}
79
80/// Entry in the cache with metadata.
81struct CacheEntry<T> {
82    /// The cached value.
83    value: T,
84    /// Number of times this entry was accessed.
85    access_count: u64,
86    /// Last access time.
87    last_accessed: Instant,
88}
89
90impl<T: Clone> CacheEntry<T> {
91    fn new(value: T) -> Self {
92        Self {
93            value,
94            access_count: 0,
95            last_accessed: Instant::now(),
96        }
97    }
98
99    fn access(&mut self) -> T {
100        self.access_count += 1;
101        self.last_accessed = Instant::now();
102        self.value.clone()
103    }
104}
105
106/// LRU cache implementation.
107struct LruCache<K, V> {
108    /// The cache storage.
109    entries: HashMap<K, CacheEntry<V>>,
110    /// Maximum number of entries.
111    capacity: usize,
112    /// Order of access (for LRU eviction).
113    access_order: Vec<K>,
114}
115
116impl<K: Clone + Eq + Hash, V: Clone> LruCache<K, V> {
117    fn new(capacity: usize) -> Self {
118        Self {
119            entries: HashMap::with_capacity(capacity),
120            capacity,
121            access_order: Vec::with_capacity(capacity),
122        }
123    }
124
125    fn get(&mut self, key: &K) -> Option<V> {
126        if let Some(entry) = self.entries.get_mut(key) {
127            // Move to end of access order (most recently used)
128            if let Some(pos) = self.access_order.iter().position(|k| k == key) {
129                self.access_order.remove(pos);
130            }
131            self.access_order.push(key.clone());
132            Some(entry.access())
133        } else {
134            None
135        }
136    }
137
138    fn put(&mut self, key: K, value: V) {
139        // Evict if at capacity
140        if self.entries.len() >= self.capacity && !self.entries.contains_key(&key) {
141            self.evict_lru();
142        }
143
144        // Remove from current position in access order
145        if let Some(pos) = self.access_order.iter().position(|k| k == &key) {
146            self.access_order.remove(pos);
147        }
148
149        // Add to end (most recently used)
150        self.access_order.push(key.clone());
151        self.entries.insert(key, CacheEntry::new(value));
152    }
153
154    fn evict_lru(&mut self) {
155        if let Some(key) = self.access_order.first().cloned() {
156            self.access_order.remove(0);
157            self.entries.remove(&key);
158        }
159    }
160
161    fn clear(&mut self) {
162        self.entries.clear();
163        self.access_order.clear();
164    }
165
166    fn len(&self) -> usize {
167        self.entries.len()
168    }
169
170    fn remove(&mut self, key: &K) -> Option<V> {
171        if let Some(pos) = self.access_order.iter().position(|k| k == key) {
172            self.access_order.remove(pos);
173        }
174        self.entries.remove(key).map(|e| e.value)
175    }
176}
177
178/// Query cache for parsed and optimized plans.
179pub struct QueryCache {
180    /// Cache for parsed (translated) logical plans.
181    parsed_cache: Mutex<LruCache<CacheKey, LogicalPlan>>,
182    /// Cache for optimized logical plans.
183    optimized_cache: Mutex<LruCache<CacheKey, LogicalPlan>>,
184    /// Cache hit count for parsed plans.
185    parsed_hits: AtomicU64,
186    /// Cache miss count for parsed plans.
187    parsed_misses: AtomicU64,
188    /// Cache hit count for optimized plans.
189    optimized_hits: AtomicU64,
190    /// Cache miss count for optimized plans.
191    optimized_misses: AtomicU64,
192    /// Whether caching is enabled.
193    enabled: bool,
194}
195
196impl QueryCache {
197    /// Creates a new query cache with the specified capacity.
198    ///
199    /// The capacity is shared between parsed and optimized caches
200    /// (each gets half the capacity).
201    #[must_use]
202    pub fn new(capacity: usize) -> Self {
203        let half_capacity = capacity / 2;
204        Self {
205            parsed_cache: Mutex::new(LruCache::new(half_capacity.max(1))),
206            optimized_cache: Mutex::new(LruCache::new(half_capacity.max(1))),
207            parsed_hits: AtomicU64::new(0),
208            parsed_misses: AtomicU64::new(0),
209            optimized_hits: AtomicU64::new(0),
210            optimized_misses: AtomicU64::new(0),
211            enabled: true,
212        }
213    }
214
215    /// Creates a disabled cache (for testing or when caching is not desired).
216    #[must_use]
217    pub fn disabled() -> Self {
218        Self {
219            parsed_cache: Mutex::new(LruCache::new(0)),
220            optimized_cache: Mutex::new(LruCache::new(0)),
221            parsed_hits: AtomicU64::new(0),
222            parsed_misses: AtomicU64::new(0),
223            optimized_hits: AtomicU64::new(0),
224            optimized_misses: AtomicU64::new(0),
225            enabled: false,
226        }
227    }
228
229    /// Returns whether caching is enabled.
230    #[must_use]
231    pub fn is_enabled(&self) -> bool {
232        self.enabled
233    }
234
235    /// Gets a parsed plan from the cache.
236    pub fn get_parsed(&self, key: &CacheKey) -> Option<LogicalPlan> {
237        if !self.enabled {
238            return None;
239        }
240
241        let result = self.parsed_cache.lock().get(key);
242        if result.is_some() {
243            self.parsed_hits.fetch_add(1, Ordering::Relaxed);
244        } else {
245            self.parsed_misses.fetch_add(1, Ordering::Relaxed);
246        }
247        result
248    }
249
250    /// Puts a parsed plan into the cache.
251    pub fn put_parsed(&self, key: CacheKey, plan: LogicalPlan) {
252        if !self.enabled {
253            return;
254        }
255        self.parsed_cache.lock().put(key, plan);
256    }
257
258    /// Gets an optimized plan from the cache.
259    pub fn get_optimized(&self, key: &CacheKey) -> Option<LogicalPlan> {
260        if !self.enabled {
261            return None;
262        }
263
264        let result = self.optimized_cache.lock().get(key);
265        if result.is_some() {
266            self.optimized_hits.fetch_add(1, Ordering::Relaxed);
267        } else {
268            self.optimized_misses.fetch_add(1, Ordering::Relaxed);
269        }
270        result
271    }
272
273    /// Puts an optimized plan into the cache.
274    pub fn put_optimized(&self, key: CacheKey, plan: LogicalPlan) {
275        if !self.enabled {
276            return;
277        }
278        self.optimized_cache.lock().put(key, plan);
279    }
280
281    /// Invalidates a specific query from both caches.
282    pub fn invalidate(&self, key: &CacheKey) {
283        self.parsed_cache.lock().remove(key);
284        self.optimized_cache.lock().remove(key);
285    }
286
287    /// Clears all cached entries.
288    pub fn clear(&self) {
289        self.parsed_cache.lock().clear();
290        self.optimized_cache.lock().clear();
291    }
292
293    /// Returns cache statistics.
294    #[must_use]
295    pub fn stats(&self) -> CacheStats {
296        CacheStats {
297            parsed_size: self.parsed_cache.lock().len(),
298            optimized_size: self.optimized_cache.lock().len(),
299            parsed_hits: self.parsed_hits.load(Ordering::Relaxed),
300            parsed_misses: self.parsed_misses.load(Ordering::Relaxed),
301            optimized_hits: self.optimized_hits.load(Ordering::Relaxed),
302            optimized_misses: self.optimized_misses.load(Ordering::Relaxed),
303        }
304    }
305
306    /// Resets hit/miss counters.
307    pub fn reset_stats(&self) {
308        self.parsed_hits.store(0, Ordering::Relaxed);
309        self.parsed_misses.store(0, Ordering::Relaxed);
310        self.optimized_hits.store(0, Ordering::Relaxed);
311        self.optimized_misses.store(0, Ordering::Relaxed);
312    }
313}
314
315impl Default for QueryCache {
316    fn default() -> Self {
317        // Default capacity of 1000 queries
318        Self::new(1000)
319    }
320}
321
322/// Cache statistics.
323#[derive(Debug, Clone)]
324pub struct CacheStats {
325    /// Number of entries in parsed cache.
326    pub parsed_size: usize,
327    /// Number of entries in optimized cache.
328    pub optimized_size: usize,
329    /// Number of parsed cache hits.
330    pub parsed_hits: u64,
331    /// Number of parsed cache misses.
332    pub parsed_misses: u64,
333    /// Number of optimized cache hits.
334    pub optimized_hits: u64,
335    /// Number of optimized cache misses.
336    pub optimized_misses: u64,
337}
338
339impl CacheStats {
340    /// Returns the hit rate for parsed cache (0.0 to 1.0).
341    #[must_use]
342    pub fn parsed_hit_rate(&self) -> f64 {
343        let total = self.parsed_hits + self.parsed_misses;
344        if total == 0 {
345            0.0
346        } else {
347            self.parsed_hits as f64 / total as f64
348        }
349    }
350
351    /// Returns the hit rate for optimized cache (0.0 to 1.0).
352    #[must_use]
353    pub fn optimized_hit_rate(&self) -> f64 {
354        let total = self.optimized_hits + self.optimized_misses;
355        if total == 0 {
356            0.0
357        } else {
358            self.optimized_hits as f64 / total as f64
359        }
360    }
361
362    /// Returns the total cache size.
363    #[must_use]
364    pub fn total_size(&self) -> usize {
365        self.parsed_size + self.optimized_size
366    }
367
368    /// Returns the total hit rate.
369    #[must_use]
370    pub fn total_hit_rate(&self) -> f64 {
371        let total_hits = self.parsed_hits + self.optimized_hits;
372        let total_misses = self.parsed_misses + self.optimized_misses;
373        let total = total_hits + total_misses;
374        if total == 0 {
375            0.0
376        } else {
377            total_hits as f64 / total as f64
378        }
379    }
380}
381
382/// A caching wrapper for the query processor.
383///
384/// This type wraps a query processor and adds caching capabilities.
385/// Use this for production deployments where query caching is beneficial.
386pub struct CachingQueryProcessor<P> {
387    /// The underlying processor.
388    processor: P,
389    /// The query cache.
390    cache: QueryCache,
391}
392
393impl<P> CachingQueryProcessor<P> {
394    /// Creates a new caching processor.
395    pub fn new(processor: P, cache: QueryCache) -> Self {
396        Self { processor, cache }
397    }
398
399    /// Creates a new caching processor with default cache settings.
400    pub fn with_default_cache(processor: P) -> Self {
401        Self::new(processor, QueryCache::default())
402    }
403
404    /// Returns a reference to the cache.
405    #[must_use]
406    pub fn cache(&self) -> &QueryCache {
407        &self.cache
408    }
409
410    /// Returns a reference to the underlying processor.
411    #[must_use]
412    pub fn processor(&self) -> &P {
413        &self.processor
414    }
415
416    /// Returns cache statistics.
417    #[must_use]
418    pub fn stats(&self) -> CacheStats {
419        self.cache.stats()
420    }
421
422    /// Clears the cache.
423    pub fn clear_cache(&self) {
424        self.cache.clear();
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[cfg(feature = "gql")]
433    fn test_language() -> QueryLanguage {
434        QueryLanguage::Gql
435    }
436
437    #[cfg(not(feature = "gql"))]
438    fn test_language() -> QueryLanguage {
439        // Fallback for tests without gql feature
440        #[cfg(feature = "cypher")]
441        return QueryLanguage::Cypher;
442        #[cfg(feature = "sparql")]
443        return QueryLanguage::Sparql;
444    }
445
446    #[test]
447    fn test_cache_key_normalization() {
448        let key1 = CacheKey::new("MATCH  (n)  RETURN n", test_language());
449        let key2 = CacheKey::new("MATCH (n) RETURN n", test_language());
450
451        // Both should normalize to the same key
452        assert_eq!(key1.query(), key2.query());
453    }
454
455    #[test]
456    fn test_cache_basic_operations() {
457        let cache = QueryCache::new(10);
458        let key = CacheKey::new("MATCH (n) RETURN n", test_language());
459
460        // Create a simple logical plan for testing
461        use crate::query::plan::{LogicalOperator, LogicalPlan};
462        let plan = LogicalPlan::new(LogicalOperator::Empty);
463
464        // Initially empty
465        assert!(cache.get_parsed(&key).is_none());
466
467        // Put and get
468        cache.put_parsed(key.clone(), plan.clone());
469        assert!(cache.get_parsed(&key).is_some());
470
471        // Stats
472        let stats = cache.stats();
473        assert_eq!(stats.parsed_size, 1);
474        assert_eq!(stats.parsed_hits, 1);
475        assert_eq!(stats.parsed_misses, 1);
476    }
477
478    #[test]
479    fn test_cache_lru_eviction() {
480        let cache = QueryCache::new(4); // 2 entries per cache level
481
482        use crate::query::plan::{LogicalOperator, LogicalPlan};
483
484        // Add 3 entries to parsed cache (capacity is 2)
485        for i in 0..3 {
486            let key = CacheKey::new(format!("QUERY {}", i), test_language());
487            cache.put_parsed(key, LogicalPlan::new(LogicalOperator::Empty));
488        }
489
490        // First entry should be evicted
491        let key0 = CacheKey::new("QUERY 0", test_language());
492        assert!(cache.get_parsed(&key0).is_none());
493
494        // Entry 1 and 2 should still be present
495        let key1 = CacheKey::new("QUERY 1", test_language());
496        let key2 = CacheKey::new("QUERY 2", test_language());
497        assert!(cache.get_parsed(&key1).is_some());
498        assert!(cache.get_parsed(&key2).is_some());
499    }
500
501    #[test]
502    fn test_cache_invalidation() {
503        let cache = QueryCache::new(10);
504        let key = CacheKey::new("MATCH (n) RETURN n", test_language());
505
506        use crate::query::plan::{LogicalOperator, LogicalPlan};
507        let plan = LogicalPlan::new(LogicalOperator::Empty);
508
509        cache.put_parsed(key.clone(), plan.clone());
510        cache.put_optimized(key.clone(), plan);
511
512        assert!(cache.get_parsed(&key).is_some());
513        assert!(cache.get_optimized(&key).is_some());
514
515        // Invalidate
516        cache.invalidate(&key);
517
518        // Clear stats from previous gets
519        cache.reset_stats();
520
521        assert!(cache.get_parsed(&key).is_none());
522        assert!(cache.get_optimized(&key).is_none());
523    }
524
525    #[test]
526    fn test_cache_disabled() {
527        let cache = QueryCache::disabled();
528        let key = CacheKey::new("MATCH (n) RETURN n", test_language());
529
530        use crate::query::plan::{LogicalOperator, LogicalPlan};
531        let plan = LogicalPlan::new(LogicalOperator::Empty);
532
533        // Should not store anything
534        cache.put_parsed(key.clone(), plan);
535        assert!(cache.get_parsed(&key).is_none());
536
537        // Stats should be zero
538        let stats = cache.stats();
539        assert_eq!(stats.parsed_size, 0);
540    }
541
542    #[test]
543    fn test_cache_stats() {
544        let cache = QueryCache::new(10);
545
546        use crate::query::plan::{LogicalOperator, LogicalPlan};
547
548        let key1 = CacheKey::new("QUERY 1", test_language());
549        let key2 = CacheKey::new("QUERY 2", test_language());
550        let plan = LogicalPlan::new(LogicalOperator::Empty);
551
552        // Miss
553        cache.get_optimized(&key1);
554
555        // Put and hit
556        cache.put_optimized(key1.clone(), plan);
557        cache.get_optimized(&key1);
558        cache.get_optimized(&key1);
559
560        // Another miss
561        cache.get_optimized(&key2);
562
563        let stats = cache.stats();
564        assert_eq!(stats.optimized_hits, 2);
565        assert_eq!(stats.optimized_misses, 2);
566        assert!((stats.optimized_hit_rate() - 0.5).abs() < 0.01);
567    }
568}