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}