Skip to main content

fraiseql_core/compiler/
compilation_cache.rs

1//! Query compilation result caching with LRU eviction.
2//!
3//! This module provides caching for schema compilation results, avoiding redundant
4//! compilation of identical schemas. Uses fingerprinting (SHA-256) for cache key generation.
5
6use std::{
7    num::NonZeroUsize,
8    sync::{Arc, Mutex},
9};
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::{error::Result, schema::CompiledSchema};
15
16/// Cache entry for compiled schema with metadata.
17#[derive(Debug, Clone)]
18pub struct CachedCompilation {
19    /// The compiled schema result.
20    pub schema: Arc<CompiledSchema>,
21
22    /// Number of cache hits for this entry.
23    pub hit_count: u64,
24}
25
26/// Configuration for compilation cache.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CompilationCacheConfig {
29    /// Enable compilation caching.
30    pub enabled: bool,
31
32    /// Maximum number of compiled schemas to cache.
33    pub max_entries: usize,
34}
35
36impl Default for CompilationCacheConfig {
37    fn default() -> Self {
38        Self {
39            enabled:     true,
40            max_entries: 100,
41        }
42    }
43}
44
45impl CompilationCacheConfig {
46    /// Create disabled cache configuration.
47    ///
48    /// Useful for deterministic testing where compilation should always occur.
49    #[must_use]
50    pub const fn disabled() -> Self {
51        Self {
52            enabled:     false,
53            max_entries: 0,
54        }
55    }
56}
57
58/// Thread-safe LRU cache for compiled schemas.
59///
60/// # Design
61///
62/// - **Fingerprinting**: Uses SHA-256 hash of schema JSON for cache keys
63/// - **LRU eviction**: Automatically evicts least-recently-used entries
64/// - **Thread-safe**: All operations use interior mutability
65///
66/// # Memory Safety
67///
68/// - Hard LRU limit ensures bounded memory usage
69/// - Default config: 100 entries (reasonable for most deployments)
70///
71/// # Example
72///
73/// ```rust,no_run
74/// use fraiseql_core::compiler::compilation_cache::{CompilationCache, CompilationCacheConfig};
75/// use fraiseql_core::compiler::Compiler;
76///
77/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
78/// let cache = CompilationCache::new(CompilationCacheConfig::default());
79/// let compiler = Compiler::new();
80///
81/// let schema_json = r#"{"types": [], "queries": []}"#;
82///
83/// // First compilation - cache miss
84/// let compiled = cache.compile(&compiler, schema_json)?;
85///
86/// // Second compilation - cache hit
87/// let compiled_cached = cache.compile(&compiler, schema_json)?;
88///
89/// # Ok(())
90/// # }
91/// ```
92pub struct CompilationCache {
93    /// LRU cache: fingerprint -> compiled schema.
94    cache: Arc<Mutex<lru::LruCache<String, CachedCompilation>>>,
95
96    /// Configuration.
97    config: CompilationCacheConfig,
98
99    /// Metrics.
100    metrics: Arc<Mutex<CompilationCacheMetrics>>,
101}
102
103/// Metrics for compilation cache monitoring.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CompilationCacheMetrics {
106    /// Number of cache hits.
107    pub hits: u64,
108
109    /// Number of cache misses.
110    pub misses: u64,
111
112    /// Total compilations performed.
113    pub total_compilations: u64,
114
115    /// Current cache size (entries).
116    pub size: usize,
117}
118
119impl CompilationCache {
120    /// Create new compilation cache with configuration.
121    ///
122    /// # Panics
123    ///
124    /// Panics if cache is enabled but `max_entries` is 0.
125    #[must_use]
126    pub fn new(config: CompilationCacheConfig) -> Self {
127        if config.enabled {
128            let max = NonZeroUsize::new(config.max_entries)
129                .expect("max_entries must be > 0 when cache is enabled");
130            Self {
131                cache: Arc::new(Mutex::new(lru::LruCache::new(max))),
132                config,
133                metrics: Arc::new(Mutex::new(CompilationCacheMetrics {
134                    hits:               0,
135                    misses:             0,
136                    total_compilations: 0,
137                    size:               0,
138                })),
139            }
140        } else {
141            // Create dummy cache (won't be used)
142            let max = NonZeroUsize::new(1).expect("impossible");
143            Self {
144                cache: Arc::new(Mutex::new(lru::LruCache::new(max))),
145                config,
146                metrics: Arc::new(Mutex::new(CompilationCacheMetrics {
147                    hits:               0,
148                    misses:             0,
149                    total_compilations: 0,
150                    size:               0,
151                })),
152            }
153        }
154    }
155
156    /// Compute SHA-256 fingerprint of schema JSON.
157    ///
158    /// This fingerprint uniquely identifies the schema and is used as cache key.
159    fn fingerprint(schema_json: &str) -> String {
160        let mut hasher = Sha256::new();
161        hasher.update(schema_json.as_bytes());
162        format!("{:x}", hasher.finalize())
163    }
164
165    /// Compile schema with caching.
166    ///
167    /// If cache is enabled and schema fingerprint matches a cached entry,
168    /// returns the cached compiled schema. Otherwise, compiles the schema
169    /// and stores result in cache.
170    ///
171    /// # Arguments
172    ///
173    /// * `compiler` - Schema compiler
174    /// * `schema_json` - JSON schema from Python/TypeScript decorators
175    ///
176    /// # Returns
177    ///
178    /// Compiled schema (cached if possible)
179    pub fn compile(
180        &self,
181        compiler: &crate::compiler::Compiler,
182        schema_json: &str,
183    ) -> Result<Arc<CompiledSchema>> {
184        if !self.config.enabled {
185            // Cache disabled - always compile
186            let schema = Arc::new(compiler.compile(schema_json)?);
187
188            let mut metrics = self.metrics.lock().expect("metrics lock poisoned");
189            metrics.total_compilations += 1;
190            metrics.misses += 1;
191
192            return Ok(schema);
193        }
194
195        let fingerprint = Self::fingerprint(schema_json);
196
197        // Check cache
198        {
199            let mut cache = self.cache.lock().expect("cache lock poisoned");
200            if let Some(cached) = cache.get_mut(&fingerprint) {
201                // Cache hit
202                let mut metrics = self.metrics.lock().expect("metrics lock poisoned");
203                metrics.hits += 1;
204                cached.hit_count += 1;
205                return Ok(Arc::clone(&cached.schema));
206            }
207        }
208
209        // Cache miss - compile schema
210        let schema = Arc::new(compiler.compile(schema_json)?);
211
212        // Store in cache
213        {
214            let mut cache = self.cache.lock().expect("cache lock poisoned");
215            cache.put(
216                fingerprint,
217                CachedCompilation {
218                    schema:    Arc::clone(&schema),
219                    hit_count: 0,
220                },
221            );
222
223            let mut metrics = self.metrics.lock().expect("metrics lock poisoned");
224            metrics.total_compilations += 1;
225            metrics.misses += 1;
226            metrics.size = cache.len();
227        }
228
229        Ok(schema)
230    }
231
232    /// Get current cache metrics.
233    pub fn metrics(&self) -> Result<CompilationCacheMetrics> {
234        let metrics = self.metrics.lock().expect("metrics lock poisoned");
235        Ok(metrics.clone())
236    }
237
238    /// Clear all cached compilations.
239    pub fn clear(&self) -> Result<()> {
240        self.cache.lock().expect("cache lock poisoned").clear();
241        let mut metrics = self.metrics.lock().expect("metrics lock poisoned");
242        metrics.size = 0;
243        Ok(())
244    }
245
246    /// Get cache hit rate as percentage (0-100).
247    pub fn hit_rate(&self) -> Result<f64> {
248        let metrics = self.metrics.lock().expect("metrics lock poisoned");
249        if metrics.total_compilations == 0 {
250            return Ok(0.0);
251        }
252        Ok((metrics.hits as f64 / metrics.total_compilations as f64) * 100.0)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_fingerprint_deterministic() {
262        let schema = r#"{"types": [], "queries": []}"#;
263        let fp1 = CompilationCache::fingerprint(schema);
264        let fp2 = CompilationCache::fingerprint(schema);
265        assert_eq!(fp1, fp2, "Fingerprints should be deterministic");
266    }
267
268    #[test]
269    fn test_fingerprint_unique() {
270        let schema1 = r#"{"types": [], "queries": []}"#;
271        let schema2 = r#"{"types": [{"name": "User"}], "queries": []}"#;
272        let fp1 = CompilationCache::fingerprint(schema1);
273        let fp2 = CompilationCache::fingerprint(schema2);
274        assert_ne!(fp1, fp2, "Different schemas should have different fingerprints");
275    }
276
277    #[test]
278    fn test_cache_new_enabled() {
279        let config = CompilationCacheConfig {
280            enabled:     true,
281            max_entries: 50,
282        };
283        let cache = CompilationCache::new(config);
284        assert!(cache.config.enabled);
285    }
286
287    #[test]
288    fn test_cache_new_disabled() {
289        let config = CompilationCacheConfig::disabled();
290        let cache = CompilationCache::new(config);
291        assert!(!cache.config.enabled);
292    }
293
294    #[test]
295    fn test_cache_default_config() {
296        let config = CompilationCacheConfig::default();
297        assert!(config.enabled);
298        assert_eq!(config.max_entries, 100);
299    }
300
301    #[test]
302    fn test_metrics_initial_state() {
303        let cache = CompilationCache::new(CompilationCacheConfig::default());
304        let metrics = cache.metrics().expect("metrics should work");
305        assert_eq!(metrics.hits, 0);
306        assert_eq!(metrics.misses, 0);
307        assert_eq!(metrics.total_compilations, 0);
308        assert_eq!(metrics.size, 0);
309    }
310
311    #[test]
312    fn test_hit_rate_no_compilations() {
313        let cache = CompilationCache::new(CompilationCacheConfig::default());
314        let rate = cache.hit_rate().expect("hit_rate should work");
315        assert_eq!(rate, 0.0);
316    }
317
318    #[test]
319    fn test_clear_cache() {
320        let cache = CompilationCache::new(CompilationCacheConfig::default());
321
322        // Disable cache temporarily to add entry without using compile()
323        // For now, just verify clear works without panicking
324        cache.clear().expect("clear should work");
325
326        let metrics = cache.metrics().expect("metrics should work");
327        assert_eq!(metrics.size, 0);
328    }
329
330    #[test]
331    fn test_cache_config_max_entries_zero_when_disabled() {
332        // When cache is disabled, max_entries being 0 is OK
333        let config = CompilationCacheConfig {
334            enabled:     false,
335            max_entries: 0,
336        };
337        let cache = CompilationCache::new(config);
338        assert!(!cache.config.enabled);
339    }
340
341    #[test]
342    #[should_panic(expected = "max_entries must be > 0 when cache is enabled")]
343    fn test_cache_panics_on_zero_max_entries_when_enabled() {
344        let config = CompilationCacheConfig {
345            enabled:     true,
346            max_entries: 0,
347        };
348        let _ = CompilationCache::new(config);
349    }
350
351    #[test]
352    fn test_cache_metrics_clone() {
353        let metrics = CompilationCacheMetrics {
354            hits:               5,
355            misses:             3,
356            total_compilations: 8,
357            size:               2,
358        };
359        let cloned = metrics.clone();
360        assert_eq!(cloned.hits, 5);
361        assert_eq!(cloned.misses, 3);
362    }
363
364    #[test]
365    fn test_cache_config_serialize() {
366        let config = CompilationCacheConfig {
367            enabled:     true,
368            max_entries: 50,
369        };
370        let json = serde_json::to_string(&config).expect("serialize should work");
371        let restored: CompilationCacheConfig =
372            serde_json::from_str(&json).expect("deserialize should work");
373        assert_eq!(restored.enabled, config.enabled);
374        assert_eq!(restored.max_entries, config.max_entries);
375    }
376
377    #[test]
378    fn test_compilation_cache_metrics_serialize() {
379        let metrics = CompilationCacheMetrics {
380            hits:               10,
381            misses:             5,
382            total_compilations: 15,
383            size:               3,
384        };
385        let json = serde_json::to_string(&metrics).expect("serialize should work");
386        let restored: CompilationCacheMetrics =
387            serde_json::from_str(&json).expect("deserialize should work");
388        assert_eq!(restored.hits, 10);
389        assert_eq!(restored.size, 3);
390    }
391}