fraiseql_core/compiler/
compilation_cache.rs1use 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#[derive(Debug, Clone)]
16pub struct CachedCompilation {
17 pub schema: Arc<CompiledSchema>,
19
20 pub hit_count: u64,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CompilationCacheConfig {
27 pub enabled: bool,
29
30 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 #[must_use]
48 pub const fn disabled() -> Self {
49 Self {
50 enabled: false,
51 max_entries: 0,
52 }
53 }
54}
55
56pub struct CompilationCache {
91 cache: Arc<Mutex<lru::LruCache<String, CachedCompilation>>>,
93
94 config: CompilationCacheConfig,
96
97 metrics: Arc<Mutex<CompilationCacheMetrics>>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CompilationCacheMetrics {
104 pub hits: u64,
106
107 pub misses: u64,
109
110 pub total_compilations: u64,
112
113 pub size: usize,
115}
116
117impl CompilationCache {
118 #[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 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 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 pub fn compile(
180 &self,
181 compiler: &crate::compiler::Compiler,
182 schema_json: &str,
183 ) -> Result<Arc<CompiledSchema>> {
184 if !self.config.enabled {
185 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 {
199 let mut cache = self.cache.lock();
200 if let Some(cached) = cache.get_mut(&fingerprint) {
201 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 let schema = Arc::new(compiler.compile(schema_json)?);
211
212 {
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 pub fn metrics(&self) -> Result<CompilationCacheMetrics> {
239 let metrics = self.metrics.lock();
240 Ok(metrics.clone())
241 }
242
243 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 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 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 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 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}