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 the view index is a cheap atomic ref-count
73    /// bump rather than a fresh heap allocation.
74    accessed_views: Box<[ViewName]>,
75
76    /// Process-unique identity of this entry instance.
77    ///
78    /// See [`crate::cache::result`]'s module docs: the reverse index registers
79    /// `(key, epoch)` so the eviction listener deregisters the displaced entry
80    /// and never the live replacement under the same key (#740).
81    epoch: u64,
82}
83
84/// A reverse-index registration: the `(query_hash, security_hash)` cache key
85/// plus the epoch of the entry instance that registered it.
86type IndexRef = ((u64, u64), u64);
87
88/// Executor-level cache for projected GraphQL responses.
89///
90/// Stores the final serialized response keyed by `(query_hash, security_hash)`.
91/// On hit, the entire projection + RBAC + serialization pipeline is skipped.
92///
93/// # Thread Safety
94///
95/// `moka::sync::Cache` is `Send + Sync` with lock-free reads. The view reverse
96/// index uses `DashMap` (fine-grained shard locking). There is no global mutex
97/// on the read path.
98pub struct ResponseCache {
99    store: MokaCache<(u64, u64), Arc<ResponseEntry>>,
100
101    /// Reverse index: view name → set of `((query_hash, sec_hash), epoch)`.
102    ///
103    /// Maintained in `put()` and pruned by the moka eviction listener.
104    /// Enables O(k) invalidation without scanning the full cache.
105    /// Keyed by [`ViewName`] so the entry shares its `Arc<str>` allocation
106    /// with the names stored on each cache entry.
107    view_index: Arc<DashMap<ViewName, DashSet<IndexRef>>>,
108
109    enabled:    bool,
110    hits:       AtomicU64,
111    misses:     AtomicU64,
112    next_epoch: AtomicU64,
113
114    /// Bumped by every invalidation, so a read that straddled one can tell (#1079).
115    ///
116    /// Same defect and same fence as `QueryResultCache`: registering the key before
117    /// `store.insert` makes it *visible* to a concurrent `invalidate_views`, but does not
118    /// stop the insert that follows. Whole GraphQL responses are stranded here, not just
119    /// row sets, so fixing only the row cache would leave the user-visible half live.
120    invalidation_generation: AtomicU64,
121}
122
123impl ResponseCache {
124    /// Create a new response cache from configuration.
125    #[must_use]
126    pub fn new(config: ResponseCacheConfig) -> Self {
127        let view_index: Arc<DashMap<ViewName, DashSet<IndexRef>>> = Arc::new(DashMap::new());
128        let vi = Arc::clone(&view_index);
129
130        let mut builder = MokaCache::builder()
131            .max_capacity(config.max_entries as u64)
132            .eviction_listener(move |key: Arc<(u64, u64)>, value: Arc<ResponseEntry>, _cause| {
133                // Deregister this entry INSTANCE, not the key: a re-cached
134                // response is live under the same key while moka reports the
135                // displaced one, and pruning by key alone would detach it from
136                // every invalidation path (#740). `_cause` is deliberately
137                // unused — see `crate::cache::result`'s module docs.
138                let reference: IndexRef = (*key, value.epoch);
139                for view in &value.accessed_views {
140                    if let Some(keys) = vi.get(view) {
141                        keys.remove(&reference);
142                    }
143                }
144            });
145
146        if config.ttl_seconds > 0 {
147            builder = builder.time_to_live(Duration::from_secs(config.ttl_seconds));
148        }
149
150        let store = builder.build();
151
152        Self {
153            store,
154            view_index,
155            enabled: config.enabled,
156            hits: AtomicU64::new(0),
157            misses: AtomicU64::new(0),
158            next_epoch: AtomicU64::new(0),
159            invalidation_generation: AtomicU64::new(0),
160        }
161    }
162
163    /// Whether the response cache is enabled.
164    #[must_use]
165    pub const fn is_enabled(&self) -> bool {
166        self.enabled
167    }
168
169    /// Look up a cached response.
170    ///
171    /// # Errors
172    ///
173    /// This method is infallible with the moka backend and always returns `Ok`.
174    pub fn get(&self, query_key: u64, security_hash: u64) -> Result<Option<Arc<Value>>> {
175        if !self.enabled {
176            return Ok(None);
177        }
178
179        let key = (query_key, security_hash);
180        if let Some(entry) = self.store.get(&key) {
181            self.hits.fetch_add(1, Ordering::Relaxed);
182            Ok(Some(Arc::clone(&entry.response)))
183        } else {
184            self.misses.fetch_add(1, Ordering::Relaxed);
185            Ok(None)
186        }
187    }
188
189    /// Snapshot of the invalidation generation, for fencing a cache write (#1079).
190    ///
191    /// Take this before the work whose result is being cached, and pass it to
192    /// [`put`](Self::put).
193    #[must_use]
194    pub fn invalidation_generation(&self) -> u64 {
195        self.invalidation_generation.load(Ordering::Acquire)
196    }
197
198    /// Store a response in the cache.
199    ///
200    /// `fence` is a snapshot from [`invalidation_generation`](Self::invalidation_generation)
201    /// taken before the work that produced `response`. If an invalidation landed since,
202    /// the entry is discarded rather than stored — the caller keeps its response, it is
203    /// simply not cached. `None` stores unconditionally.
204    ///
205    /// # Errors
206    ///
207    /// This method is infallible with the moka backend and always returns `Ok`.
208    pub fn put(
209        &self,
210        query_key: u64,
211        security_hash: u64,
212        response: Arc<Value>,
213        accessed_views: Vec<String>,
214        fence: Option<u64>,
215    ) -> Result<()> {
216        if !self.enabled {
217            return Ok(());
218        }
219
220        // Cheap pre-check; the authoritative one is after registration (#1079).
221        if fence.is_some_and(|snapshot| snapshot != self.invalidation_generation()) {
222            return Ok(());
223        }
224
225        let key = (query_key, security_hash);
226
227        // Promote owned `String` view names into `ViewName(Arc<str>)` exactly
228        // once; the same Arc is then shared by `view_index` and the cached
229        // entry.
230        let accessed_views: Box<[ViewName]> =
231            accessed_views.into_iter().map(ViewName::from).collect();
232
233        // Update view → key reverse index before inserting into the store,
234        // so invalidate_views() called concurrently won't miss the key.
235        let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
236        let reference: IndexRef = (key, epoch);
237        for view in &accessed_views {
238            self.view_index.entry(view.clone()).or_default().insert(reference);
239        }
240
241        // The fence (#1079): authoritative check, after registration and before insert.
242        // Deregistration is symmetric with the eviction listener's, so no index row
243        // survives pointing at a key that was never stored.
244        if fence.is_some_and(|snapshot| snapshot != self.invalidation_generation()) {
245            for view in &accessed_views {
246                if let Some(keys) = self.view_index.get(view) {
247                    keys.remove(&reference);
248                }
249            }
250            return Ok(());
251        }
252
253        let entry = Arc::new(ResponseEntry {
254            response,
255            accessed_views,
256            epoch,
257        });
258
259        self.store.insert(key, entry);
260        Ok(())
261    }
262
263    /// Invalidate all entries that access any of the given views.
264    ///
265    /// Uses the O(k) reverse index — no full-cache scan.
266    ///
267    /// # Errors
268    ///
269    /// This method is infallible with the moka backend and always returns `Ok`.
270    pub fn invalidate_views(&self, views: &[ViewName]) -> Result<u64> {
271        // Bump before collecting keys (#1079) — the window may be too wide, never too narrow.
272        self.invalidation_generation.fetch_add(1, Ordering::Release);
273
274        // Dedup by cache key: an entry mid-replacement is registered under two
275        // epochs, and one reading several of `views` is registered once per view.
276        let mut to_remove: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new();
277
278        for view in views {
279            // ViewName: Borrow<str> — look up by &str without allocating a
280            // fresh ViewName.
281            if let Some(keys) = self.view_index.get(view.as_str()) {
282                for reference in keys.iter() {
283                    to_remove.insert(reference.0);
284                }
285            }
286        }
287
288        #[allow(clippy::cast_possible_truncation)]
289        // Reason: entry count never exceeds u64
290        let total = to_remove.len() as u64;
291        for key in to_remove {
292            self.store.invalidate(&key);
293            // The eviction listener handles index cleanup.
294        }
295
296        Ok(total)
297    }
298
299    /// Get cache hit/miss counts.
300    #[must_use]
301    pub fn metrics(&self) -> (u64, u64) {
302        (self.hits.load(Ordering::Relaxed), self.misses.load(Ordering::Relaxed))
303    }
304
305    /// Flush pending background tasks in the moka store.
306    ///
307    /// Used in tests to synchronise async eviction/invalidation before assertions.
308    #[cfg(test)]
309    pub(crate) fn run_pending_tasks(&self) {
310        self.store.run_pending_tasks();
311    }
312}
313
314/// Hash the security context fields that affect response content.
315///
316/// Fields hashed: `user_id`, roles (sorted), `tenant_id`, scopes (sorted),
317/// `attributes` (sorted keys + JSON-serialized values).
318///
319/// Fields NOT hashed: `request_id`, `ip_address`, `authenticated_at`, `expires_at`,
320/// `issuer`, `audience` — these don't affect which data the user can see.
321///
322/// `attributes` IS hashed because custom RLS policies can key on arbitrary
323/// attributes (e.g., "department", "region") to produce different query results
324/// for users who otherwise share the same `user_id`/roles/`tenant_id`/scopes.
325///
326/// Returns `0` when no security context is present (all users share one entry).
327#[must_use]
328pub fn hash_security_context(ctx: Option<&SecurityContext>) -> u64 {
329    use std::hash::{Hash, Hasher};
330
331    let Some(ctx) = ctx else {
332        return 0;
333    };
334
335    let mut hasher = ahash::AHasher::default();
336    ctx.user_id.hash(&mut hasher);
337
338    // Sort roles for determinism (JWT may present them in any order)
339    let mut sorted_roles = ctx.roles.clone();
340    sorted_roles.sort();
341    for role in &sorted_roles {
342        role.hash(&mut hasher);
343    }
344
345    ctx.tenant_id.hash(&mut hasher);
346
347    let mut sorted_scopes = ctx.scopes.clone();
348    sorted_scopes.sort();
349    for scope in &sorted_scopes {
350        scope.hash(&mut hasher);
351    }
352
353    // Hash attributes (custom RLS policies can key on these)
354    if !ctx.attributes.is_empty() {
355        let mut attr_keys: Vec<&String> = ctx.attributes.keys().collect();
356        attr_keys.sort();
357        for key in attr_keys {
358            key.hash(&mut hasher);
359            // Use JSON serialization for deterministic Value hashing
360            serde_json::to_string(&ctx.attributes[key])
361                .unwrap_or_default()
362                .hash(&mut hasher);
363        }
364    }
365
366    hasher.finish()
367}