Skip to main content

fraiseql_core/cache/
response_cache.rs

1//! Executor-level response cache.
2//!
3//! Caches the final projected GraphQL response value (after RBAC filtering,
4//! projection, and envelope wrapping) to skip all redundant work on cache
5//! hits for the same user + query combination.
6//!
7//! This is a **second cache tier** above the adapter-level row cache:
8//! - Row cache: raw JSONB rows, shared across projection shapes
9//! - Response cache: final `serde_json::Value`, keyed per (query + security context)
10//!
11//! ## Performance characteristics
12//!
13//! Backed by `moka::sync::Cache` (W-TinyLFU, lock-free reads). Invalidation
14//! uses a `DashMap` reverse index (view name → set of keys), enabling O(k)
15//! eviction without scanning the full cache.
16//!
17//! ## Security
18//!
19//! The response cache key includes a hash of the `SecurityContext` fields
20//! that affect response content (`user_id`, roles, `tenant_id`, scopes,
21//! attributes). Different RBAC scopes produce different cache entries.
22
23use std::{
24    sync::{
25        Arc,
26        atomic::{AtomicU64, Ordering},
27    },
28    time::Duration,
29};
30
31use dashmap::{DashMap, DashSet};
32use moka::sync::Cache as MokaCache;
33use serde_json::Value;
34
35use crate::{error::Result, security::SecurityContext};
36
37/// Configuration for the response cache.
38#[derive(Debug, Clone, Copy)]
39pub struct ResponseCacheConfig {
40    /// Enable the response cache.
41    pub enabled: bool,
42
43    /// Maximum number of cached responses.
44    pub max_entries: usize,
45
46    /// TTL in seconds (0 = no time-based expiry, live until invalidated).
47    pub ttl_seconds: u64,
48}
49
50impl Default for ResponseCacheConfig {
51    fn default() -> Self {
52        Self {
53            enabled:     false, // opt-in
54            max_entries: 10_000,
55            ttl_seconds: 300,
56        }
57    }
58}
59
60/// Per-entry value stored in moka alongside the response value.
61///
62/// Contains the accessed views so the eviction listener can clean up
63/// the reverse index when an entry is evicted by TTL or LFU policy.
64struct ResponseEntry {
65    /// The projected GraphQL response value.
66    response: Arc<Value>,
67
68    /// Views accessed by this query (for invalidation).
69    accessed_views: Box<[String]>,
70}
71
72/// Executor-level cache for projected GraphQL responses.
73///
74/// Stores the final serialized response keyed by `(query_hash, security_hash)`.
75/// On hit, the entire projection + RBAC + serialization pipeline is skipped.
76///
77/// # Thread Safety
78///
79/// `moka::sync::Cache` is `Send + Sync` with lock-free reads. The view reverse
80/// index uses `DashMap` (fine-grained shard locking). There is no global mutex
81/// on the read path.
82pub struct ResponseCache {
83    store: MokaCache<(u64, u64), Arc<ResponseEntry>>,
84
85    /// Reverse index: view name → set of `(query_hash, sec_hash)` keys.
86    ///
87    /// Maintained in `put()` and pruned by the moka eviction listener.
88    /// Enables O(k) invalidation without scanning the full cache.
89    view_index: Arc<DashMap<String, DashSet<(u64, u64)>>>,
90
91    enabled: bool,
92    hits:    AtomicU64,
93    misses:  AtomicU64,
94}
95
96impl ResponseCache {
97    /// Create a new response cache from configuration.
98    #[must_use]
99    pub fn new(config: ResponseCacheConfig) -> Self {
100        let view_index: Arc<DashMap<String, DashSet<(u64, u64)>>> = Arc::new(DashMap::new());
101        let vi = Arc::clone(&view_index);
102
103        let mut builder = MokaCache::builder()
104            .max_capacity(config.max_entries as u64)
105            .eviction_listener(move |key: Arc<(u64, u64)>, value: Arc<ResponseEntry>, _cause| {
106                for view in &value.accessed_views {
107                    if let Some(keys) = vi.get(view) {
108                        keys.remove(&*key);
109                    }
110                }
111            });
112
113        if config.ttl_seconds > 0 {
114            builder = builder.time_to_live(Duration::from_secs(config.ttl_seconds));
115        }
116
117        let store = builder.build();
118
119        Self {
120            store,
121            view_index,
122            enabled: config.enabled,
123            hits: AtomicU64::new(0),
124            misses: AtomicU64::new(0),
125        }
126    }
127
128    /// Whether the response cache is enabled.
129    #[must_use]
130    pub const fn is_enabled(&self) -> bool {
131        self.enabled
132    }
133
134    /// Look up a cached response.
135    ///
136    /// # Errors
137    ///
138    /// This method is infallible with the moka backend and always returns `Ok`.
139    pub fn get(&self, query_key: u64, security_hash: u64) -> Result<Option<Arc<Value>>> {
140        if !self.enabled {
141            return Ok(None);
142        }
143
144        let key = (query_key, security_hash);
145        if let Some(entry) = self.store.get(&key) {
146            self.hits.fetch_add(1, Ordering::Relaxed);
147            Ok(Some(Arc::clone(&entry.response)))
148        } else {
149            self.misses.fetch_add(1, Ordering::Relaxed);
150            Ok(None)
151        }
152    }
153
154    /// Store a response in the cache.
155    ///
156    /// # Errors
157    ///
158    /// This method is infallible with the moka backend and always returns `Ok`.
159    pub fn put(
160        &self,
161        query_key: u64,
162        security_hash: u64,
163        response: Arc<Value>,
164        accessed_views: Vec<String>,
165    ) -> Result<()> {
166        if !self.enabled {
167            return Ok(());
168        }
169
170        let key = (query_key, security_hash);
171
172        // Update view → key reverse index before inserting into the store,
173        // so invalidate_views() called concurrently won't miss the key.
174        for view in &accessed_views {
175            self.view_index.entry(view.clone()).or_default().insert(key);
176        }
177
178        let entry = Arc::new(ResponseEntry {
179            response,
180            accessed_views: accessed_views.into_boxed_slice(),
181        });
182
183        self.store.insert(key, entry);
184        Ok(())
185    }
186
187    /// Invalidate all entries that access any of the given views.
188    ///
189    /// Uses the O(k) reverse index — no full-cache scan.
190    ///
191    /// # Errors
192    ///
193    /// This method is infallible with the moka backend and always returns `Ok`.
194    pub fn invalidate_views(&self, views: &[String]) -> Result<u64> {
195        let mut total = 0_u64;
196
197        for view in views {
198            if let Some(keys) = self.view_index.get(view) {
199                let to_remove: Vec<(u64, u64)> = keys.iter().map(|k| *k).collect();
200                drop(keys);
201                for key in to_remove {
202                    self.store.invalidate(&key);
203                    // The eviction listener handles index cleanup.
204                    total += 1;
205                }
206            }
207        }
208
209        Ok(total)
210    }
211
212    /// Get cache hit/miss counts.
213    #[must_use]
214    pub fn metrics(&self) -> (u64, u64) {
215        (self.hits.load(Ordering::Relaxed), self.misses.load(Ordering::Relaxed))
216    }
217}
218
219/// Hash the security context fields that affect response content.
220///
221/// Fields hashed: `user_id`, roles (sorted), `tenant_id`, scopes (sorted),
222/// `attributes` (sorted keys + JSON-serialized values).
223///
224/// Fields NOT hashed: `request_id`, `ip_address`, `authenticated_at`, `expires_at`,
225/// `issuer`, `audience` — these don't affect which data the user can see.
226///
227/// `attributes` IS hashed because custom RLS policies can key on arbitrary
228/// attributes (e.g., "department", "region") to produce different query results
229/// for users who otherwise share the same `user_id`/roles/`tenant_id`/scopes.
230///
231/// Returns `0` when no security context is present (all users share one entry).
232#[must_use]
233pub fn hash_security_context(ctx: Option<&SecurityContext>) -> u64 {
234    use std::hash::{Hash, Hasher};
235
236    let Some(ctx) = ctx else {
237        return 0;
238    };
239
240    let mut hasher = ahash::AHasher::default();
241    ctx.user_id.hash(&mut hasher);
242
243    // Sort roles for determinism (JWT may present them in any order)
244    let mut sorted_roles = ctx.roles.clone();
245    sorted_roles.sort();
246    for role in &sorted_roles {
247        role.hash(&mut hasher);
248    }
249
250    ctx.tenant_id.hash(&mut hasher);
251
252    let mut sorted_scopes = ctx.scopes.clone();
253    sorted_scopes.sort();
254    for scope in &sorted_scopes {
255        scope.hash(&mut hasher);
256    }
257
258    // Hash attributes (custom RLS policies can key on these)
259    if !ctx.attributes.is_empty() {
260        let mut attr_keys: Vec<&String> = ctx.attributes.keys().collect();
261        attr_keys.sort();
262        for key in attr_keys {
263            key.hash(&mut hasher);
264            // Use JSON serialization for deterministic Value hashing
265            serde_json::to_string(&ctx.attributes[key])
266                .unwrap_or_default()
267                .hash(&mut hasher);
268        }
269    }
270
271    hasher.finish()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn enabled_config() -> ResponseCacheConfig {
279        ResponseCacheConfig {
280            enabled:     true,
281            max_entries: 100,
282            ttl_seconds: 3600,
283        }
284    }
285
286    #[test]
287    fn test_put_and_get() {
288        let cache = ResponseCache::new(enabled_config());
289        let response = Arc::new(serde_json::json!({"data": {"users": []}}));
290
291        cache
292            .put(1, 0, response.clone(), vec!["v_user".to_string()])
293            .expect("put should succeed");
294        let result = cache.get(1, 0).expect("get should succeed");
295        assert!(result.is_some());
296        assert_eq!(*result.expect("should be Some"), *response);
297    }
298
299    #[test]
300    fn test_different_security_contexts_different_entries() {
301        let cache = ResponseCache::new(enabled_config());
302
303        let admin_response =
304            Arc::new(serde_json::json!({"data": {"users": [{"id": "1", "role": "admin"}]}}));
305        let user_response = Arc::new(serde_json::json!({"data": {"users": [{"id": "1"}]}}));
306
307        // Same query key (1), different security hashes
308        cache
309            .put(1, 100, admin_response.clone(), vec!["v_user".to_string()])
310            .expect("put admin");
311        cache
312            .put(1, 200, user_response.clone(), vec!["v_user".to_string()])
313            .expect("put user");
314
315        let admin_result = cache.get(1, 100).expect("get admin").expect("admin hit");
316        let user_result = cache.get(1, 200).expect("get user").expect("user hit");
317
318        assert_ne!(*admin_result, *user_result);
319        assert_eq!(*admin_result, *admin_response);
320        assert_eq!(*user_result, *user_response);
321    }
322
323    #[test]
324    fn test_invalidate_views() {
325        let cache = ResponseCache::new(enabled_config());
326
327        cache
328            .put(1, 0, Arc::new(serde_json::json!("r1")), vec!["v_user".to_string()])
329            .expect("put 1");
330        cache
331            .put(2, 0, Arc::new(serde_json::json!("r2")), vec!["v_post".to_string()])
332            .expect("put 2");
333
334        // Flush pending moka writes before invalidation
335        cache.store.run_pending_tasks();
336
337        let invalidated = cache.invalidate_views(&["v_user".to_string()]).expect("invalidate");
338        assert_eq!(invalidated, 1);
339
340        // Flush invalidations
341        cache.store.run_pending_tasks();
342
343        assert!(cache.get(1, 0).expect("get 1").is_none());
344        assert!(cache.get(2, 0).expect("get 2").is_some());
345    }
346
347    #[test]
348    fn test_disabled_cache_returns_none() {
349        let cache = ResponseCache::new(ResponseCacheConfig::default());
350        assert!(!cache.is_enabled());
351
352        cache.put(1, 0, Arc::new(serde_json::json!("r")), vec![]).expect("put disabled");
353        assert!(cache.get(1, 0).expect("get disabled").is_none());
354    }
355
356    #[test]
357    fn test_metrics() {
358        let cache = ResponseCache::new(enabled_config());
359
360        cache.put(1, 0, Arc::new(serde_json::json!("r")), vec![]).expect("put");
361        cache.store.run_pending_tasks();
362        let _ = cache.get(1, 0); // hit
363        let _ = cache.get(2, 0); // miss
364
365        let (hits, misses) = cache.metrics();
366        assert_eq!(hits, 1);
367        assert_eq!(misses, 1);
368    }
369
370    // ========================================================================
371    // Security Context Hash Tests
372    // ========================================================================
373
374    #[test]
375    fn test_hash_security_context_none_returns_zero() {
376        assert_eq!(hash_security_context(None), 0);
377    }
378
379    #[test]
380    fn test_hash_security_context_same_context_same_hash() {
381        let ctx = make_security_context("alice", &["admin"], Some("tenant-1"), &["read:user"]);
382        let hash1 = hash_security_context(Some(&ctx));
383        let hash2 = hash_security_context(Some(&ctx));
384        assert_eq!(hash1, hash2, "Same context must produce same hash");
385    }
386
387    #[test]
388    fn test_hash_security_context_different_user_different_hash() {
389        let alice = make_security_context("alice", &["admin"], Some("tenant-1"), &[]);
390        let bob = make_security_context("bob", &["admin"], Some("tenant-1"), &[]);
391
392        assert_ne!(
393            hash_security_context(Some(&alice)),
394            hash_security_context(Some(&bob)),
395            "Different user_id must produce different hash"
396        );
397    }
398
399    #[test]
400    fn test_hash_security_context_different_roles_different_hash() {
401        let admin = make_security_context("alice", &["admin"], None, &[]);
402        let viewer = make_security_context("alice", &["viewer"], None, &[]);
403
404        assert_ne!(
405            hash_security_context(Some(&admin)),
406            hash_security_context(Some(&viewer)),
407            "Different roles must produce different hash"
408        );
409    }
410
411    #[test]
412    fn test_hash_security_context_role_order_independent() {
413        let ctx1 = make_security_context("alice", &["admin", "viewer"], None, &[]);
414        let ctx2 = make_security_context("alice", &["viewer", "admin"], None, &[]);
415
416        assert_eq!(
417            hash_security_context(Some(&ctx1)),
418            hash_security_context(Some(&ctx2)),
419            "Role order must not affect hash (sorted internally)"
420        );
421    }
422
423    #[test]
424    fn test_hash_security_context_different_tenant_different_hash() {
425        let t1 = make_security_context("alice", &[], Some("tenant-1"), &[]);
426        let t2 = make_security_context("alice", &[], Some("tenant-2"), &[]);
427        let none = make_security_context("alice", &[], None, &[]);
428
429        assert_ne!(hash_security_context(Some(&t1)), hash_security_context(Some(&t2)),);
430        assert_ne!(hash_security_context(Some(&t1)), hash_security_context(Some(&none)),);
431    }
432
433    #[test]
434    fn test_hash_security_context_different_scopes_different_hash() {
435        let read = make_security_context("alice", &[], None, &["read:user"]);
436        let write = make_security_context("alice", &[], None, &["write:user"]);
437        let both = make_security_context("alice", &[], None, &["read:user", "write:user"]);
438
439        assert_ne!(hash_security_context(Some(&read)), hash_security_context(Some(&write)),);
440        assert_ne!(hash_security_context(Some(&read)), hash_security_context(Some(&both)),);
441    }
442
443    #[test]
444    fn test_hash_security_context_scope_order_independent() {
445        let ctx1 = make_security_context("alice", &[], None, &["read:user", "write:post"]);
446        let ctx2 = make_security_context("alice", &[], None, &["write:post", "read:user"]);
447
448        assert_eq!(
449            hash_security_context(Some(&ctx1)),
450            hash_security_context(Some(&ctx2)),
451            "Scope order must not affect hash (sorted internally)"
452        );
453    }
454
455    #[test]
456    fn test_hash_security_context_different_attributes_different_hash() {
457        let mut ctx1 = make_security_context("alice", &["admin"], None, &[]);
458        ctx1.attributes
459            .insert("department".to_string(), serde_json::json!("engineering"));
460
461        let mut ctx2 = make_security_context("alice", &["admin"], None, &[]);
462        ctx2.attributes.insert("department".to_string(), serde_json::json!("sales"));
463
464        let ctx_no_attrs = make_security_context("alice", &["admin"], None, &[]);
465
466        assert_ne!(
467            hash_security_context(Some(&ctx1)),
468            hash_security_context(Some(&ctx2)),
469            "Different attribute values must produce different hashes"
470        );
471        assert_ne!(
472            hash_security_context(Some(&ctx1)),
473            hash_security_context(Some(&ctx_no_attrs)),
474            "Attributes vs no attributes must produce different hashes"
475        );
476    }
477
478    // ========================================================================
479    // Invalidation Edge Cases
480    // ========================================================================
481
482    #[test]
483    fn test_invalidate_empty_views_is_noop() {
484        let cache = ResponseCache::new(enabled_config());
485        cache
486            .put(1, 0, Arc::new(serde_json::json!("r")), vec!["v_user".to_string()])
487            .expect("put");
488        cache.store.run_pending_tasks();
489
490        let invalidated = cache.invalidate_views(&[]).expect("invalidate empty");
491        assert_eq!(invalidated, 0);
492        assert!(cache.get(1, 0).expect("still cached").is_some());
493    }
494
495    #[test]
496    fn test_invalidate_nonexistent_view_is_noop() {
497        let cache = ResponseCache::new(enabled_config());
498        cache
499            .put(1, 0, Arc::new(serde_json::json!("r")), vec!["v_user".to_string()])
500            .expect("put");
501        cache.store.run_pending_tasks();
502
503        let invalidated = cache
504            .invalidate_views(&["v_nonexistent".to_string()])
505            .expect("invalidate nonexistent");
506        assert_eq!(invalidated, 0);
507        assert!(cache.get(1, 0).expect("still cached").is_some());
508    }
509
510    #[test]
511    fn test_invalidate_clears_all_security_contexts_for_view() {
512        let cache = ResponseCache::new(enabled_config());
513
514        // Same query, different users, same view
515        cache
516            .put(1, 100, Arc::new(serde_json::json!("admin")), vec!["v_user".to_string()])
517            .expect("put admin");
518        cache
519            .put(1, 200, Arc::new(serde_json::json!("user")), vec!["v_user".to_string()])
520            .expect("put user");
521        cache
522            .put(1, 0, Arc::new(serde_json::json!("anon")), vec!["v_user".to_string()])
523            .expect("put anon");
524        cache.store.run_pending_tasks();
525
526        let invalidated = cache.invalidate_views(&["v_user".to_string()]).expect("invalidate");
527        assert_eq!(invalidated, 3, "All entries for the view must be invalidated");
528
529        cache.store.run_pending_tasks();
530
531        assert!(cache.get(1, 100).expect("admin gone").is_none());
532        assert!(cache.get(1, 200).expect("user gone").is_none());
533        assert!(cache.get(1, 0).expect("anon gone").is_none());
534    }
535
536    #[test]
537    fn test_invalidate_multiple_views_at_once() {
538        let cache = ResponseCache::new(enabled_config());
539
540        cache
541            .put(1, 0, Arc::new(serde_json::json!("users")), vec!["v_user".to_string()])
542            .expect("put users");
543        cache
544            .put(2, 0, Arc::new(serde_json::json!("posts")), vec!["v_post".to_string()])
545            .expect("put posts");
546        cache
547            .put(3, 0, Arc::new(serde_json::json!("tags")), vec!["v_tag".to_string()])
548            .expect("put tags");
549        cache.store.run_pending_tasks();
550
551        let invalidated = cache
552            .invalidate_views(&["v_user".to_string(), "v_post".to_string()])
553            .expect("invalidate");
554        assert_eq!(invalidated, 2);
555
556        cache.store.run_pending_tasks();
557
558        assert!(cache.get(1, 0).expect("users gone").is_none());
559        assert!(cache.get(2, 0).expect("posts gone").is_none());
560        assert!(cache.get(3, 0).expect("tags alive").is_some());
561    }
562
563    #[test]
564    fn test_entry_with_multiple_views_invalidated_by_any() {
565        let cache = ResponseCache::new(enabled_config());
566
567        // Query reads from both v_user and v_post (e.g., a join)
568        cache
569            .put(
570                1,
571                0,
572                Arc::new(serde_json::json!("joined")),
573                vec!["v_user".to_string(), "v_post".to_string()],
574            )
575            .expect("put");
576        cache.store.run_pending_tasks();
577
578        // Invalidating either view should remove the entry
579        let invalidated = cache.invalidate_views(&["v_post".to_string()]).expect("invalidate");
580        assert_eq!(invalidated, 1);
581
582        cache.store.run_pending_tasks();
583        assert!(cache.get(1, 0).expect("gone").is_none());
584    }
585
586    // ========================================================================
587    // Response Cache Key Collision Avoidance
588    // ========================================================================
589
590    #[test]
591    fn test_different_query_keys_no_collision() {
592        let cache = ResponseCache::new(enabled_config());
593
594        cache
595            .put(1, 0, Arc::new(serde_json::json!("response_1")), vec![])
596            .expect("put q1");
597        cache
598            .put(2, 0, Arc::new(serde_json::json!("response_2")), vec![])
599            .expect("put q2");
600        cache.store.run_pending_tasks();
601
602        let r1 = cache.get(1, 0).expect("get q1").expect("q1 hit");
603        let r2 = cache.get(2, 0).expect("get q2").expect("q2 hit");
604
605        assert_eq!(*r1, serde_json::json!("response_1"));
606        assert_eq!(*r2, serde_json::json!("response_2"));
607    }
608
609    #[test]
610    fn test_same_query_key_different_security_no_collision() {
611        let cache = ResponseCache::new(enabled_config());
612
613        for sec_hash in 0_u64..10 {
614            cache
615                .put(
616                    42,
617                    sec_hash,
618                    Arc::new(serde_json::json!(format!("response_for_user_{sec_hash}"))),
619                    vec![],
620                )
621                .expect("put");
622        }
623        cache.store.run_pending_tasks();
624
625        for sec_hash in 0_u64..10 {
626            let r = cache.get(42, sec_hash).expect("get").expect("should be cached");
627            assert_eq!(*r, serde_json::json!(format!("response_for_user_{sec_hash}")));
628        }
629    }
630
631    // ========================================================================
632    // Helper: SecurityContext builder for tests
633    // ========================================================================
634
635    fn make_security_context(
636        user_id: &str,
637        roles: &[&str],
638        tenant_id: Option<&str>,
639        scopes: &[&str],
640    ) -> SecurityContext {
641        use chrono::Utc;
642        SecurityContext {
643            user_id:          user_id.to_string(),
644            roles:            roles.iter().map(|s| (*s).to_string()).collect(),
645            tenant_id:        tenant_id.map(str::to_string),
646            scopes:           scopes.iter().map(|s| (*s).to_string()).collect(),
647            attributes:       std::collections::HashMap::new(),
648            request_id:       "test-request".to_string(),
649            ip_address:       None,
650            authenticated_at: Utc::now(),
651            expires_at:       Utc::now() + chrono::Duration::hours(1),
652            issuer:           None,
653            audience:         None,
654        }
655    }
656}