Skip to main content

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}