Skip to main content

sqlitegraph/
query_cache.rs

1//! High-level query cache layer for SQLiteGraph.
2//!
3//! This module provides deterministic, MVCC-aware caching for expensive graph traversal
4//! queries. The cache is transparent to callers and lives entirely inside the SQLiteGraph
5//! implementation without requiring any API changes.
6
7use std::collections::HashMap;
8use std::hash::{Hash, Hasher};
9use std::sync::{Arc, RwLock};
10
11use crate::backend::BackendDirection;
12
13/// Cache key for BFS queries
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BfsCacheKey {
16    pub start: i64,
17    pub depth: u32,
18}
19
20/// Cache key for k-hop queries
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct KHopCacheKey {
23    pub start: i64,
24    pub depth: u32,
25    pub direction: BackendDirection,
26}
27
28/// Cache key for filtered k-hop queries
29#[derive(Debug, Clone)]
30pub struct KHopFilteredCacheKey {
31    pub start: i64,
32    pub depth: u32,
33    pub direction: BackendDirection,
34    pub allowed_edge_types: Vec<String>,
35}
36
37/// Cache key for shortest path queries
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ShortestPathCacheKey {
40    pub start: i64,
41    pub end: i64,
42}
43
44/// Enumeration of all supported query cache keys
45#[derive(Debug, Clone)]
46pub enum QueryCacheKey {
47    Bfs(BfsCacheKey),
48    KHop(KHopCacheKey),
49    KHopFiltered(KHopFilteredCacheKey),
50    ShortestPath(ShortestPathCacheKey),
51}
52
53/// Cache entry containing query results
54#[derive(Debug, Clone)]
55pub struct QueryCacheEntry {
56    pub result: QueryResult,
57    // Note: In a production system, you might want to add timestamps, TTL, etc.
58}
59
60/// Enumeration of cached query results
61#[derive(Debug, Clone)]
62pub enum QueryResult {
63    Bfs(Vec<i64>),
64    KHop(Vec<i64>),
65    ShortestPath(Option<Vec<i64>>),
66}
67
68impl QueryCacheKey {
69    /// Create a deterministic hash for the cache key
70    pub fn hash(&self) -> u64 {
71        let mut hasher = ahash::AHasher::default();
72        match self {
73            QueryCacheKey::Bfs(key) => {
74                0u8.hash(&mut hasher);
75                key.start.hash(&mut hasher);
76                key.depth.hash(&mut hasher);
77            }
78            QueryCacheKey::KHop(key) => {
79                1u8.hash(&mut hasher);
80                key.start.hash(&mut hasher);
81                key.depth.hash(&mut hasher);
82                (match key.direction {
83                    BackendDirection::Outgoing => 0u8,
84                    BackendDirection::Incoming => 1u8,
85                })
86                .hash(&mut hasher);
87            }
88            QueryCacheKey::KHopFiltered(key) => {
89                2u8.hash(&mut hasher);
90                key.start.hash(&mut hasher);
91                key.depth.hash(&mut hasher);
92                (match key.direction {
93                    BackendDirection::Outgoing => 0u8,
94                    BackendDirection::Incoming => 1u8,
95                })
96                .hash(&mut hasher);
97                key.allowed_edge_types.len().hash(&mut hasher);
98                for edge_type in &key.allowed_edge_types {
99                    edge_type.hash(&mut hasher);
100                }
101            }
102            QueryCacheKey::ShortestPath(key) => {
103                3u8.hash(&mut hasher);
104                key.start.hash(&mut hasher);
105                key.end.hash(&mut hasher);
106            }
107        }
108        hasher.finish()
109    }
110}
111
112impl PartialEq for QueryCacheKey {
113    fn eq(&self, other: &Self) -> bool {
114        match (self, other) {
115            (QueryCacheKey::Bfs(a), QueryCacheKey::Bfs(b)) => a == b,
116            (QueryCacheKey::KHop(a), QueryCacheKey::KHop(b)) => a == b,
117            (QueryCacheKey::KHopFiltered(a), QueryCacheKey::KHopFiltered(b)) => {
118                a.start == b.start
119                    && a.depth == b.depth
120                    && a.direction == b.direction
121                    && a.allowed_edge_types == b.allowed_edge_types
122            }
123            (QueryCacheKey::ShortestPath(a), QueryCacheKey::ShortestPath(b)) => a == b,
124            _ => false,
125        }
126    }
127}
128
129impl Eq for QueryCacheKey {}
130
131impl Hash for QueryCacheKey {
132    fn hash<H: Hasher>(&self, state: &mut H) {
133        self.hash().hash(state);
134    }
135}
136
137impl PartialEq for KHopFilteredCacheKey {
138    fn eq(&self, other: &Self) -> bool {
139        self.start == other.start
140            && self.depth == other.depth
141            && self.direction == other.direction
142            && self.allowed_edge_types == other.allowed_edge_types
143    }
144}
145
146impl Eq for KHopFilteredCacheKey {}
147
148impl Hash for KHopFilteredCacheKey {
149    fn hash<H: Hasher>(&self, state: &mut H) {
150        self.start.hash(state);
151        self.depth.hash(state);
152        (match self.direction {
153            BackendDirection::Outgoing => 0u8,
154            BackendDirection::Incoming => 1u8,
155        })
156        .hash(state);
157        self.allowed_edge_types.len().hash(state);
158        for edge_type in &self.allowed_edge_types {
159            edge_type.hash(state);
160        }
161    }
162}
163
164/// Thread-safe query cache storage
165#[derive(Debug)]
166pub struct QueryCache {
167    cache: Arc<RwLock<HashMap<QueryCacheKey, QueryCacheEntry>>>,
168}
169
170impl QueryCache {
171    /// Create a new query cache
172    pub fn new() -> Self {
173        Self {
174            cache: Arc::new(RwLock::new(HashMap::new())),
175        }
176    }
177
178    /// Get a cached result for a BFS query
179    pub fn get_bfs(&self, start: i64, depth: u32) -> Option<Vec<i64>> {
180        let key = QueryCacheKey::Bfs(BfsCacheKey { start, depth });
181
182        // Handle potential RwLock poisoning gracefully
183        let cache = match self.cache.read() {
184            Ok(cache) => cache,
185            Err(poisoned) => {
186                // Log the poisoning error and treat as cache miss
187                eprintln!(
188                    "WARNING: Query cache read lock poisoned in get_bfs operation (start={}, depth={}). Treating as cache miss.",
189                    start, depth
190                );
191                // Return the inner HashMap from the poisoned lock
192                poisoned.into_inner()
193            }
194        };
195
196        cache.get(&key).and_then(|entry| match &entry.result {
197            QueryResult::Bfs(result) => Some(result.clone()),
198            _ => None,
199        })
200    }
201
202    /// Cache a BFS query result
203    pub fn put_bfs(&self, start: i64, depth: u32, result: Vec<i64>) {
204        let key = QueryCacheKey::Bfs(BfsCacheKey { start, depth });
205        let entry = QueryCacheEntry {
206            result: QueryResult::Bfs(result),
207        };
208
209        // Handle potential RwLock poisoning gracefully
210        match self.cache.write() {
211            Ok(mut cache) => {
212                cache.insert(key, entry);
213            }
214            Err(poisoned) => {
215                // Log the poisoning error and recover from poisoned lock
216                eprintln!(
217                    "WARNING: Query cache write lock poisoned in put_bfs operation (start={}, depth={}). Recovering and continuing.",
218                    start, depth
219                );
220                let mut cache = poisoned.into_inner();
221                cache.insert(key, entry);
222            }
223        }
224    }
225
226    /// Get a cached result for a k-hop query
227    pub fn get_k_hop(
228        &self,
229        start: i64,
230        depth: u32,
231        direction: BackendDirection,
232    ) -> Option<Vec<i64>> {
233        let key = QueryCacheKey::KHop(KHopCacheKey {
234            start,
235            depth,
236            direction,
237        });
238
239        // Handle potential RwLock poisoning gracefully
240        let cache = match self.cache.read() {
241            Ok(cache) => cache,
242            Err(poisoned) => {
243                // Log the poisoning error and treat as cache miss
244                eprintln!(
245                    "WARNING: Query cache read lock poisoned in get_k_hop operation (start={}, depth={}, direction={:?}). Treating as cache miss.",
246                    start, depth, direction
247                );
248                poisoned.into_inner()
249            }
250        };
251
252        cache.get(&key).and_then(|entry| match &entry.result {
253            QueryResult::KHop(result) => Some(result.clone()),
254            _ => None,
255        })
256    }
257
258    /// Cache a k-hop query result
259    pub fn put_k_hop(&self, start: i64, depth: u32, direction: BackendDirection, result: Vec<i64>) {
260        let key = QueryCacheKey::KHop(KHopCacheKey {
261            start,
262            depth,
263            direction,
264        });
265        let entry = QueryCacheEntry {
266            result: QueryResult::KHop(result),
267        };
268
269        // Handle potential RwLock poisoning gracefully
270        match self.cache.write() {
271            Ok(mut cache) => {
272                cache.insert(key, entry);
273            }
274            Err(poisoned) => {
275                // Log the poisoning error and recover from poisoned lock
276                eprintln!(
277                    "WARNING: Query cache write lock poisoned in put_k_hop operation (start={}, depth={}, direction={:?}). Recovering and continuing.",
278                    start, depth, direction
279                );
280                let mut cache = poisoned.into_inner();
281                cache.insert(key, entry);
282            }
283        }
284    }
285
286    /// Get a cached result for a filtered k-hop query
287    pub fn get_k_hop_filtered(
288        &self,
289        start: i64,
290        depth: u32,
291        direction: BackendDirection,
292        allowed_edge_types: &[&str],
293    ) -> Option<Vec<i64>> {
294        let edge_types = allowed_edge_types.iter().map(|s| s.to_string()).collect();
295        let key = QueryCacheKey::KHopFiltered(KHopFilteredCacheKey {
296            start,
297            depth,
298            direction,
299            allowed_edge_types: edge_types,
300        });
301
302        // Handle potential RwLock poisoning gracefully
303        let cache = match self.cache.read() {
304            Ok(cache) => cache,
305            Err(poisoned) => {
306                // Log the poisoning error and treat as cache miss
307                eprintln!(
308                    "WARNING: Query cache read lock poisoned in get_k_hop_filtered operation (start={}, depth={}, direction={:?}, edge_types={:?}). Treating as cache miss.",
309                    start, depth, direction, allowed_edge_types
310                );
311                poisoned.into_inner()
312            }
313        };
314
315        cache.get(&key).and_then(|entry| match &entry.result {
316            QueryResult::KHop(result) => Some(result.clone()),
317            _ => None,
318        })
319    }
320
321    /// Cache a filtered k-hop query result
322    pub fn put_k_hop_filtered(
323        &self,
324        start: i64,
325        depth: u32,
326        direction: BackendDirection,
327        allowed_edge_types: &[&str],
328        result: Vec<i64>,
329    ) {
330        let edge_types = allowed_edge_types.iter().map(|s| s.to_string()).collect();
331        let key = QueryCacheKey::KHopFiltered(KHopFilteredCacheKey {
332            start,
333            depth,
334            direction,
335            allowed_edge_types: edge_types,
336        });
337        let entry = QueryCacheEntry {
338            result: QueryResult::KHop(result),
339        };
340
341        // Handle potential RwLock poisoning gracefully
342        match self.cache.write() {
343            Ok(mut cache) => {
344                cache.insert(key, entry);
345            }
346            Err(poisoned) => {
347                // Log the poisoning error and recover from poisoned lock
348                eprintln!(
349                    "WARNING: Query cache write lock poisoned in put_k_hop_filtered operation (start={}, depth={}, direction={:?}, edge_types={:?}). Recovering and continuing.",
350                    start, depth, direction, allowed_edge_types
351                );
352                let mut cache = poisoned.into_inner();
353                cache.insert(key, entry);
354            }
355        }
356    }
357
358    /// Get a cached result for a shortest path query
359    pub fn get_shortest_path(&self, start: i64, end: i64) -> Option<Option<Vec<i64>>> {
360        let key = QueryCacheKey::ShortestPath(ShortestPathCacheKey { start, end });
361
362        // Handle potential RwLock poisoning gracefully
363        let cache = match self.cache.read() {
364            Ok(cache) => cache,
365            Err(poisoned) => {
366                // Log the poisoning error and treat as cache miss
367                eprintln!(
368                    "WARNING: Query cache read lock poisoned in get_shortest_path operation (start={}, end={}). Treating as cache miss.",
369                    start, end
370                );
371                poisoned.into_inner()
372            }
373        };
374
375        cache.get(&key).and_then(|entry| match &entry.result {
376            QueryResult::ShortestPath(result) => Some(result.clone()),
377            _ => None,
378        })
379    }
380
381    /// Cache a shortest path query result
382    pub fn put_shortest_path(&self, start: i64, end: i64, result: Option<Vec<i64>>) {
383        let key = QueryCacheKey::ShortestPath(ShortestPathCacheKey { start, end });
384        let entry = QueryCacheEntry {
385            result: QueryResult::ShortestPath(result),
386        };
387
388        // Handle potential RwLock poisoning gracefully
389        match self.cache.write() {
390            Ok(mut cache) => {
391                cache.insert(key, entry);
392            }
393            Err(poisoned) => {
394                // Log the poisoning error and recover from poisoned lock
395                eprintln!(
396                    "WARNING: Query cache write lock poisoned in put_shortest_path operation (start={}, end={}). Recovering and continuing.",
397                    start, end
398                );
399                let mut cache = poisoned.into_inner();
400                cache.insert(key, entry);
401            }
402        }
403    }
404
405    /// Clear all cached queries (MVCC invalidation)
406    pub fn invalidate_all(&self) {
407        // Handle potential RwLock poisoning gracefully
408        match self.cache.write() {
409            Ok(mut cache) => {
410                cache.clear();
411            }
412            Err(poisoned) => {
413                // Log the poisoning error and recover from poisoned lock
414                eprintln!(
415                    "WARNING: Query cache write lock poisoned in invalidate_all operation. Recovering and continuing."
416                );
417                let mut cache = poisoned.into_inner();
418                cache.clear();
419            }
420        }
421    }
422
423    /// Get cache statistics for monitoring
424    pub fn size(&self) -> usize {
425        // Handle potential RwLock poisoning gracefully
426        match self.cache.read() {
427            Ok(cache) => cache.len(),
428            Err(poisoned) => {
429                // Log the poisoning error and treat as empty cache
430                eprintln!(
431                    "WARNING: Query cache read lock poisoned in size operation. Treating as empty cache."
432                );
433                poisoned.into_inner().len()
434            }
435        }
436    }
437
438    /// Check if the cache is empty
439    pub fn is_empty(&self) -> bool {
440        // Handle potential RwLock poisoning gracefully
441        match self.cache.read() {
442            Ok(cache) => cache.is_empty(),
443            Err(poisoned) => {
444                // Log the poisoning error and treat as empty cache
445                eprintln!(
446                    "WARNING: Query cache read lock poisoned in is_empty operation. Treating as empty cache."
447                );
448                poisoned.into_inner().is_empty()
449            }
450        }
451    }
452}
453
454impl Default for QueryCache {
455    fn default() -> Self {
456        Self::new()
457    }
458}
459
460impl Clone for QueryCache {
461    fn clone(&self) -> Self {
462        Self {
463            cache: Arc::clone(&self.cache),
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_cache_key_hashing() {
474        // Test that identical keys produce identical hashes
475        let key1 = QueryCacheKey::Bfs(BfsCacheKey {
476            start: 42,
477            depth: 3,
478        });
479        let key2 = QueryCacheKey::Bfs(BfsCacheKey {
480            start: 42,
481            depth: 3,
482        });
483        assert_eq!(key1.hash(), key2.hash());
484
485        // Test that different keys produce different hashes
486        let key3 = QueryCacheKey::Bfs(BfsCacheKey {
487            start: 42,
488            depth: 4,
489        });
490        assert_ne!(key1.hash(), key3.hash());
491    }
492
493    #[test]
494    fn test_cache_basic_operations() {
495        let cache = QueryCache::new();
496
497        // Test cache miss
498        assert_eq!(cache.get_bfs(1, 2), None);
499
500        // Test cache put and hit
501        cache.put_bfs(1, 2, vec![3, 4, 5]);
502        assert_eq!(cache.get_bfs(1, 2), Some(vec![3, 4, 5]));
503
504        // Test cache size
505        assert_eq!(cache.size(), 1);
506        assert!(!cache.is_empty());
507
508        // Test cache invalidation
509        cache.invalidate_all();
510        assert_eq!(cache.get_bfs(1, 2), None);
511        assert_eq!(cache.size(), 0);
512        assert!(cache.is_empty());
513    }
514
515    #[test]
516    fn test_k_hop_filtered_cache() {
517        let cache = QueryCache::new();
518        let edge_types = vec!["friend", "colleague"];
519
520        // Test cache miss
521        assert_eq!(
522            cache.get_k_hop_filtered(1, 2, BackendDirection::Outgoing, &edge_types),
523            None
524        );
525
526        // Test cache put and hit
527        cache.put_k_hop_filtered(1, 2, BackendDirection::Outgoing, &edge_types, vec![3, 4]);
528        assert_eq!(
529            cache.get_k_hop_filtered(1, 2, BackendDirection::Outgoing, &edge_types),
530            Some(vec![3, 4])
531        );
532
533        // Test that different edge types don't interfere
534        assert_eq!(
535            cache.get_k_hop_filtered(1, 2, BackendDirection::Outgoing, &["enemy"]),
536            None
537        );
538    }
539
540    #[test]
541    fn test_shortest_path_cache() {
542        let cache = QueryCache::new();
543
544        // Test caching None result
545        cache.put_shortest_path(1, 5, None);
546        assert_eq!(cache.get_shortest_path(1, 5), Some(None));
547
548        // Test caching Some result
549        cache.put_shortest_path(1, 3, Some(vec![1, 2, 3]));
550        assert_eq!(cache.get_shortest_path(1, 3), Some(Some(vec![1, 2, 3])));
551
552        // Test cache size
553        assert_eq!(cache.size(), 2);
554    }
555}