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}