Skip to main content

oxirs_tdb/
query_cache.rs

1//! Query result caching for improved performance
2//!
3//! This module provides a cache for SPARQL query results to reduce redundant
4//! query execution. The cache uses LRU eviction and supports cache invalidation
5//! when the underlying data changes.
6
7use crate::dictionary::Term;
8use crate::error::Result;
9use dashmap::DashMap;
10use std::collections::VecDeque;
11use std::hash::{Hash, Hasher};
12use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16/// Configuration for query result cache
17#[derive(Debug, Clone)]
18pub struct QueryCacheConfig {
19    /// Maximum number of cached query results
20    pub max_entries: usize,
21    /// Time-to-live for cache entries
22    pub ttl: Duration,
23    /// Whether to enable the cache
24    pub enabled: bool,
25    /// Maximum result size to cache (in triples)
26    pub max_result_size: usize,
27}
28
29impl Default for QueryCacheConfig {
30    fn default() -> Self {
31        Self {
32            max_entries: 1000,
33            ttl: Duration::from_secs(300), // 5 minutes
34            enabled: true,
35            max_result_size: 10000, // Don't cache huge result sets
36        }
37    }
38}
39
40/// Query pattern for caching (immutable key)
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct QueryPattern {
43    /// Subject (None = wildcard)
44    pub subject: Option<String>,
45    /// Predicate (None = wildcard)
46    pub predicate: Option<String>,
47    /// Object (None = wildcard)
48    pub object: Option<String>,
49}
50
51impl QueryPattern {
52    /// Create a new query pattern
53    pub fn new(subject: Option<&Term>, predicate: Option<&Term>, object: Option<&Term>) -> Self {
54        Self {
55            subject: subject.map(|t| format!("{:?}", t)),
56            predicate: predicate.map(|t| format!("{:?}", t)),
57            object: object.map(|t| format!("{:?}", t)),
58        }
59    }
60
61    /// Check if this pattern is cacheable
62    /// We avoid caching fully wildcarded queries (*, *, *) as they're too volatile
63    pub fn is_cacheable(&self) -> bool {
64        self.subject.is_some() || self.predicate.is_some() || self.object.is_some()
65    }
66}
67
68/// Cached query result
69#[derive(Debug, Clone)]
70struct CachedResult {
71    /// The query results
72    results: Vec<(Term, Term, Term)>,
73    /// When this entry was cached
74    cached_at: Instant,
75    /// Number of times this entry was accessed
76    access_count: u64,
77    /// Last access time
78    last_accessed: Instant,
79}
80
81impl CachedResult {
82    fn new(results: Vec<(Term, Term, Term)>) -> Self {
83        let now = Instant::now();
84        Self {
85            results,
86            cached_at: now,
87            access_count: 0,
88            last_accessed: now,
89        }
90    }
91
92    fn is_expired(&self, ttl: Duration) -> bool {
93        self.cached_at.elapsed() > ttl
94    }
95
96    fn access(&mut self) {
97        self.access_count += 1;
98        self.last_accessed = Instant::now();
99    }
100}
101
102/// LRU entry tracker
103#[derive(Debug, Clone)]
104struct LruEntry {
105    pattern: QueryPattern,
106    last_accessed: Instant,
107}
108
109/// Query result cache with LRU eviction
110pub struct QueryCache {
111    /// Configuration
112    config: QueryCacheConfig,
113    /// Cache storage (pattern -> results)
114    cache: Arc<DashMap<QueryPattern, CachedResult>>,
115    /// LRU tracking queue
116    lru_queue: parking_lot::Mutex<VecDeque<LruEntry>>,
117    /// Statistics
118    stats: QueryCacheStats,
119}
120
121/// Query cache statistics
122#[derive(Debug, Default)]
123pub struct QueryCacheStats {
124    /// Total cache hits
125    pub hits: AtomicU64,
126    /// Total cache misses
127    pub misses: AtomicU64,
128    /// Total evictions
129    pub evictions: AtomicU64,
130    /// Total invalidations
131    pub invalidations: AtomicU64,
132    /// Current cache size
133    pub current_size: AtomicUsize,
134}
135
136impl QueryCacheStats {
137    /// Calculate hit rate
138    pub fn hit_rate(&self) -> f64 {
139        let hits = self.hits.load(Ordering::Relaxed) as f64;
140        let misses = self.misses.load(Ordering::Relaxed) as f64;
141        let total = hits + misses;
142
143        if total == 0.0 {
144            0.0
145        } else {
146            hits / total
147        }
148    }
149
150    /// Get total requests
151    pub fn total_requests(&self) -> u64 {
152        self.hits.load(Ordering::Relaxed) + self.misses.load(Ordering::Relaxed)
153    }
154}
155
156impl QueryCache {
157    /// Create a new query cache
158    pub fn new(config: QueryCacheConfig) -> Self {
159        Self {
160            config,
161            cache: Arc::new(DashMap::new()),
162            lru_queue: parking_lot::Mutex::new(VecDeque::new()),
163            stats: QueryCacheStats::default(),
164        }
165    }
166
167    /// Get cached results for a query pattern
168    pub fn get(&self, pattern: &QueryPattern) -> Option<Vec<(Term, Term, Term)>> {
169        if !self.config.enabled || !pattern.is_cacheable() {
170            return None;
171        }
172
173        // Try to get from cache
174        if let Some(mut entry) = self.cache.get_mut(pattern) {
175            // Check if expired
176            if entry.is_expired(self.config.ttl) {
177                // Expired - remove it
178                drop(entry);
179                self.cache.remove(pattern);
180                self.stats.misses.fetch_add(1, Ordering::Relaxed);
181                self.stats.current_size.fetch_sub(1, Ordering::Relaxed);
182                return None;
183            }
184
185            // Valid cache hit
186            entry.access();
187            self.stats.hits.fetch_add(1, Ordering::Relaxed);
188
189            // Update LRU
190            self.update_lru(pattern);
191
192            return Some(entry.results.clone());
193        }
194
195        // Cache miss
196        self.stats.misses.fetch_add(1, Ordering::Relaxed);
197        None
198    }
199
200    /// Store results in cache
201    pub fn put(&self, pattern: QueryPattern, results: Vec<(Term, Term, Term)>) -> Result<()> {
202        if !self.config.enabled || !pattern.is_cacheable() {
203            return Ok(());
204        }
205
206        // Don't cache huge result sets
207        if results.len() > self.config.max_result_size {
208            return Ok(());
209        }
210
211        // Check if we need to evict
212        while self.cache.len() >= self.config.max_entries {
213            self.evict_lru()?;
214        }
215
216        // Insert into cache
217        let entry = CachedResult::new(results);
218        self.cache.insert(pattern.clone(), entry);
219        self.stats.current_size.fetch_add(1, Ordering::Relaxed);
220
221        // Update LRU
222        let mut lru = self.lru_queue.lock();
223        lru.push_back(LruEntry {
224            pattern,
225            last_accessed: Instant::now(),
226        });
227
228        Ok(())
229    }
230
231    /// Invalidate all cached results
232    ///
233    /// This should be called when the underlying data changes (inserts, deletes, updates)
234    pub fn invalidate_all(&self) {
235        let count = self.cache.len();
236        self.cache.clear();
237        self.lru_queue.lock().clear();
238
239        self.stats.current_size.store(0, Ordering::Relaxed);
240        self.stats
241            .invalidations
242            .fetch_add(count as u64, Ordering::Relaxed);
243    }
244
245    /// Invalidate cache entries matching a pattern
246    ///
247    /// This is more selective than invalidate_all() - only invalidates entries
248    /// that might be affected by changes to a specific triple pattern.
249    pub fn invalidate_pattern(
250        &self,
251        subject: Option<&str>,
252        predicate: Option<&str>,
253        object: Option<&str>,
254    ) {
255        let mut invalidated = 0;
256
257        // Remove all entries that overlap with the pattern
258        self.cache.retain(|pattern, _| {
259            let should_keep = !self.pattern_overlaps(pattern, subject, predicate, object);
260            if !should_keep {
261                invalidated += 1;
262            }
263            should_keep
264        });
265
266        // Update stats
267        self.stats
268            .current_size
269            .fetch_sub(invalidated, Ordering::Relaxed);
270        self.stats
271            .invalidations
272            .fetch_add(invalidated as u64, Ordering::Relaxed);
273
274        // Clean up LRU queue (remove invalidated entries)
275        let mut lru = self.lru_queue.lock();
276        lru.retain(|entry| self.cache.contains_key(&entry.pattern));
277    }
278
279    /// Check if a cached pattern overlaps with a triple pattern
280    fn pattern_overlaps(
281        &self,
282        cached: &QueryPattern,
283        subject: Option<&str>,
284        predicate: Option<&str>,
285        object: Option<&str>,
286    ) -> bool {
287        // If any position matches or either is a wildcard, they overlap
288        let s_overlaps = match (&cached.subject, subject) {
289            (Some(cs), Some(s)) => cs == s,
290            _ => true, // Wildcard always overlaps
291        };
292
293        let p_overlaps = match (&cached.predicate, predicate) {
294            (Some(cp), Some(p)) => cp == p,
295            _ => true,
296        };
297
298        let o_overlaps = match (&cached.object, object) {
299            (Some(co), Some(o)) => co == o,
300            _ => true,
301        };
302
303        s_overlaps && p_overlaps && o_overlaps
304    }
305
306    /// Evict least recently used entry
307    fn evict_lru(&self) -> Result<()> {
308        let mut lru = self.lru_queue.lock();
309
310        // Find the oldest entry
311        while let Some(entry) = lru.pop_front() {
312            // Try to remove it (might already be gone)
313            if self.cache.remove(&entry.pattern).is_some() {
314                self.stats.evictions.fetch_add(1, Ordering::Relaxed);
315                self.stats.current_size.fetch_sub(1, Ordering::Relaxed);
316                return Ok(());
317            }
318        }
319
320        Ok(())
321    }
322
323    /// Update LRU position for a pattern
324    fn update_lru(&self, pattern: &QueryPattern) {
325        let mut lru = self.lru_queue.lock();
326
327        // Find and update the entry
328        if let Some(pos) = lru.iter().position(|e| &e.pattern == pattern) {
329            if let Some(mut entry) = lru.remove(pos) {
330                entry.last_accessed = Instant::now();
331                lru.push_back(entry);
332            }
333        }
334    }
335
336    /// Get cache statistics
337    pub fn stats(&self) -> &QueryCacheStats {
338        &self.stats
339    }
340
341    /// Maximum number of cached query results this cache retains before
342    /// evicting (the configured capacity, e.g. from
343    /// [`StoreParams::query_cache_size`](crate::store::StoreParams)).
344    pub fn max_entries(&self) -> usize {
345        self.config.max_entries
346    }
347
348    /// Clear all cache entries and reset statistics
349    pub fn clear(&self) {
350        self.cache.clear();
351        self.lru_queue.lock().clear();
352        self.stats.current_size.store(0, Ordering::Relaxed);
353    }
354
355    /// Get current cache size
356    pub fn len(&self) -> usize {
357        self.cache.len()
358    }
359
360    /// Check if cache is empty
361    pub fn is_empty(&self) -> bool {
362        self.cache.is_empty()
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::dictionary::Term;
370
371    fn create_test_pattern(s: Option<&str>, p: Option<&str>, o: Option<&str>) -> QueryPattern {
372        QueryPattern {
373            subject: s.map(String::from),
374            predicate: p.map(String::from),
375            object: o.map(String::from),
376        }
377    }
378
379    fn create_test_results(count: usize) -> Vec<(Term, Term, Term)> {
380        (0..count)
381            .map(|i| {
382                (
383                    Term::Iri(format!("http://example.org/s{}", i)),
384                    Term::Iri("http://example.org/knows".to_string()),
385                    Term::Iri(format!("http://example.org/o{}", i)),
386                )
387            })
388            .collect()
389    }
390
391    #[test]
392    fn test_query_cache_creation() {
393        let config = QueryCacheConfig::default();
394        let cache = QueryCache::new(config);
395
396        assert_eq!(cache.len(), 0);
397        assert!(cache.is_empty());
398    }
399
400    #[test]
401    fn test_cache_put_and_get() {
402        let cache = QueryCache::new(QueryCacheConfig::default());
403        let pattern = create_test_pattern(Some("s1"), None, None);
404        let results = create_test_results(5);
405
406        cache.put(pattern.clone(), results.clone()).unwrap();
407
408        let cached = cache.get(&pattern);
409        assert!(cached.is_some());
410        assert_eq!(cached.unwrap().len(), 5);
411    }
412
413    #[test]
414    fn test_cache_miss() {
415        let cache = QueryCache::new(QueryCacheConfig::default());
416        let pattern = create_test_pattern(Some("s1"), None, None);
417
418        let cached = cache.get(&pattern);
419        assert!(cached.is_none());
420
421        let stats = cache.stats();
422        assert_eq!(stats.misses.load(Ordering::Relaxed), 1);
423    }
424
425    #[test]
426    fn test_cache_hit() {
427        let cache = QueryCache::new(QueryCacheConfig::default());
428        let pattern = create_test_pattern(Some("s1"), None, None);
429        let results = create_test_results(5);
430
431        cache.put(pattern.clone(), results).unwrap();
432        cache.get(&pattern);
433
434        let stats = cache.stats();
435        assert_eq!(stats.hits.load(Ordering::Relaxed), 1);
436        assert!(stats.hit_rate() > 0.0);
437    }
438
439    #[test]
440    fn test_cache_expiration() {
441        let config = QueryCacheConfig {
442            ttl: Duration::from_millis(10),
443            ..Default::default()
444        };
445        let cache = QueryCache::new(config);
446        let pattern = create_test_pattern(Some("s1"), None, None);
447        let results = create_test_results(5);
448
449        cache.put(pattern.clone(), results).unwrap();
450
451        // Wait for expiration
452        std::thread::sleep(Duration::from_millis(20));
453
454        let cached = cache.get(&pattern);
455        assert!(cached.is_none()); // Should be expired
456    }
457
458    #[test]
459    fn test_lru_eviction() {
460        let config = QueryCacheConfig {
461            max_entries: 3,
462            ..Default::default()
463        };
464        let cache = QueryCache::new(config);
465
466        // Fill cache to capacity
467        for i in 0..3 {
468            let pattern = create_test_pattern(Some(&format!("s{}", i)), None, None);
469            cache.put(pattern, create_test_results(1)).unwrap();
470        }
471
472        assert_eq!(cache.len(), 3);
473
474        // Add one more - should evict oldest
475        let pattern4 = create_test_pattern(Some("s4"), None, None);
476        cache.put(pattern4, create_test_results(1)).unwrap();
477
478        assert_eq!(cache.len(), 3); // Still at max
479
480        let stats = cache.stats();
481        assert_eq!(stats.evictions.load(Ordering::Relaxed), 1);
482    }
483
484    #[test]
485    fn test_invalidate_all() {
486        let cache = QueryCache::new(QueryCacheConfig::default());
487
488        for i in 0..5 {
489            let pattern = create_test_pattern(Some(&format!("s{}", i)), None, None);
490            cache.put(pattern, create_test_results(1)).unwrap();
491        }
492
493        assert_eq!(cache.len(), 5);
494
495        cache.invalidate_all();
496
497        assert_eq!(cache.len(), 0);
498        let stats = cache.stats();
499        assert_eq!(stats.invalidations.load(Ordering::Relaxed), 5);
500    }
501
502    #[test]
503    fn test_invalidate_pattern() {
504        let cache = QueryCache::new(QueryCacheConfig::default());
505
506        // Cache some patterns
507        cache
508            .put(
509                create_test_pattern(Some("s1"), Some("p1"), None),
510                create_test_results(1),
511            )
512            .unwrap();
513        cache
514            .put(
515                create_test_pattern(Some("s2"), Some("p1"), None),
516                create_test_results(1),
517            )
518            .unwrap();
519        cache
520            .put(
521                create_test_pattern(Some("s3"), Some("p2"), None),
522                create_test_results(1),
523            )
524            .unwrap();
525
526        assert_eq!(cache.len(), 3);
527
528        // Invalidate all patterns with p1
529        cache.invalidate_pattern(None, Some("p1"), None);
530
531        assert_eq!(cache.len(), 1); // Only s3/p2 should remain
532    }
533
534    #[test]
535    fn test_max_result_size() {
536        let config = QueryCacheConfig {
537            max_result_size: 10,
538            ..Default::default()
539        };
540        let cache = QueryCache::new(config);
541
542        let pattern = create_test_pattern(Some("s1"), None, None);
543        let large_results = create_test_results(100); // Exceeds max
544
545        cache.put(pattern.clone(), large_results).unwrap();
546
547        // Should not be cached due to size
548        assert_eq!(cache.len(), 0);
549    }
550
551    #[test]
552    fn test_pattern_is_cacheable() {
553        // Fully wildcarded - not cacheable
554        let pattern1 = create_test_pattern(None, None, None);
555        assert!(!pattern1.is_cacheable());
556
557        // Has at least one bound position - cacheable
558        let pattern2 = create_test_pattern(Some("s1"), None, None);
559        assert!(pattern2.is_cacheable());
560
561        let pattern3 = create_test_pattern(None, Some("p1"), None);
562        assert!(pattern3.is_cacheable());
563
564        let pattern4 = create_test_pattern(None, None, Some("o1"));
565        assert!(pattern4.is_cacheable());
566    }
567
568    #[test]
569    fn test_cache_disabled() {
570        let config = QueryCacheConfig {
571            enabled: false,
572            ..Default::default()
573        };
574        let cache = QueryCache::new(config);
575
576        let pattern = create_test_pattern(Some("s1"), None, None);
577        let results = create_test_results(5);
578
579        cache.put(pattern.clone(), results).unwrap();
580
581        // Should not be cached when disabled
582        assert_eq!(cache.len(), 0);
583
584        let cached = cache.get(&pattern);
585        assert!(cached.is_none());
586    }
587
588    #[test]
589    fn test_hit_rate_calculation() {
590        let cache = QueryCache::new(QueryCacheConfig::default());
591        let pattern = create_test_pattern(Some("s1"), None, None);
592        let results = create_test_results(5);
593
594        cache.put(pattern.clone(), results).unwrap();
595
596        // 3 hits
597        cache.get(&pattern);
598        cache.get(&pattern);
599        cache.get(&pattern);
600
601        // 2 misses
602        cache.get(&create_test_pattern(Some("s2"), None, None));
603        cache.get(&create_test_pattern(Some("s3"), None, None));
604
605        let stats = cache.stats();
606        assert_eq!(stats.total_requests(), 5);
607        assert_eq!(stats.hit_rate(), 0.6); // 3/5 = 0.6
608    }
609
610    #[test]
611    fn test_clear() {
612        let cache = QueryCache::new(QueryCacheConfig::default());
613
614        for i in 0..5 {
615            let pattern = create_test_pattern(Some(&format!("s{}", i)), None, None);
616            cache.put(pattern, create_test_results(1)).unwrap();
617        }
618
619        assert_eq!(cache.len(), 5);
620
621        cache.clear();
622
623        assert_eq!(cache.len(), 0);
624        assert_eq!(cache.stats().current_size.load(Ordering::Relaxed), 0);
625    }
626}