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::{num::NonZeroUsize, sync::Arc};
7
8use parking_lot::Mutex;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{error::Result, schema::CompiledSchema};
13
14/// Cache entry for compiled schema with metadata.
15#[derive(Debug, Clone)]
16pub struct CachedCompilation {
17    /// The compiled schema result.
18    pub schema: Arc<CompiledSchema>,
19
20    /// Number of cache hits for this entry.
21    pub hit_count: u64,
22}
23
24/// Configuration for compilation cache.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CompilationCacheConfig {
27    /// Enable compilation caching.
28    pub enabled: bool,
29
30    /// Maximum number of compiled schemas to cache.
31    pub max_entries: usize,
32}
33
34impl Default for CompilationCacheConfig {
35    fn default() -> Self {
36        Self {
37            enabled:     true,
38            max_entries: 100,
39        }
40    }
41}
42
43impl CompilationCacheConfig {
44    /// Create disabled cache configuration.
45    ///
46    /// Useful for deterministic testing where compilation should always occur.
47    #[must_use]
48    pub const fn disabled() -> Self {
49        Self {
50            enabled:     false,
51            max_entries: 0,
52        }
53    }
54}
55
56/// Thread-safe LRU cache for compiled schemas.
57///
58/// # Design
59///
60/// - **Fingerprinting**: Uses SHA-256 hash of schema JSON for cache keys
61/// - **LRU eviction**: Automatically evicts least-recently-used entries
62/// - **Thread-safe**: All operations use interior mutability
63///
64/// # Memory Safety
65///
66/// - Hard LRU limit ensures bounded memory usage
67/// - Default config: 100 entries (reasonable for most deployments)
68///
69/// # Example
70///
71/// ```rust,no_run
72/// use fraiseql_core::compiler::compilation_cache::{CompilationCache, CompilationCacheConfig};
73/// use fraiseql_core::compiler::Compiler;
74///
75/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
76/// let cache = CompilationCache::new(CompilationCacheConfig::default());
77/// let compiler = Compiler::new();
78///
79/// let schema_json = r#"{"types": [], "queries": []}"#;
80///
81/// // First compilation - cache miss
82/// let compiled = cache.compile(&compiler, schema_json)?;
83///
84/// // Second compilation - cache hit
85/// let compiled_cached = cache.compile(&compiler, schema_json)?;
86///
87/// # Ok(())
88/// # }
89/// ```
90pub struct CompilationCache {
91    /// LRU cache: fingerprint -> compiled schema.
92    cache: Arc<Mutex<lru::LruCache<String, CachedCompilation>>>,
93
94    /// Configuration.
95    config: CompilationCacheConfig,
96
97    /// Metrics.
98    metrics: Arc<Mutex<CompilationCacheMetrics>>,
99}
100
101/// Metrics for compilation cache monitoring.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CompilationCacheMetrics {
104    /// Number of cache hits.
105    pub hits: u64,
106
107    /// Number of cache misses.
108    pub misses: u64,
109
110    /// Total compilations performed.
111    pub total_compilations: u64,
112
113    /// Current cache size (entries).
114    pub size: usize,
115}
116
117impl CompilationCache {
118    /// Create new compilation cache with configuration.
119    ///
120    /// # Panics
121    ///
122    /// Panics if cache is enabled but `max_entries` is 0.  Uses `parking_lot::Mutex`
123    /// internally, so lock operations are infallible (no poison panics).
124    #[must_use]
125    pub fn new(config: CompilationCacheConfig) -> Self {
126        if config.enabled {
127            let max = NonZeroUsize::new(config.max_entries)
128                .expect("max_entries must be > 0 when cache is enabled");
129            Self {
130                cache: Arc::new(Mutex::new(lru::LruCache::new(max))),
131                config,
132                metrics: Arc::new(Mutex::new(CompilationCacheMetrics {
133                    hits:               0,
134                    misses:             0,
135                    total_compilations: 0,
136                    size:               0,
137                })),
138            }
139        } else {
140            // Create dummy cache (won't be used)
141            // Reason: literal `1` can never be zero; `NonZeroUsize::new` is infallible here.
142            let max = NonZeroUsize::new(1).expect("1 is non-zero");
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 emitted by the authoring-language decorators
175    ///
176    /// # Errors
177    ///
178    /// Returns `FraiseQLError` if schema compilation fails.
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();
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();
200            if let Some(cached) = cache.get_mut(&fingerprint) {
201                // Cache hit
202                let mut metrics = self.metrics.lock();
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();
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();
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    ///
234    /// # Errors
235    ///
236    /// This function is currently infallible. The `Result` return type is
237    /// reserved for future implementations that may use fallible storage.
238    pub fn metrics(&self) -> Result<CompilationCacheMetrics> {
239        let metrics = self.metrics.lock();
240        Ok(metrics.clone())
241    }
242
243    /// Clear all cached compilations.
244    ///
245    /// # Errors
246    ///
247    /// This function is currently infallible. The `Result` return type is
248    /// reserved for future implementations that may use fallible storage.
249    pub fn clear(&self) -> Result<()> {
250        self.cache.lock().clear();
251        let mut metrics = self.metrics.lock();
252        metrics.size = 0;
253        Ok(())
254    }
255
256    /// Get cache hit rate as percentage (0-100).
257    ///
258    /// # Errors
259    ///
260    /// This function is currently infallible. The `Result` return type is
261    /// reserved for future implementations that may use fallible storage.
262    pub fn hit_rate(&self) -> Result<f64> {
263        let metrics = self.metrics.lock();
264        if metrics.total_compilations == 0 {
265            return Ok(0.0);
266        }
267        #[allow(clippy::cast_precision_loss)]
268        // Reason: hit-rate percentage is a display metric; f64 precision loss on u64 counts is
269        // acceptable
270        Ok((metrics.hits as f64 / metrics.total_compilations as f64) * 100.0)
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_fingerprint_deterministic() {
280        let schema = r#"{"types": [], "queries": []}"#;
281        let fp1 = CompilationCache::fingerprint(schema);
282        let fp2 = CompilationCache::fingerprint(schema);
283        assert_eq!(fp1, fp2, "Fingerprints should be deterministic");
284    }
285
286    #[test]
287    fn test_fingerprint_unique() {
288        let schema1 = r#"{"types": [], "queries": []}"#;
289        let schema2 = r#"{"types": [{"name": "User"}], "queries": []}"#;
290        let fp1 = CompilationCache::fingerprint(schema1);
291        let fp2 = CompilationCache::fingerprint(schema2);
292        assert_ne!(fp1, fp2, "Different schemas should have different fingerprints");
293    }
294
295    #[test]
296    fn test_cache_new_enabled() {
297        let config = CompilationCacheConfig {
298            enabled:     true,
299            max_entries: 50,
300        };
301        let cache = CompilationCache::new(config);
302        assert!(cache.config.enabled);
303    }
304
305    #[test]
306    fn test_cache_new_disabled() {
307        let config = CompilationCacheConfig::disabled();
308        let cache = CompilationCache::new(config);
309        assert!(!cache.config.enabled);
310    }
311
312    #[test]
313    fn test_cache_default_config() {
314        let config = CompilationCacheConfig::default();
315        assert!(config.enabled);
316        assert_eq!(config.max_entries, 100);
317    }
318
319    #[test]
320    fn test_metrics_initial_state() {
321        let cache = CompilationCache::new(CompilationCacheConfig::default());
322        let metrics = cache.metrics().expect("metrics should work");
323        assert_eq!(metrics.hits, 0);
324        assert_eq!(metrics.misses, 0);
325        assert_eq!(metrics.total_compilations, 0);
326        assert_eq!(metrics.size, 0);
327    }
328
329    #[test]
330    fn test_hit_rate_no_compilations() {
331        let cache = CompilationCache::new(CompilationCacheConfig::default());
332        let rate = cache.hit_rate().expect("hit_rate should work");
333        assert!((rate - 0.0_f64).abs() < f64::EPSILON);
334    }
335
336    #[test]
337    fn test_clear_cache() {
338        let cache = CompilationCache::new(CompilationCacheConfig::default());
339
340        // Disable cache temporarily to add entry without using compile()
341        // For now, just verify clear works without panicking
342        cache.clear().expect("clear should work");
343
344        let metrics = cache.metrics().expect("metrics should work");
345        assert_eq!(metrics.size, 0);
346    }
347
348    #[test]
349    fn test_cache_config_max_entries_zero_when_disabled() {
350        // When cache is disabled, max_entries being 0 is OK
351        let config = CompilationCacheConfig {
352            enabled:     false,
353            max_entries: 0,
354        };
355        let cache = CompilationCache::new(config);
356        assert!(!cache.config.enabled);
357    }
358
359    #[test]
360    #[should_panic(expected = "max_entries must be > 0 when cache is enabled")]
361    fn test_cache_panics_on_zero_max_entries_when_enabled() {
362        let config = CompilationCacheConfig {
363            enabled:     true,
364            max_entries: 0,
365        };
366        let _ = CompilationCache::new(config);
367    }
368
369    #[test]
370    fn test_cache_metrics_clone() {
371        let metrics = CompilationCacheMetrics {
372            hits:               5,
373            misses:             3,
374            total_compilations: 8,
375            size:               2,
376        };
377        let cloned = metrics;
378        assert_eq!(cloned.hits, 5);
379        assert_eq!(cloned.misses, 3);
380    }
381
382    #[test]
383    fn test_cache_config_serialize() {
384        let config = CompilationCacheConfig {
385            enabled:     true,
386            max_entries: 50,
387        };
388        let json = serde_json::to_string(&config).expect("serialize should work");
389        let restored: CompilationCacheConfig =
390            serde_json::from_str(&json).expect("deserialize should work");
391        assert_eq!(restored.enabled, config.enabled);
392        assert_eq!(restored.max_entries, config.max_entries);
393    }
394
395    #[test]
396    fn test_compilation_cache_metrics_serialize() {
397        let metrics = CompilationCacheMetrics {
398            hits:               10,
399            misses:             5,
400            total_compilations: 15,
401            size:               3,
402        };
403        let json = serde_json::to_string(&metrics).expect("serialize should work");
404        let restored: CompilationCacheMetrics =
405            serde_json::from_str(&json).expect("deserialize should work");
406        assert_eq!(restored.hits, 10);
407        assert_eq!(restored.size, 3);
408    }
409}