Skip to main content

oxirs_gql/cache/
query_cache.rs

1//! GraphQL Query Result Cache with Schema-Aware Invalidation
2//!
3//! Caches serialised GraphQL responses keyed by (tenant, query, variables).
4//! Entries are invalidated when the RDF graphs they read from are modified.
5//! The cache uses an LRU eviction policy backed by an in-process `Mutex`.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12/// Stable cache key derived from tenant ID, query text, and serialised variables.
13///
14/// Hashing is done with a simple FNV-1a 64-bit implementation so we avoid
15/// pulling in extra dependencies.
16#[derive(Debug, Clone, Hash, PartialEq, Eq)]
17pub struct CacheKey {
18    /// Identifies which tenant owns this cached response.
19    pub tenant_id: String,
20    /// FNV-1a hash of the normalised GraphQL query string.
21    pub query_hash: u64,
22    /// FNV-1a hash of the serialised variables JSON (or 0 if no variables).
23    pub variables_hash: u64,
24}
25
26impl CacheKey {
27    /// Build a cache key from raw inputs.
28    ///
29    /// `variables` should be a JSON string or `None` if the query has no
30    /// variables.
31    pub fn new(tenant_id: &str, query: &str, variables: Option<&str>) -> Self {
32        Self {
33            tenant_id: tenant_id.to_string(),
34            query_hash: fnv1a_hash(query),
35            variables_hash: variables.map(fnv1a_hash).unwrap_or(0),
36        }
37    }
38}
39
40/// FNV-1a 64-bit hash of a string.
41fn fnv1a_hash(s: &str) -> u64 {
42    const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
43    const FNV_PRIME: u64 = 1099511628211;
44
45    let mut hash = FNV_OFFSET_BASIS;
46    for byte in s.bytes() {
47        hash ^= u64::from(byte);
48        hash = hash.wrapping_mul(FNV_PRIME);
49    }
50    hash
51}
52
53/// A single cached GraphQL response entry.
54#[derive(Debug, Clone)]
55pub struct CacheEntry {
56    /// The cache key associated with this entry.
57    pub key: CacheKey,
58    /// The serialised JSON response body.
59    pub response_json: String,
60    /// RDF named graph IRIs that were read when producing this response.
61    /// Used to invalidate the entry when any of these graphs changes.
62    pub accessed_graphs: Vec<String>,
63    /// RDF predicates that were accessed — used for finer-grained invalidation.
64    pub accessed_predicates: Vec<String>,
65    /// When this entry was inserted.
66    pub created_at: Instant,
67    /// How long this entry is valid for.
68    pub ttl: Duration,
69    /// Number of times this entry has been served from cache.
70    pub hit_count: u64,
71}
72
73impl CacheEntry {
74    /// Returns `true` if this entry has lived longer than its TTL.
75    pub fn is_expired(&self) -> bool {
76        self.created_at.elapsed() >= self.ttl
77    }
78
79    /// Returns the age of this entry.
80    pub fn age(&self) -> Duration {
81        self.created_at.elapsed()
82    }
83
84    /// Returns how much TTL remains (saturating at zero for expired entries).
85    pub fn remaining_ttl(&self) -> Duration {
86        self.ttl.saturating_sub(self.created_at.elapsed())
87    }
88}
89
90/// Internal cache store protected by a `Mutex`.
91struct CacheStore {
92    entries: HashMap<CacheKey, CacheEntry>,
93    /// Insertion-order queue used to enforce the capacity limit (LRU approximation).
94    lru_order: VecDeque<CacheKey>,
95    /// Maps graph IRI -> set of cache keys whose responses read from that graph.
96    graph_index: HashMap<String, HashSet<CacheKey>>,
97    /// Maps tenant ID -> set of cache keys for that tenant.
98    tenant_index: HashMap<String, HashSet<CacheKey>>,
99    max_entries: usize,
100}
101
102impl CacheStore {
103    fn new(max_entries: usize) -> Self {
104        Self {
105            entries: HashMap::new(),
106            lru_order: VecDeque::new(),
107            graph_index: HashMap::new(),
108            tenant_index: HashMap::new(),
109            max_entries,
110        }
111    }
112
113    /// Insert or replace an entry, maintaining indexes and capacity.
114    fn insert(&mut self, entry: CacheEntry) {
115        let key = entry.key.clone();
116
117        // Update graph index
118        for graph in &entry.accessed_graphs {
119            self.graph_index
120                .entry(graph.clone())
121                .or_default()
122                .insert(key.clone());
123        }
124
125        // Update tenant index
126        self.tenant_index
127            .entry(key.tenant_id.clone())
128            .or_default()
129            .insert(key.clone());
130
131        // Remove old LRU slot for this key if it already existed
132        if self.entries.contains_key(&key) {
133            self.lru_order.retain(|k| k != &key);
134        }
135
136        self.entries.insert(key.clone(), entry);
137        self.lru_order.push_back(key);
138
139        // Evict oldest entries if over capacity
140        while self.entries.len() > self.max_entries {
141            if let Some(oldest_key) = self.lru_order.pop_front() {
142                self.remove_key(&oldest_key);
143            } else {
144                break;
145            }
146        }
147    }
148
149    /// Remove a single key from all data structures.
150    fn remove_key(&mut self, key: &CacheKey) {
151        if let Some(entry) = self.entries.remove(key) {
152            // Clean graph index
153            for graph in &entry.accessed_graphs {
154                if let Some(set) = self.graph_index.get_mut(graph) {
155                    set.remove(key);
156                }
157            }
158            // Clean tenant index
159            if let Some(set) = self.tenant_index.get_mut(&entry.key.tenant_id) {
160                set.remove(key);
161            }
162        }
163        self.lru_order.retain(|k| k != key);
164    }
165
166    /// Mark `key` as recently used (move to back of LRU queue).
167    fn touch(&mut self, key: &CacheKey) {
168        self.lru_order.retain(|k| k != key);
169        self.lru_order.push_back(key.clone());
170    }
171
172    /// Remove all keys matching a predicate, returning the count removed.
173    fn remove_where<F>(&mut self, predicate: F) -> usize
174    where
175        F: Fn(&CacheKey, &CacheEntry) -> bool,
176    {
177        let to_remove: Vec<CacheKey> = self
178            .entries
179            .iter()
180            .filter(|(k, v)| predicate(k, v))
181            .map(|(k, _)| k.clone())
182            .collect();
183
184        let count = to_remove.len();
185        for key in to_remove {
186            self.remove_key(&key);
187        }
188        count
189    }
190}
191
192/// Thread-safe, schema-aware GraphQL query result cache.
193///
194/// Supports:
195/// - LRU eviction once the entry count reaches `max_entries`.
196/// - TTL-based expiry (checked lazily on `get` and explicitly via `evict_expired`).
197/// - Bulk invalidation by graph IRI or tenant ID.
198/// - Hit/miss rate statistics.
199pub struct GqlQueryCache {
200    store: Arc<Mutex<CacheStore>>,
201    default_ttl: Duration,
202    hit_count: Arc<AtomicU64>,
203    miss_count: Arc<AtomicU64>,
204    eviction_count: Arc<AtomicU64>,
205}
206
207impl std::fmt::Debug for GqlQueryCache {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("GqlQueryCache")
210            .field("default_ttl", &self.default_ttl)
211            .field("hits", &self.hit_count.load(Ordering::Relaxed))
212            .field("misses", &self.miss_count.load(Ordering::Relaxed))
213            .finish()
214    }
215}
216
217impl GqlQueryCache {
218    /// Create a new cache.
219    ///
220    /// - `max_entries`: Maximum number of entries before LRU eviction kicks in.
221    /// - `default_ttl`: TTL used when `put` does not specify a custom TTL.
222    pub fn new(max_entries: usize, default_ttl: Duration) -> Self {
223        Self {
224            store: Arc::new(Mutex::new(CacheStore::new(max_entries))),
225            default_ttl,
226            hit_count: Arc::new(AtomicU64::new(0)),
227            miss_count: Arc::new(AtomicU64::new(0)),
228            eviction_count: Arc::new(AtomicU64::new(0)),
229        }
230    }
231
232    /// Look up a cached response.
233    ///
234    /// Returns `Some(json)` on a cache hit (updating the hit counter and
235    /// LRU order).  Returns `None` on a miss or if the entry has expired
236    /// (expired entries are removed eagerly).
237    pub fn get(&self, key: &CacheKey) -> Option<String> {
238        let mut store = self
239            .store
240            .lock()
241            .unwrap_or_else(|poisoned| poisoned.into_inner());
242
243        match store.entries.get(key) {
244            Some(entry) if entry.is_expired() => {
245                let key_clone = key.clone();
246                store.remove_key(&key_clone);
247                self.miss_count.fetch_add(1, Ordering::Relaxed);
248                self.eviction_count.fetch_add(1, Ordering::Relaxed);
249                None
250            }
251            Some(entry) => {
252                let response = entry.response_json.clone();
253                if let Some(e) = store.entries.get_mut(key) {
254                    e.hit_count += 1;
255                }
256                store.touch(key);
257                self.hit_count.fetch_add(1, Ordering::Relaxed);
258                Some(response)
259            }
260            None => {
261                self.miss_count.fetch_add(1, Ordering::Relaxed);
262                None
263            }
264        }
265    }
266
267    /// Store a response in the cache with the default TTL.
268    ///
269    /// - `graphs`: RDF graph IRIs that were read when generating this response.
270    /// - `predicates`: RDF predicates that were read.
271    ///
272    /// Returns `true` if the entry was inserted, `false` if the mutex was
273    /// unavailable (should not happen under normal conditions).
274    pub fn put(
275        &self,
276        key: CacheKey,
277        response: String,
278        graphs: Vec<String>,
279        predicates: Vec<String>,
280    ) -> bool {
281        self.put_with_ttl(key, response, graphs, predicates, self.default_ttl)
282    }
283
284    /// Store a response with an explicit TTL override.
285    pub fn put_with_ttl(
286        &self,
287        key: CacheKey,
288        response: String,
289        graphs: Vec<String>,
290        predicates: Vec<String>,
291        ttl: Duration,
292    ) -> bool {
293        let entry = CacheEntry {
294            key: key.clone(),
295            response_json: response,
296            accessed_graphs: graphs,
297            accessed_predicates: predicates,
298            created_at: Instant::now(),
299            ttl,
300            hit_count: 0,
301        };
302
303        match self.store.lock() {
304            Ok(mut store) => {
305                store.insert(entry);
306                true
307            }
308            Err(_) => false,
309        }
310    }
311
312    /// Invalidate all cache entries that read from `graph`.
313    ///
314    /// Returns the number of entries removed.
315    pub fn invalidate_by_graph(&self, graph: &str) -> usize {
316        let mut store = self
317            .store
318            .lock()
319            .unwrap_or_else(|poisoned| poisoned.into_inner());
320
321        // Collect keys from graph index first to avoid borrow issues
322        let keys_to_remove: Vec<CacheKey> = store
323            .graph_index
324            .get(graph)
325            .cloned()
326            .unwrap_or_default()
327            .into_iter()
328            .collect();
329
330        let count = keys_to_remove.len();
331        for key in keys_to_remove {
332            store.remove_key(&key);
333        }
334        self.eviction_count
335            .fetch_add(count as u64, Ordering::Relaxed);
336        count
337    }
338
339    /// Invalidate all cache entries belonging to `tenant_id`.
340    ///
341    /// Returns the number of entries removed.
342    pub fn invalidate_by_tenant(&self, tenant_id: &str) -> usize {
343        let mut store = self
344            .store
345            .lock()
346            .unwrap_or_else(|poisoned| poisoned.into_inner());
347
348        let keys_to_remove: Vec<CacheKey> = store
349            .tenant_index
350            .get(tenant_id)
351            .cloned()
352            .unwrap_or_default()
353            .into_iter()
354            .collect();
355
356        let count = keys_to_remove.len();
357        for key in keys_to_remove {
358            store.remove_key(&key);
359        }
360        self.eviction_count
361            .fetch_add(count as u64, Ordering::Relaxed);
362        count
363    }
364
365    /// Remove all expired entries from the cache.
366    ///
367    /// Returns the number of entries removed.
368    pub fn evict_expired(&self) -> usize {
369        let mut store = self
370            .store
371            .lock()
372            .unwrap_or_else(|poisoned| poisoned.into_inner());
373
374        let count = store.remove_where(|_, entry| entry.is_expired());
375        self.eviction_count
376            .fetch_add(count as u64, Ordering::Relaxed);
377        count
378    }
379
380    /// Clear all entries from the cache.
381    ///
382    /// Returns the number of entries removed.
383    pub fn clear(&self) -> usize {
384        let mut store = self
385            .store
386            .lock()
387            .unwrap_or_else(|poisoned| poisoned.into_inner());
388
389        let count = store.entries.len();
390        store.entries.clear();
391        store.lru_order.clear();
392        store.graph_index.clear();
393        store.tenant_index.clear();
394        count
395    }
396
397    /// Returns the cache hit rate as a value in `[0.0, 1.0]`.
398    ///
399    /// Returns `0.0` if no requests have been made yet.
400    pub fn hit_rate(&self) -> f64 {
401        let hits = self.hit_count.load(Ordering::Relaxed) as f64;
402        let misses = self.miss_count.load(Ordering::Relaxed) as f64;
403        let total = hits + misses;
404        if total == 0.0 {
405            0.0
406        } else {
407            hits / total
408        }
409    }
410
411    /// Returns the current number of entries in the cache.
412    pub fn size(&self) -> usize {
413        self.store.lock().map(|s| s.entries.len()).unwrap_or(0)
414    }
415
416    /// Returns cumulative hit/miss/eviction counters.
417    pub fn stats(&self) -> CacheStats {
418        CacheStats {
419            hits: self.hit_count.load(Ordering::Relaxed),
420            misses: self.miss_count.load(Ordering::Relaxed),
421            evictions: self.eviction_count.load(Ordering::Relaxed),
422            current_size: self.size(),
423        }
424    }
425}
426
427/// Snapshot of cache performance statistics.
428#[derive(Debug, Clone)]
429pub struct CacheStats {
430    /// Total number of cache hits since creation.
431    pub hits: u64,
432    /// Total number of cache misses since creation.
433    pub misses: u64,
434    /// Total number of entries evicted (expired + LRU + manual invalidation).
435    pub evictions: u64,
436    /// Current number of entries in the cache.
437    pub current_size: usize,
438}
439
440impl CacheStats {
441    /// Returns the hit rate in `[0.0, 1.0]`.
442    pub fn hit_rate(&self) -> f64 {
443        let total = (self.hits + self.misses) as f64;
444        if total == 0.0 {
445            0.0
446        } else {
447            self.hits as f64 / total
448        }
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn key(tenant: &str, query: &str) -> CacheKey {
457        CacheKey::new(tenant, query, None)
458    }
459
460    fn key_with_vars(tenant: &str, query: &str, vars: &str) -> CacheKey {
461        CacheKey::new(tenant, query, Some(vars))
462    }
463
464    #[test]
465    fn test_cache_key_equality() {
466        let k1 = key("t1", "{ hello }");
467        let k2 = key("t1", "{ hello }");
468        assert_eq!(k1, k2);
469    }
470
471    #[test]
472    fn test_cache_key_different_tenants() {
473        let k1 = key("t1", "{ hello }");
474        let k2 = key("t2", "{ hello }");
475        assert_ne!(k1, k2);
476    }
477
478    #[test]
479    fn test_cache_key_different_queries() {
480        let k1 = key("t1", "{ hello }");
481        let k2 = key("t1", "{ world }");
482        assert_ne!(k1, k2);
483    }
484
485    #[test]
486    fn test_cache_key_with_variables() {
487        let k1 = key_with_vars("t1", "{ q }", r#"{"id":1}"#);
488        let k2 = key_with_vars("t1", "{ q }", r#"{"id":2}"#);
489        assert_ne!(k1, k2);
490    }
491
492    #[test]
493    fn test_put_and_get_hit() {
494        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
495        let k = key("tenant1", "{ data }");
496        cache.put(
497            k.clone(),
498            r#"{"data":{"data":"ok"}}"#.to_string(),
499            vec![],
500            vec![],
501        );
502
503        let result = cache.get(&k);
504        assert_eq!(result.as_deref(), Some(r#"{"data":{"data":"ok"}}"#));
505    }
506
507    #[test]
508    fn test_get_miss_returns_none() {
509        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
510        let k = key("tenant1", "{ missing }");
511        assert!(cache.get(&k).is_none());
512    }
513
514    #[test]
515    fn test_expired_entry_returns_none() {
516        let cache = GqlQueryCache::new(100, Duration::from_nanos(1));
517        let k = key("t", "q");
518        cache.put_with_ttl(
519            k.clone(),
520            "response".to_string(),
521            vec![],
522            vec![],
523            Duration::from_nanos(1),
524        );
525        // Sleep long enough for entry to expire
526        std::thread::sleep(Duration::from_millis(5));
527        assert!(cache.get(&k).is_none());
528        assert_eq!(cache.size(), 0);
529    }
530
531    #[test]
532    fn test_invalidate_by_graph() {
533        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
534
535        let k1 = key("t", "q1");
536        let k2 = key("t", "q2");
537        let k3 = key("t", "q3");
538
539        cache.put(
540            k1.clone(),
541            "r1".to_string(),
542            vec!["http://ex.org/g1".to_string()],
543            vec![],
544        );
545        cache.put(
546            k2.clone(),
547            "r2".to_string(),
548            vec![
549                "http://ex.org/g1".to_string(),
550                "http://ex.org/g2".to_string(),
551            ],
552            vec![],
553        );
554        cache.put(
555            k3.clone(),
556            "r3".to_string(),
557            vec!["http://ex.org/g2".to_string()],
558            vec![],
559        );
560
561        // Invalidate g1 — should remove k1 and k2
562        let removed = cache.invalidate_by_graph("http://ex.org/g1");
563        assert_eq!(removed, 2);
564        assert!(cache.get(&k1).is_none());
565        assert!(cache.get(&k2).is_none());
566        // k3 should survive
567        assert!(cache.get(&k3).is_some());
568    }
569
570    #[test]
571    fn test_invalidate_by_tenant() {
572        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
573
574        let ka = CacheKey::new("tenantA", "q1", None);
575        let kb = CacheKey::new("tenantB", "q1", None);
576
577        cache.put(ka.clone(), "ra".to_string(), vec![], vec![]);
578        cache.put(kb.clone(), "rb".to_string(), vec![], vec![]);
579
580        let removed = cache.invalidate_by_tenant("tenantA");
581        assert_eq!(removed, 1);
582        assert!(cache.get(&ka).is_none());
583        assert!(cache.get(&kb).is_some());
584    }
585
586    #[test]
587    fn test_evict_expired() {
588        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
589
590        // Insert one expiring entry and one live entry
591        let expired_key = key("t", "expiring");
592        cache.put_with_ttl(
593            expired_key.clone(),
594            "exp".to_string(),
595            vec![],
596            vec![],
597            Duration::from_nanos(1),
598        );
599
600        let live_key = key("t", "live");
601        cache.put(live_key.clone(), "live".to_string(), vec![], vec![]);
602
603        std::thread::sleep(Duration::from_millis(5));
604
605        let removed = cache.evict_expired();
606        assert_eq!(removed, 1);
607        assert_eq!(cache.size(), 1);
608        assert!(cache.get(&live_key).is_some());
609    }
610
611    #[test]
612    fn test_hit_rate_calculation() {
613        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
614        let k = key("t", "q");
615        cache.put(k.clone(), "resp".to_string(), vec![], vec![]);
616
617        cache.get(&k); // hit
618        cache.get(&key("t", "other")); // miss
619
620        let rate = cache.hit_rate();
621        assert!((rate - 0.5).abs() < f64::EPSILON);
622    }
623
624    #[test]
625    fn test_size_tracking() {
626        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
627        assert_eq!(cache.size(), 0);
628
629        cache.put(key("t", "q1"), "r1".to_string(), vec![], vec![]);
630        assert_eq!(cache.size(), 1);
631
632        cache.put(key("t", "q2"), "r2".to_string(), vec![], vec![]);
633        assert_eq!(cache.size(), 2);
634
635        cache.invalidate_by_tenant("t");
636        assert_eq!(cache.size(), 0);
637    }
638
639    #[test]
640    fn test_lru_eviction_on_max_capacity() {
641        let cache = GqlQueryCache::new(3, Duration::from_secs(60));
642
643        let k1 = key("t", "q1");
644        let k2 = key("t", "q2");
645        let k3 = key("t", "q3");
646        let k4 = key("t", "q4");
647
648        cache.put(k1.clone(), "r1".to_string(), vec![], vec![]);
649        cache.put(k2.clone(), "r2".to_string(), vec![], vec![]);
650        cache.put(k3.clone(), "r3".to_string(), vec![], vec![]);
651        // Touch k1 to make k2 the oldest
652        cache.get(&k1);
653
654        // Inserting k4 should evict k2 (least recently used)
655        cache.put(k4.clone(), "r4".to_string(), vec![], vec![]);
656
657        assert_eq!(cache.size(), 3);
658        assert!(cache.get(&k2).is_none(), "k2 should have been evicted");
659        assert!(cache.get(&k1).is_some());
660        assert!(cache.get(&k3).is_some());
661        assert!(cache.get(&k4).is_some());
662    }
663
664    #[test]
665    fn test_clear() {
666        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
667        cache.put(key("t", "q1"), "r1".to_string(), vec![], vec![]);
668        cache.put(key("t", "q2"), "r2".to_string(), vec![], vec![]);
669
670        let removed = cache.clear();
671        assert_eq!(removed, 2);
672        assert_eq!(cache.size(), 0);
673    }
674
675    #[test]
676    fn test_stats_snapshot() {
677        let cache = GqlQueryCache::new(100, Duration::from_secs(60));
678        let k = key("t", "q");
679        cache.put(k.clone(), "r".to_string(), vec![], vec![]);
680        cache.get(&k); // hit
681        cache.get(&key("t", "miss")); // miss
682
683        let stats = cache.stats();
684        assert_eq!(stats.hits, 1);
685        assert_eq!(stats.misses, 1);
686        assert_eq!(stats.current_size, 1);
687    }
688
689    #[test]
690    fn test_fnv1a_deterministic() {
691        assert_eq!(fnv1a_hash("hello"), fnv1a_hash("hello"));
692        assert_ne!(fnv1a_hash("hello"), fnv1a_hash("world"));
693    }
694
695    #[test]
696    fn test_cache_entry_is_expired() {
697        let entry = CacheEntry {
698            key: key("t", "q"),
699            response_json: String::new(),
700            accessed_graphs: vec![],
701            accessed_predicates: vec![],
702            created_at: Instant::now(),
703            ttl: Duration::from_secs(100),
704            hit_count: 0,
705        };
706        assert!(!entry.is_expired());
707    }
708}