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 fraiseql_db::ViewName;
33use moka::sync::Cache as MokaCache;
34use serde_json::Value;
35
36use crate::{error::Result, security::SecurityContext};
37
38/// Configuration for the response cache.
39#[derive(Debug, Clone, Copy)]
40pub struct ResponseCacheConfig {
41    /// Enable the response cache.
42    pub enabled: bool,
43
44    /// Maximum number of cached responses.
45    pub max_entries: usize,
46
47    /// TTL in seconds (0 = no time-based expiry, live until invalidated).
48    pub ttl_seconds: u64,
49}
50
51impl Default for ResponseCacheConfig {
52    fn default() -> Self {
53        Self {
54            enabled:     false, // opt-in
55            max_entries: 10_000,
56            ttl_seconds: 300,
57        }
58    }
59}
60
61/// Per-entry value stored in moka alongside the response value.
62///
63/// Contains the accessed views so the eviction listener can clean up
64/// the reverse index when an entry is evicted by TTL or LFU policy.
65struct ResponseEntry {
66    /// The projected GraphQL response value.
67    response: Arc<Value>,
68
69    /// Views accessed by this query (for invalidation).
70    ///
71    /// Stored as a boxed slice of [`ViewName`] (each backed by `Arc<str>`)
72    /// so cloning a name into [`Self::view_index`] is a cheap atomic
73    /// ref-count bump rather than a fresh heap allocation.
74    accessed_views: Box<[ViewName]>,
75}
76
77/// Executor-level cache for projected GraphQL responses.
78///
79/// Stores the final serialized response keyed by `(query_hash, security_hash)`.
80/// On hit, the entire projection + RBAC + serialization pipeline is skipped.
81///
82/// # Thread Safety
83///
84/// `moka::sync::Cache` is `Send + Sync` with lock-free reads. The view reverse
85/// index uses `DashMap` (fine-grained shard locking). There is no global mutex
86/// on the read path.
87pub struct ResponseCache {
88    store: MokaCache<(u64, u64), Arc<ResponseEntry>>,
89
90    /// Reverse index: view name → set of `(query_hash, sec_hash)` keys.
91    ///
92    /// Maintained in `put()` and pruned by the moka eviction listener.
93    /// Enables O(k) invalidation without scanning the full cache.
94    /// Keyed by [`ViewName`] so the entry shares its `Arc<str>` allocation
95    /// with the names stored on each cache entry.
96    view_index: Arc<DashMap<ViewName, DashSet<(u64, u64)>>>,
97
98    enabled: bool,
99    hits:    AtomicU64,
100    misses:  AtomicU64,
101}
102
103impl ResponseCache {
104    /// Create a new response cache from configuration.
105    #[must_use]
106    pub fn new(config: ResponseCacheConfig) -> Self {
107        let view_index: Arc<DashMap<ViewName, DashSet<(u64, u64)>>> = Arc::new(DashMap::new());
108        let vi = Arc::clone(&view_index);
109
110        let mut builder = MokaCache::builder()
111            .max_capacity(config.max_entries as u64)
112            .eviction_listener(move |key: Arc<(u64, u64)>, value: Arc<ResponseEntry>, _cause| {
113                for view in &value.accessed_views {
114                    if let Some(keys) = vi.get(view) {
115                        keys.remove(&*key);
116                    }
117                }
118            });
119
120        if config.ttl_seconds > 0 {
121            builder = builder.time_to_live(Duration::from_secs(config.ttl_seconds));
122        }
123
124        let store = builder.build();
125
126        Self {
127            store,
128            view_index,
129            enabled: config.enabled,
130            hits: AtomicU64::new(0),
131            misses: AtomicU64::new(0),
132        }
133    }
134
135    /// Whether the response cache is enabled.
136    #[must_use]
137    pub const fn is_enabled(&self) -> bool {
138        self.enabled
139    }
140
141    /// Look up a cached response.
142    ///
143    /// # Errors
144    ///
145    /// This method is infallible with the moka backend and always returns `Ok`.
146    pub fn get(&self, query_key: u64, security_hash: u64) -> Result<Option<Arc<Value>>> {
147        if !self.enabled {
148            return Ok(None);
149        }
150
151        let key = (query_key, security_hash);
152        if let Some(entry) = self.store.get(&key) {
153            self.hits.fetch_add(1, Ordering::Relaxed);
154            Ok(Some(Arc::clone(&entry.response)))
155        } else {
156            self.misses.fetch_add(1, Ordering::Relaxed);
157            Ok(None)
158        }
159    }
160
161    /// Store a response in the cache.
162    ///
163    /// # Errors
164    ///
165    /// This method is infallible with the moka backend and always returns `Ok`.
166    pub fn put(
167        &self,
168        query_key: u64,
169        security_hash: u64,
170        response: Arc<Value>,
171        accessed_views: Vec<String>,
172    ) -> Result<()> {
173        if !self.enabled {
174            return Ok(());
175        }
176
177        let key = (query_key, security_hash);
178
179        // Promote owned `String` view names into `ViewName(Arc<str>)` exactly
180        // once; the same Arc is then shared by `view_index` and the cached
181        // entry.
182        let accessed_views: Box<[ViewName]> =
183            accessed_views.into_iter().map(ViewName::from).collect();
184
185        // Update view → key reverse index before inserting into the store,
186        // so invalidate_views() called concurrently won't miss the key.
187        for view in &accessed_views {
188            self.view_index.entry(view.clone()).or_default().insert(key);
189        }
190
191        let entry = Arc::new(ResponseEntry {
192            response,
193            accessed_views,
194        });
195
196        self.store.insert(key, entry);
197        Ok(())
198    }
199
200    /// Invalidate all entries that access any of the given views.
201    ///
202    /// Uses the O(k) reverse index — no full-cache scan.
203    ///
204    /// # Errors
205    ///
206    /// This method is infallible with the moka backend and always returns `Ok`.
207    pub fn invalidate_views(&self, views: &[ViewName]) -> Result<u64> {
208        let mut total = 0_u64;
209
210        for view in views {
211            // ViewName: Borrow<str> — look up by &str without allocating a
212            // fresh ViewName.
213            if let Some(keys) = self.view_index.get(view.as_str()) {
214                let to_remove: Vec<(u64, u64)> = keys.iter().map(|k| *k).collect();
215                drop(keys);
216                for key in to_remove {
217                    self.store.invalidate(&key);
218                    // The eviction listener handles index cleanup.
219                    total += 1;
220                }
221            }
222        }
223
224        Ok(total)
225    }
226
227    /// Get cache hit/miss counts.
228    #[must_use]
229    pub fn metrics(&self) -> (u64, u64) {
230        (self.hits.load(Ordering::Relaxed), self.misses.load(Ordering::Relaxed))
231    }
232
233    /// Flush pending background tasks in the moka store.
234    ///
235    /// Used in tests to synchronise async eviction/invalidation before assertions.
236    #[cfg(test)]
237    pub(crate) fn run_pending_tasks(&self) {
238        self.store.run_pending_tasks();
239    }
240}
241
242/// Hash the security context fields that affect response content.
243///
244/// Fields hashed: `user_id`, roles (sorted), `tenant_id`, scopes (sorted),
245/// `attributes` (sorted keys + JSON-serialized values).
246///
247/// Fields NOT hashed: `request_id`, `ip_address`, `authenticated_at`, `expires_at`,
248/// `issuer`, `audience` — these don't affect which data the user can see.
249///
250/// `attributes` IS hashed because custom RLS policies can key on arbitrary
251/// attributes (e.g., "department", "region") to produce different query results
252/// for users who otherwise share the same `user_id`/roles/`tenant_id`/scopes.
253///
254/// Returns `0` when no security context is present (all users share one entry).
255#[must_use]
256pub fn hash_security_context(ctx: Option<&SecurityContext>) -> u64 {
257    use std::hash::{Hash, Hasher};
258
259    let Some(ctx) = ctx else {
260        return 0;
261    };
262
263    let mut hasher = ahash::AHasher::default();
264    ctx.user_id.hash(&mut hasher);
265
266    // Sort roles for determinism (JWT may present them in any order)
267    let mut sorted_roles = ctx.roles.clone();
268    sorted_roles.sort();
269    for role in &sorted_roles {
270        role.hash(&mut hasher);
271    }
272
273    ctx.tenant_id.hash(&mut hasher);
274
275    let mut sorted_scopes = ctx.scopes.clone();
276    sorted_scopes.sort();
277    for scope in &sorted_scopes {
278        scope.hash(&mut hasher);
279    }
280
281    // Hash attributes (custom RLS policies can key on these)
282    if !ctx.attributes.is_empty() {
283        let mut attr_keys: Vec<&String> = ctx.attributes.keys().collect();
284        attr_keys.sort();
285        for key in attr_keys {
286            key.hash(&mut hasher);
287            // Use JSON serialization for deterministic Value hashing
288            serde_json::to_string(&ctx.attributes[key])
289                .unwrap_or_default()
290                .hash(&mut hasher);
291        }
292    }
293
294    hasher.finish()
295}