Skip to main content

oxirs_gql/
schema_cache.rs

1//! Schema caching with hot-reload support
2//!
3//! This module provides caching for generated GraphQL schemas with support for:
4//! - File-based schema caching with hot-reload
5//! - In-memory LRU caching
6//! - Automatic invalidation on ontology changes
7//! - Schema versioning
8
9use crate::schema::{RdfVocabulary, SchemaGenerationConfig, SchemaGenerator};
10use crate::types::Schema;
11use anyhow::Result;
12use lru::LruCache;
13use std::collections::HashMap;
14use std::num::NonZeroUsize;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::{Duration, SystemTime};
18use tokio::fs;
19use tokio::sync::RwLock;
20use tokio::time::interval;
21use tracing::{debug, error, info, warn};
22
23/// Schema cache entry with metadata
24#[derive(Debug, Clone)]
25pub struct SchemaCacheEntry {
26    pub schema: Schema,
27    pub created_at: SystemTime,
28    pub last_accessed: SystemTime,
29    pub access_count: u64,
30    pub version: u64,
31    pub source_hash: String,
32}
33
34impl SchemaCacheEntry {
35    pub fn new(schema: Schema, source_hash: String, version: u64) -> Self {
36        let now = SystemTime::now();
37        Self {
38            schema,
39            created_at: now,
40            last_accessed: now,
41            access_count: 0,
42            version,
43            source_hash,
44        }
45    }
46
47    pub fn access(&mut self) {
48        self.access_count += 1;
49        self.last_accessed = SystemTime::now();
50    }
51
52    pub fn age(&self) -> Duration {
53        self.created_at.elapsed().unwrap_or(Duration::from_secs(0))
54    }
55}
56
57/// Configuration for schema cache
58#[derive(Debug, Clone)]
59pub struct SchemaCacheConfig {
60    /// Maximum number of schemas to cache in memory
61    pub max_cache_size: usize,
62    /// TTL for cached schemas
63    pub ttl: Duration,
64    /// Enable hot-reload from file system
65    pub enable_hot_reload: bool,
66    /// Directory to watch for schema changes
67    pub watch_directory: Option<PathBuf>,
68    /// Interval for checking file changes
69    pub reload_check_interval: Duration,
70    /// Enable cache statistics
71    pub enable_statistics: bool,
72}
73
74impl Default for SchemaCacheConfig {
75    fn default() -> Self {
76        Self {
77            max_cache_size: 100,
78            ttl: Duration::from_secs(3600), // 1 hour
79            enable_hot_reload: true,
80            watch_directory: None,
81            reload_check_interval: Duration::from_secs(5),
82            enable_statistics: true,
83        }
84    }
85}
86
87/// Cache statistics
88#[derive(Debug, Clone, Default)]
89pub struct CacheStatistics {
90    pub hits: u64,
91    pub misses: u64,
92    pub evictions: u64,
93    pub reloads: u64,
94    pub total_schemas: u64,
95    pub current_size: usize,
96}
97
98impl CacheStatistics {
99    pub fn hit_rate(&self) -> f64 {
100        let total = self.hits + self.misses;
101        if total == 0 {
102            0.0
103        } else {
104            self.hits as f64 / total as f64
105        }
106    }
107}
108
109/// Schema cache with hot-reload support
110pub struct SchemaCache {
111    cache: Arc<RwLock<LruCache<String, SchemaCacheEntry>>>,
112    config: SchemaCacheConfig,
113    statistics: Arc<RwLock<CacheStatistics>>,
114    file_watchers: Arc<RwLock<HashMap<PathBuf, SystemTime>>>,
115    next_version: Arc<RwLock<u64>>,
116}
117
118impl SchemaCache {
119    /// Create a new schema cache
120    pub fn new(config: SchemaCacheConfig) -> Self {
121        let cache_size = NonZeroUsize::new(config.max_cache_size)
122            .unwrap_or(NonZeroUsize::new(100).expect("Default cache size should be valid"));
123
124        Self {
125            cache: Arc::new(RwLock::new(LruCache::new(cache_size))),
126            config: config.clone(),
127            statistics: Arc::new(RwLock::new(CacheStatistics::default())),
128            file_watchers: Arc::new(RwLock::new(HashMap::new())),
129            next_version: Arc::new(RwLock::new(1)),
130        }
131    }
132
133    /// Get schema from cache
134    pub async fn get(&self, key: &str) -> Option<Schema> {
135        let mut cache = self.cache.write().await;
136
137        if let Some(entry) = cache.get_mut(key) {
138            // Check if entry is expired
139            if entry.age() > self.config.ttl {
140                debug!("Schema cache entry expired for key: {}", key);
141                cache.pop(key);
142                self.record_miss().await;
143                return None;
144            }
145
146            entry.access();
147            self.record_hit().await;
148            debug!(
149                "Schema cache hit for key: {} (version: {}, age: {:?})",
150                key,
151                entry.version,
152                entry.age()
153            );
154            Some(entry.schema.clone())
155        } else {
156            self.record_miss().await;
157            debug!("Schema cache miss for key: {}", key);
158            None
159        }
160    }
161
162    /// Put schema in cache
163    pub async fn put(&self, key: String, schema: Schema, source_hash: String) {
164        let mut cache = self.cache.write().await;
165        let mut version_guard = self.next_version.write().await;
166        let version = *version_guard;
167        *version_guard += 1;
168
169        let entry = SchemaCacheEntry::new(schema, source_hash, version);
170
171        // Check if we're evicting an entry
172        if cache.len() >= cache.cap().get() && !cache.contains(&key) {
173            self.record_eviction().await;
174        }
175
176        cache.put(key.clone(), entry);
177        info!("Cached schema for key: {} (version: {})", key, version);
178
179        // Update statistics
180        let mut stats = self.statistics.write().await;
181        stats.total_schemas += 1;
182        stats.current_size = cache.len();
183    }
184
185    /// Generate and cache schema from vocabulary
186    pub async fn get_or_generate(
187        &self,
188        key: &str,
189        vocabulary: RdfVocabulary,
190        config: SchemaGenerationConfig,
191    ) -> Result<Schema> {
192        // Try to get from cache first
193        if let Some(schema) = self.get(key).await {
194            return Ok(schema);
195        }
196
197        // Generate schema
198        let generator = SchemaGenerator::new()
199            .with_config(config)
200            .with_vocabulary(vocabulary.clone());
201        let schema = generator.generate_schema()?;
202
203        // Compute source hash (simple approach - in production use proper hashing)
204        let source_hash = format!("{:?}", vocabulary).len().to_string();
205
206        // Cache the schema
207        self.put(key.to_string(), schema.clone(), source_hash).await;
208
209        Ok(schema)
210    }
211
212    /// Invalidate cache entry
213    pub async fn invalidate(&self, key: &str) -> bool {
214        let mut cache = self.cache.write().await;
215        if cache.pop(key).is_some() {
216            info!("Invalidated schema cache for key: {}", key);
217
218            let mut stats = self.statistics.write().await;
219            stats.current_size = cache.len();
220            true
221        } else {
222            false
223        }
224    }
225
226    /// Clear all cache entries
227    pub async fn clear(&self) {
228        let mut cache = self.cache.write().await;
229        let count = cache.len();
230        cache.clear();
231
232        info!("Cleared schema cache ({} entries removed)", count);
233
234        let mut stats = self.statistics.write().await;
235        stats.current_size = 0;
236    }
237
238    /// Get cache statistics
239    pub async fn statistics(&self) -> CacheStatistics {
240        self.statistics.read().await.clone()
241    }
242
243    /// Start hot-reload watcher
244    pub async fn start_hot_reload(&self) {
245        if !self.config.enable_hot_reload {
246            return;
247        }
248
249        let watch_dir = match &self.config.watch_directory {
250            Some(dir) => dir.clone(),
251            None => {
252                warn!("Hot-reload enabled but no watch directory specified");
253                return;
254            }
255        };
256
257        info!(
258            "Starting schema hot-reload watcher for directory: {:?}",
259            watch_dir
260        );
261
262        let cache = Arc::clone(&self.cache);
263        let file_watchers = Arc::clone(&self.file_watchers);
264        let statistics = Arc::clone(&self.statistics);
265        let check_interval = self.config.reload_check_interval;
266
267        tokio::spawn(async move {
268            let mut interval = interval(check_interval);
269
270            loop {
271                interval.tick().await;
272
273                match Self::check_directory_changes(&watch_dir, &file_watchers, &cache, &statistics)
274                    .await
275                {
276                    Ok(changed) => {
277                        if changed > 0 {
278                            info!("Hot-reload detected {} schema file changes", changed);
279                        }
280                    }
281                    Err(e) => {
282                        error!("Error checking directory changes: {}", e);
283                    }
284                }
285            }
286        });
287    }
288
289    /// Check directory for changes
290    async fn check_directory_changes(
291        dir: &Path,
292        watchers: &Arc<RwLock<HashMap<PathBuf, SystemTime>>>,
293        cache: &Arc<RwLock<LruCache<String, SchemaCacheEntry>>>,
294        statistics: &Arc<RwLock<CacheStatistics>>,
295    ) -> Result<usize> {
296        let mut changed_count = 0;
297
298        // Read directory entries
299        let mut entries = fs::read_dir(dir).await?;
300
301        let mut current_files = HashMap::new();
302
303        while let Some(entry) = entries.next_entry().await? {
304            let path = entry.path();
305
306            // Only watch .ttl, .rdf, .owl files
307            if let Some(ext) = path.extension() {
308                let ext_str = ext.to_string_lossy();
309                if !matches!(ext_str.as_ref(), "ttl" | "rdf" | "owl" | "nt") {
310                    continue;
311                }
312            } else {
313                continue;
314            }
315
316            // Get file modification time
317            if let Ok(metadata) = fs::metadata(&path).await {
318                if let Ok(modified) = metadata.modified() {
319                    current_files.insert(path.clone(), modified);
320                }
321            }
322        }
323
324        let mut watchers_guard = watchers.write().await;
325
326        // Check for changes or new files
327        for (file_path, modified_time) in &current_files {
328            match watchers_guard.get(file_path) {
329                Some(cached_time) if cached_time != modified_time => {
330                    // File was modified
331                    debug!("Detected change in schema file: {:?}", file_path);
332
333                    // Invalidate related cache entries
334                    let mut cache_guard = cache.write().await;
335                    cache_guard.clear(); // Simple approach: clear all cache
336
337                    watchers_guard.insert(file_path.clone(), *modified_time);
338                    changed_count += 1;
339
340                    let mut stats = statistics.write().await;
341                    stats.reloads += 1;
342                }
343                None => {
344                    // New file detected
345                    debug!("Detected new schema file: {:?}", file_path);
346                    watchers_guard.insert(file_path.clone(), *modified_time);
347                    changed_count += 1;
348                }
349                _ => {}
350            }
351        }
352
353        // Remove deleted files from watchers
354        let deleted: Vec<_> = watchers_guard
355            .keys()
356            .filter(|path| !current_files.contains_key(*path))
357            .cloned()
358            .collect();
359
360        for path in deleted {
361            debug!("Detected deletion of schema file: {:?}", path);
362            watchers_guard.remove(&path);
363            changed_count += 1;
364        }
365
366        Ok(changed_count)
367    }
368
369    /// Record cache hit
370    async fn record_hit(&self) {
371        if self.config.enable_statistics {
372            let mut stats = self.statistics.write().await;
373            stats.hits += 1;
374        }
375    }
376
377    /// Record cache miss
378    async fn record_miss(&self) {
379        if self.config.enable_statistics {
380            let mut stats = self.statistics.write().await;
381            stats.misses += 1;
382        }
383    }
384
385    /// Record cache eviction
386    async fn record_eviction(&self) {
387        if self.config.enable_statistics {
388            let mut stats = self.statistics.write().await;
389            stats.evictions += 1;
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use std::collections::HashMap;
398
399    fn create_test_schema() -> Schema {
400        Schema::new()
401    }
402
403    #[allow(dead_code)]
404    fn create_test_vocabulary() -> RdfVocabulary {
405        RdfVocabulary {
406            classes: HashMap::new(),
407            properties: HashMap::new(),
408            namespaces: HashMap::new(),
409        }
410    }
411
412    #[tokio::test]
413    async fn test_schema_cache_creation() {
414        let config = SchemaCacheConfig::default();
415        let cache = SchemaCache::new(config);
416
417        let stats = cache.statistics().await;
418        assert_eq!(stats.hits, 0);
419        assert_eq!(stats.misses, 0);
420        assert_eq!(stats.current_size, 0);
421    }
422
423    #[tokio::test]
424    async fn test_schema_cache_put_and_get() {
425        let config = SchemaCacheConfig::default();
426        let cache = SchemaCache::new(config);
427
428        let schema = create_test_schema();
429        let key = "test_schema".to_string();
430        let hash = "test_hash".to_string();
431
432        cache.put(key.clone(), schema.clone(), hash).await;
433
434        let retrieved = cache.get(&key).await;
435        assert!(retrieved.is_some());
436
437        let stats = cache.statistics().await;
438        assert_eq!(stats.hits, 1);
439        assert_eq!(stats.misses, 0);
440    }
441
442    #[tokio::test]
443    async fn test_schema_cache_miss() {
444        let config = SchemaCacheConfig::default();
445        let cache = SchemaCache::new(config);
446
447        let retrieved = cache.get("nonexistent").await;
448        assert!(retrieved.is_none());
449
450        let stats = cache.statistics().await;
451        assert_eq!(stats.hits, 0);
452        assert_eq!(stats.misses, 1);
453    }
454
455    #[tokio::test]
456    async fn test_schema_cache_invalidation() {
457        let config = SchemaCacheConfig::default();
458        let cache = SchemaCache::new(config);
459
460        let schema = create_test_schema();
461        let key = "test_schema".to_string();
462        let hash = "test_hash".to_string();
463
464        cache.put(key.clone(), schema, hash).await;
465        assert!(cache.get(&key).await.is_some());
466
467        cache.invalidate(&key).await;
468        assert!(cache.get(&key).await.is_none());
469    }
470
471    #[tokio::test]
472    async fn test_schema_cache_clear() {
473        let config = SchemaCacheConfig::default();
474        let cache = SchemaCache::new(config);
475
476        for i in 0..5 {
477            let schema = create_test_schema();
478            let key = format!("schema_{}", i);
479            let hash = format!("hash_{}", i);
480            cache.put(key, schema, hash).await;
481        }
482
483        let stats = cache.statistics().await;
484        assert_eq!(stats.current_size, 5);
485
486        cache.clear().await;
487
488        let stats = cache.statistics().await;
489        assert_eq!(stats.current_size, 0);
490    }
491
492    #[tokio::test]
493    async fn test_schema_cache_statistics() {
494        let config = SchemaCacheConfig::default();
495        let cache = SchemaCache::new(config);
496
497        let schema = create_test_schema();
498        cache
499            .put("schema1".to_string(), schema.clone(), "hash1".to_string())
500            .await;
501
502        // Hit
503        let _ = cache.get("schema1").await;
504
505        // Miss
506        let _ = cache.get("schema2").await;
507
508        let stats = cache.statistics().await;
509        assert_eq!(stats.hits, 1);
510        assert_eq!(stats.misses, 1);
511        assert_eq!(stats.hit_rate(), 0.5);
512    }
513
514    #[tokio::test]
515    async fn test_schema_cache_ttl_expiration() {
516        let config = SchemaCacheConfig {
517            ttl: Duration::from_millis(100), // Very short TTL for testing
518            ..Default::default()
519        };
520
521        let cache = SchemaCache::new(config);
522        let schema = create_test_schema();
523
524        cache
525            .put("test".to_string(), schema, "hash".to_string())
526            .await;
527
528        // Should be in cache immediately
529        assert!(cache.get("test").await.is_some());
530
531        // Wait for TTL to expire
532        tokio::time::sleep(Duration::from_millis(150)).await;
533
534        // Should be expired now
535        assert!(cache.get("test").await.is_none());
536    }
537
538    #[tokio::test]
539    async fn test_cache_entry_versioning() {
540        let schema = create_test_schema();
541        let entry1 = SchemaCacheEntry::new(schema.clone(), "hash1".to_string(), 1);
542        let entry2 = SchemaCacheEntry::new(schema, "hash2".to_string(), 2);
543
544        assert_eq!(entry1.version, 1);
545        assert_eq!(entry2.version, 2);
546        assert_ne!(entry1.source_hash, entry2.source_hash);
547    }
548
549    #[tokio::test]
550    async fn test_cache_entry_access_tracking() {
551        let schema = create_test_schema();
552        let mut entry = SchemaCacheEntry::new(schema, "hash".to_string(), 1);
553
554        assert_eq!(entry.access_count, 0);
555
556        entry.access();
557        assert_eq!(entry.access_count, 1);
558
559        entry.access();
560        assert_eq!(entry.access_count, 2);
561    }
562}