fraiseql_core/cache/config.rs
1//! Cache configuration.
2//!
3//! Defines configuration options for the query result cache.
4//!
5//! # Important: Caching is Disabled by Default (v2.0.0-rc.12+)
6//!
7//! **FraiseQL v2.0.0-rc.12 changed the default: caching is now DISABLED.**
8//!
9//! FraiseQL uses precomputed views (tv_* tables) and optimized PostgreSQL queries
10//! that are typically faster than cache overhead for most use cases. Caching only
11//! provides benefit for specific scenarios (see below).
12//!
13//! # When to Enable Caching
14//!
15//! Enable caching (`enabled = true`) **only** if you have:
16//!
17//! 1. **Federation with slow external services** (>100ms response times)
18//! 2. **Expensive computations** not covered by precomputed views
19//! 3. **Very high-frequency repeated queries** (>1000 QPS with identical parameters)
20//!
21//! # When NOT to Enable Caching
22//!
23//! Don't enable caching for:
24//! - Simple lookups (faster to query PostgreSQL directly)
25//! - Standard CRUD operations on precomputed views
26//! - Low-traffic applications (<100 QPS)
27//! - Any workload where Issue #40 analysis applies
28//!
29//! # Configuration Examples
30//!
31//! **Default (recommended for most deployments):**
32//! ```rust
33//! use fraiseql_core::cache::CacheConfig;
34//!
35//! let config = CacheConfig::default(); // enabled = false
36//! ```
37//!
38//! **Federation with external services:**
39//! ```rust
40//! use fraiseql_core::cache::CacheConfig;
41//!
42//! let config = CacheConfig::enabled();
43//! ```
44//!
45//! **Custom cache size (if enabled):**
46//! ```rust
47//! use fraiseql_core::cache::CacheConfig;
48//!
49//! let config = CacheConfig {
50//! enabled: true,
51//! max_entries: 5_000,
52//! ttl_seconds: 3_600, // 1 hour
53//! cache_list_queries: true,
54//! ..Default::default()
55//! };
56//! ```
57//!
58//! # Memory Estimates (if enabled)
59//!
60//! - **1,000 entries**: ~10 MB
61//! - **10,000 entries**: ~100 MB
62//! - **50,000 entries**: ~500 MB
63//!
64//! Actual memory usage depends on query result sizes.
65
66use serde::{Deserialize, Serialize};
67
68/// Controls what happens when caching is enabled in a multi-tenant deployment but
69/// Row-Level Security does not appear to be active.
70///
71/// Configure via `rls_enforcement` in `CacheConfig` or `fraiseql.toml`.
72///
73/// # Security implication
74///
75/// Without RLS, all authenticated users sharing the same query and variables will
76/// receive the **same cached response**, potentially leaking data across tenants.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79#[non_exhaustive]
80pub enum RlsEnforcement {
81 /// Refuse server startup if RLS appears inactive (default, safest).
82 ///
83 /// Use this in production to prevent silent cross-tenant data leakage.
84 #[default]
85 Error,
86
87 /// Log a warning and continue if RLS appears inactive.
88 ///
89 /// Use during migration or for non-critical workloads.
90 Warn,
91
92 /// Skip the RLS check entirely.
93 ///
94 /// Use for single-tenant deployments where RLS is not needed.
95 Off,
96}
97
98/// Cache configuration - **disabled by default** as of v2.0.0-rc.12.
99///
100/// FraiseQL's architecture (precomputed views + optimized PostgreSQL) makes
101/// caching unnecessary for most use cases. Enable only for federation or
102/// expensive computations.
103///
104/// # Key Changes in rc.12
105///
106/// - `enabled` now defaults to `false` (was `true`)
107/// - `with_max_entries()` and `with_ttl()` also set `enabled: false`
108/// - New `enabled()` constructor for explicit opt-in
109///
110/// See module documentation for detailed guidance.
111#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
112pub struct CacheConfig {
113 /// Enable response caching.
114 ///
115 /// **Default: `false`** (changed from `true` in v2.0.0-rc.12)
116 ///
117 /// Enable only for:
118 /// - Federation with slow external services
119 /// - Expensive computations not covered by precomputed views
120 /// - High-frequency repeated queries with identical parameters
121 ///
122 /// See Issue #40 for performance analysis.
123 pub enabled: bool,
124
125 /// Maximum number of cached entries.
126 ///
127 /// When this limit is reached, the least-recently-used (LRU) entry is evicted
128 /// to make room for new entries. This hard limit prevents unbounded memory growth.
129 ///
130 /// Recommended values:
131 /// - Development: 1,000
132 /// - Production (small): 10,000
133 /// - Production (large): 50,000
134 ///
135 /// Default: 10,000 entries (~100 MB estimated memory)
136 pub max_entries: usize,
137
138 /// Time-to-live (TTL) in seconds for cached entries.
139 ///
140 /// Entries older than this are considered expired and will be removed on next access.
141 /// This acts as a safety net for cases where invalidation might be missed (e.g.,
142 /// database changes outside of mutations).
143 ///
144 /// Recommended values:
145 /// - Development: 3,600 (1 hour)
146 /// - Production: 86,400 (24 hours)
147 /// - Long-lived data: 604,800 (7 days)
148 ///
149 /// Default: 86,400 seconds (24 hours)
150 pub ttl_seconds: u64,
151
152 /// Whether to cache list queries.
153 ///
154 /// List queries (e.g., `users(limit: 100)`) can have large result sets that
155 /// consume significant memory. Set to `false` to only cache single-object queries
156 /// (results with a single row). Results with more than one row are skipped.
157 ///
158 /// Default: `true`
159 pub cache_list_queries: bool,
160
161 /// Row-Level Security enforcement mode for multi-tenant deployments.
162 ///
163 /// When caching is enabled alongside a multi-tenant schema (detected via
164 /// `is_multi_tenant()` on the compiled schema), FraiseQL checks that RLS is active.
165 /// Without RLS, two users sharing the same query may receive each other's data
166 /// from the cache.
167 ///
168 /// | Mode | Behaviour |
169 /// |------|-----------|
170 /// | `Error` | Server refuses to start (default, safest) |
171 /// | `Warn` | Logs a warning and continues |
172 /// | `Off` | Skips the check (single-tenant deployments) |
173 ///
174 /// Default: [`RlsEnforcement::Error`]
175 #[serde(default)]
176 pub rls_enforcement: RlsEnforcement,
177
178 /// Maximum bytes for a single cache entry. Entries exceeding this are silently skipped.
179 ///
180 /// Prevents a single oversized response from consuming a disproportionate share of
181 /// the cache. The size is estimated by serializing the result to JSON and measuring
182 /// the byte length.
183 ///
184 /// Default: `None` (no per-entry limit). Suggested value: 10 MB (`10_485_760`).
185 #[serde(default)]
186 pub max_entry_bytes: Option<usize>,
187
188 /// Maximum total bytes across all cache entries. Triggers LRU eviction when exceeded.
189 ///
190 /// When set, `put()` checks whether adding the new entry would exceed the budget.
191 /// If the budget is already exceeded the entry is silently skipped (the LRU count
192 /// limit continues to apply independently).
193 ///
194 /// Default: `None` (no total limit). Suggested value: 1 GB (`1_073_741_824`).
195 #[serde(default)]
196 pub max_total_bytes: Option<usize>,
197}
198
199impl Default for CacheConfig {
200 /// Default cache configuration - **DISABLED by default** as of v2.0.0-rc.12.
201 ///
202 /// FraiseQL uses precomputed views (tv_* tables) and optimized PostgreSQL queries
203 /// that are typically faster than cache overhead for most use cases.
204 ///
205 /// Enable caching ONLY if you have:
206 /// - Federation with slow external services
207 /// - Expensive computations not covered by precomputed views
208 /// - High-frequency repeated queries (>1000 QPS with same params)
209 ///
210 /// See Issue #40 for performance analysis.
211 ///
212 /// # Current Default
213 /// - **Caching: DISABLED**
214 /// - 10,000 max entries (~100 MB if enabled)
215 /// - 24 hour TTL
216 /// - List queries cached (when enabled)
217 fn default() -> Self {
218 Self {
219 enabled: false, // CHANGED in rc.12: Disabled by default
220 max_entries: 10_000,
221 ttl_seconds: 86_400, // 24 hours
222 cache_list_queries: true,
223 rls_enforcement: RlsEnforcement::Error,
224 max_entry_bytes: None,
225 max_total_bytes: None,
226 }
227 }
228}
229
230impl CacheConfig {
231 /// Create cache configuration with custom max entries.
232 ///
233 /// Uses default values for other fields (**enabled=false**, 24h TTL).
234 ///
235 /// # Arguments
236 ///
237 /// * `max_entries` - Maximum number of entries in cache
238 ///
239 /// # Example
240 ///
241 /// ```rust
242 /// use fraiseql_core::cache::CacheConfig;
243 ///
244 /// let config = CacheConfig::with_max_entries(50_000);
245 /// assert_eq!(config.max_entries, 50_000);
246 /// assert!(!config.enabled); // Disabled by default
247 /// ```
248 #[must_use]
249 pub const fn with_max_entries(max_entries: usize) -> Self {
250 Self {
251 enabled: false, // Consistent with new default
252 max_entries,
253 ttl_seconds: 86_400,
254 cache_list_queries: true,
255 rls_enforcement: RlsEnforcement::Error,
256 max_entry_bytes: None,
257 max_total_bytes: None,
258 }
259 }
260
261 /// Create cache configuration with custom TTL.
262 ///
263 /// Uses default values for other fields (**enabled=false**, 10,000 entries).
264 ///
265 /// # Arguments
266 ///
267 /// * `ttl_seconds` - Time-to-live in seconds
268 ///
269 /// # Example
270 ///
271 /// ```rust
272 /// use fraiseql_core::cache::CacheConfig;
273 ///
274 /// let config = CacheConfig::with_ttl(3_600); // 1 hour
275 /// assert_eq!(config.ttl_seconds, 3_600);
276 /// assert!(!config.enabled); // Disabled by default
277 /// ```
278 #[must_use]
279 pub const fn with_ttl(ttl_seconds: u64) -> Self {
280 Self {
281 enabled: false, // Consistent with new default
282 max_entries: 10_000,
283 ttl_seconds,
284 cache_list_queries: true,
285 rls_enforcement: RlsEnforcement::Error,
286 max_entry_bytes: None,
287 max_total_bytes: None,
288 }
289 }
290
291 /// Create cache configuration with caching **enabled**.
292 ///
293 /// Use this method when you explicitly need caching (e.g., federation,
294 /// expensive computations). Most FraiseQL deployments don't need this.
295 ///
296 /// # Example
297 ///
298 /// ```rust
299 /// use fraiseql_core::cache::CacheConfig;
300 ///
301 /// let config = CacheConfig::enabled();
302 /// assert!(config.enabled);
303 /// assert_eq!(config.max_entries, 10_000);
304 /// ```
305 #[must_use]
306 pub const fn enabled() -> Self {
307 Self {
308 enabled: true,
309 max_entries: 10_000,
310 ttl_seconds: 86_400,
311 cache_list_queries: true,
312 rls_enforcement: RlsEnforcement::Error,
313 max_entry_bytes: None,
314 max_total_bytes: None,
315 }
316 }
317
318 /// Create cache configuration with caching disabled.
319 ///
320 /// This is now the **default behavior**. Use this method for explicit clarity
321 /// or to override a previously enabled configuration.
322 ///
323 /// # Example
324 ///
325 /// ```rust
326 /// use fraiseql_core::cache::CacheConfig;
327 ///
328 /// let config = CacheConfig::disabled();
329 /// assert!(!config.enabled);
330 /// ```
331 #[must_use]
332 pub const fn disabled() -> Self {
333 Self {
334 enabled: false,
335 max_entries: 10_000,
336 ttl_seconds: 86_400,
337 cache_list_queries: true,
338 rls_enforcement: RlsEnforcement::Error,
339 max_entry_bytes: None,
340 max_total_bytes: None,
341 }
342 }
343
344 /// Estimate memory usage in bytes for this configuration.
345 ///
346 /// This is a rough estimate assuming average entry size of 10 KB.
347 /// Actual memory usage will vary based on query result sizes.
348 ///
349 /// # Returns
350 ///
351 /// Estimated memory usage in bytes
352 ///
353 /// # Example
354 ///
355 /// ```rust
356 /// use fraiseql_core::cache::CacheConfig;
357 ///
358 /// let config = CacheConfig::default();
359 /// let estimated_bytes = config.estimated_memory_bytes();
360 /// println!("Estimated memory: {} MB", estimated_bytes / 1_000_000);
361 /// ```
362 #[must_use]
363 pub const fn estimated_memory_bytes(&self) -> usize {
364 // Rough estimate: 10 KB per entry
365 const AVG_ENTRY_SIZE_BYTES: usize = 10_000;
366 self.max_entries * AVG_ENTRY_SIZE_BYTES
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
373
374 use super::*;
375
376 #[test]
377 fn test_default_config() {
378 let config = CacheConfig::default();
379 assert!(!config.enabled); // Disabled by default as of rc.12
380 assert_eq!(config.max_entries, 10_000);
381 assert_eq!(config.ttl_seconds, 86_400);
382 assert!(config.cache_list_queries);
383 }
384
385 #[test]
386 fn test_with_max_entries() {
387 let config = CacheConfig::with_max_entries(50_000);
388 assert_eq!(config.max_entries, 50_000);
389 assert!(!config.enabled); // Disabled by default as of rc.12
390 assert_eq!(config.ttl_seconds, 86_400);
391 }
392
393 #[test]
394 fn test_with_ttl() {
395 let config = CacheConfig::with_ttl(3_600);
396 assert_eq!(config.ttl_seconds, 3_600);
397 assert!(!config.enabled); // Disabled by default as of rc.12
398 assert_eq!(config.max_entries, 10_000);
399 }
400
401 #[test]
402 fn test_enabled() {
403 let config = CacheConfig::enabled();
404 assert!(config.enabled);
405 assert_eq!(config.max_entries, 10_000);
406 assert_eq!(config.ttl_seconds, 86_400);
407 }
408
409 #[test]
410 fn test_disabled() {
411 let config = CacheConfig::disabled();
412 assert!(!config.enabled);
413 }
414
415 #[test]
416 fn test_estimated_memory() {
417 let config = CacheConfig::with_max_entries(10_000);
418 let estimated = config.estimated_memory_bytes();
419 // Should be roughly 100 MB (10,000 * 10 KB)
420 assert_eq!(estimated, 100_000_000);
421 }
422
423 #[test]
424 fn test_serialization() {
425 let config = CacheConfig::default();
426 let json = serde_json::to_string(&config).unwrap();
427 let deserialized: CacheConfig = serde_json::from_str(&json).unwrap();
428
429 assert_eq!(config.enabled, deserialized.enabled);
430 assert_eq!(config.max_entries, deserialized.max_entries);
431 assert_eq!(config.ttl_seconds, deserialized.ttl_seconds);
432 }
433}