fraiseql_core/cache/mod.rs
1//! Query result caching for FraiseQL v2.
2//!
3//! # Overview
4//!
5//! This module provides transparent W-TinyLFU query result caching with view-based
6//! and entity-based invalidation. Cache entries are automatically invalidated when
7//! mutations modify the underlying data.
8//!
9//! # Scope
10//!
11//! - **W-TinyLFU result caching** with per-entry TTL (via moka)
12//! - **Lock-free reads** — cache hits do not acquire any shared lock
13//! - **View-based invalidation** and **entity-based invalidation** via O(k) reverse indexes
14//! - **Security-aware cache key generation** (prevents data leakage)
15//! - **Integration with `DatabaseAdapter`** via wrapper
16//!
17//! # Architecture
18//!
19//! ```text
20//! ┌─────────────────────┐
21//! │ GraphQL Query │
22//! │ + Variables │
23//! │ + WHERE Clause │
24//! └──────────┬──────────┘
25//! │
26//! ↓ generate_cache_key()
27//! ┌─────────────────────┐
28//! │ ahash Cache Key │ ← Includes variables for security
29//! └──────────┬──────────┘
30//! │
31//! ↓ QueryResultCache::get()
32//! ┌─────────────────────┐
33//! │ Cache Hit? │
34//! │ - Check TTL (moka) │
35//! │ - W-TinyLFU policy │
36//! └──────────┬──────────┘
37//! │
38//! ┌─────┴─────┐
39//! │ │
40//! HIT MISS
41//! │ │
42//! ↓ ↓ execute_query()
43//! Return Database Query
44//! Cached + Store Result
45//! Result + Track Views
46//!
47//! Mutation:
48//! ┌─────────────────────┐
49//! │ Mutation executed │
50//! │ "createUser" │
51//! └──────────┬──────────┘
52//! │
53//! ↓ InvalidationContext::for_mutation()
54//! ┌─────────────────────┐
55//! │ Modified Views: │
56//! │ - v_user │
57//! └──────────┬──────────┘
58//! │
59//! ↓ cache.invalidate_views()
60//! ┌─────────────────────┐
61//! │ Remove all caches │
62//! │ reading from v_user │
63//! └─────────────────────┘
64//! ```
65//!
66//! # Configuration
67//!
68//! ```rust
69//! use fraiseql_core::cache::CacheConfig;
70//!
71//! // Production configuration
72//! let config = CacheConfig {
73//! enabled: true,
74//! max_entries: 50_000,
75//! ttl_seconds: 86_400, // 24 hours
76//! cache_list_queries: true,
77//! ..Default::default()
78//! };
79//!
80//! // Development (disable for deterministic tests)
81//! let config = CacheConfig::disabled();
82//! ```
83//!
84//! # Usage Example
85//!
86//! ```no_run
87//! use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig, InvalidationContext};
88//! use fraiseql_core::db::{postgres::PostgresAdapter, DatabaseAdapter};
89//!
90//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
91//! // Create database adapter
92//! let db_adapter = PostgresAdapter::new("postgresql://localhost/db").await?;
93//!
94//! // Wrap with caching
95//! let cache = QueryResultCache::new(CacheConfig::default());
96//! let adapter = CachedDatabaseAdapter::new(
97//! db_adapter,
98//! cache,
99//! "1.0.0".to_string() // schema version
100//! );
101//!
102//! // Use as normal DatabaseAdapter - caching is transparent
103//! let users = adapter
104//! .execute_where_query("v_user", None, Some(10), None, None)
105//! .await?;
106//!
107//! println!("Found {} users", users.len());
108//!
109//! // After mutation, invalidate
110//! let invalidation = InvalidationContext::for_mutation(
111//! "createUser",
112//! vec!["v_user".to_string()]
113//! );
114//! let views: Vec<fraiseql_db::ViewName> = invalidation
115//! .modified_views
116//! .iter()
117//! .map(fraiseql_db::ViewName::from)
118//! .collect();
119//! adapter.invalidate_views(&views)?;
120//! # Ok(())
121//! # }
122//! ```
123//!
124//! # Performance
125//!
126//! - **Cache hit latency**: ~0.05ms (P99 < 0.5ms) — lock-free read path
127//! - **Expected hit rate**: 60-80% for typical workloads (higher than LRU under skewed access)
128//! - **Memory usage**: ~100 MB for default config (10,000 entries @ 10 KB avg)
129//! - **Speedup**: 50-200x faster than database queries
130//!
131//! # Security
132//!
133//! Cache keys include variable values to prevent data leakage between users.
134//! Different users with different query variables get different cache entries.
135//!
136//! **Example**:
137//! ```text
138//! User A: query { user(id: 1) } → Cache key: abc123...
139//! User B: query { user(id: 2) } → Cache key: def456... (DIFFERENT)
140//! ```
141//!
142//! This prevents User B from accidentally seeing User A's cached data.
143//!
144//! # View-Based Invalidation
145//!
146//! Invalidation operates at the **view/table level**:
147//!
148//! - **Mutation modifies `v_user`** → Invalidate ALL caches reading from `v_user`
149//! - **Expected hit rate**: 60-70% (some over-invalidation)
150//!
151//! **Example**:
152//! ```text
153//! Cache Entry 1: query { user(id: 1) } → reads v_user
154//! Cache Entry 2: query { user(id: 2) } → reads v_user
155//! Cache Entry 3: query { post(id: 100) } → reads v_post
156//!
157//! Mutation: updateUser(id: 1)
158//! → Invalidates Entry 1 AND Entry 2 (even though Entry 2 not affected)
159//! → Entry 3 remains cached
160//! ```
161//!
162//! # Cache Security Requirements
163//!
164//! The cache is safe in single-tenant deployments with no additional configuration.
165//! In **multi-tenant deployments**, two requirements must be met to prevent data
166//! leakage between tenants:
167//!
168//! 1. **Row-Level Security (RLS) must be active.** The cache key includes the per-request WHERE
169//! clause injected by FraiseQL's RLS policy engine. Different users with different RLS
170//! predicates receive different cache entries. If RLS is disabled or returns an empty clause,
171//! all users share the same key for identical queries and variables — Tenant A's data appears in
172//! Tenant B's responses.
173//!
174//! 2. **Schema content hash must be used as the schema version.** Use
175//! `CompiledSchema::content_hash()` (not `env!("CARGO_PKG_VERSION")`) when constructing
176//! `CachedDatabaseAdapter`. This ensures that any schema change automatically invalidates all
177//! cached entries, preventing stale-schema hits after deployment.
178//!
179//! The server emits a startup `warn!` when caching is enabled but no RLS policies
180//! are declared in the compiled schema. This warning is informational in
181//! single-tenant deployments and a critical security indicator in multi-tenant ones.
182//!
183//! # Future Enhancements
184//!
185//! - **Entity-level tracking**: Track by `User:123`, not just `v_user`
186//! - **Cascade integration**: Parse mutation metadata for precise invalidation
187//! - **Selective invalidation**: Only invalidate affected entity IDs
188//! - **Expected hit rate**: 90-95% with entity-level tracking
189//!
190//! # Module Organization
191//!
192//! - **`adapter`**: `CachedDatabaseAdapter` wrapper for transparent caching
193//! - **`config`**: Cache configuration with memory-safe bounds
194//! - **`key`**: Security-critical cache key generation (includes APQ integration)
195//! - **`result`**: W-TinyLFU cache storage (moka) with per-entry TTL, reverse indexes, and metrics
196//! - **`dependency_tracker`**: Bidirectional view↔cache mapping
197//! - **`invalidation`**: Public invalidation API with structured contexts
198
199mod adapter;
200mod config;
201mod dependency_tracker;
202mod fact_table_cache;
203mod invalidation;
204mod invalidation_api;
205mod key;
206mod relay_cache;
207pub mod response_cache;
208mod result;
209
210// Cascading invalidation with transitive dependencies
211pub mod cascade_invalidator;
212
213// Entity-level caching modules
214pub mod cascade_metadata;
215pub mod cascade_response_parser;
216pub mod entity_key;
217pub mod query_analyzer;
218pub mod uuid_extractor;
219
220// Fact table aggregation caching
221pub mod fact_table_version;
222
223// Public exports
224//
225// Re-export `ViewName` from `fraiseql-db` so consumers of the cache API can
226// import the typed view-name newtype alongside `QueryResultCache` etc. without
227// pulling in `fraiseql-db` directly.
228pub use adapter::{CachedDatabaseAdapter, view_name_to_entity_type};
229pub use cascade_invalidator::{CascadeInvalidator, InvalidationStats};
230pub use cascade_metadata::CascadeMetadata;
231pub use cascade_response_parser::CascadeResponseParser;
232pub use config::{CacheConfig, RlsEnforcement};
233// Export dependency tracker (used in doctests and advanced use cases)
234pub use dependency_tracker::DependencyTracker;
235pub use entity_key::EntityKey;
236pub use fact_table_version::{
237 FactTableCacheConfig, FactTableVersionProvider, FactTableVersionStrategy, VERSION_TABLE_SCHEMA,
238};
239pub use fraiseql_db::ViewName;
240pub use invalidation::{InvalidationContext, InvalidationReason};
241pub use key::{
242 extract_accessed_views, generate_cache_key, generate_projection_query_key,
243 generate_view_query_key,
244};
245pub use query_analyzer::{QueryAnalyzer, QueryCardinality, QueryEntityProfile};
246pub use response_cache::{ResponseCache, ResponseCacheConfig};
247pub use result::{CacheMetrics, CachedResult, QueryResultCache};
248pub use uuid_extractor::UUIDExtractor;
249
250#[cfg(test)]
251mod tests;