llm_config_cache/
l2.rs

1//! L2 persistent cache for warm restarts
2
3use crate::{CacheError, Result};
4use llm_config_core::ConfigEntry;
5use std::collections::HashMap;
6use std::fs::{self, File};
7use std::io::{BufReader, BufWriter, Write};
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, RwLock};
10
11/// L2 persistent cache
12pub struct L2Cache {
13    cache_dir: PathBuf,
14    index: Arc<RwLock<HashMap<String, PathBuf>>>,
15}
16
17impl L2Cache {
18    /// Create a new L2 cache
19    pub fn new(cache_dir: impl AsRef<Path>) -> Result<Self> {
20        let cache_dir = cache_dir.as_ref();
21        fs::create_dir_all(cache_dir)?;
22
23        let cache = Self {
24            cache_dir: cache_dir.to_path_buf(),
25            index: Arc::new(RwLock::new(HashMap::new())),
26        };
27
28        // Build index from existing cache files
29        cache.rebuild_index()?;
30
31        Ok(cache)
32    }
33
34    /// Generate cache key
35    fn cache_key(namespace: &str, key: &str, env: &str) -> String {
36        format!("{}:{}:{}", namespace, key, env)
37    }
38
39    /// Get cache file path for a key
40    fn cache_file_path(&self, cache_key: &str) -> PathBuf {
41        // Use hex encoding for safe filesystem names
42        let encoded = hex::encode(cache_key.as_bytes());
43        self.cache_dir.join(format!("{}.cache", encoded))
44    }
45
46    /// Rebuild the index from disk
47    fn rebuild_index(&self) -> Result<()> {
48        let mut index = self.index.write().unwrap();
49        index.clear();
50
51        if !self.cache_dir.exists() {
52            return Ok(());
53        }
54
55        for entry in fs::read_dir(&self.cache_dir)? {
56            let entry = entry?;
57            let path = entry.path();
58
59            if path.extension().and_then(|s| s.to_str()) == Some("cache") {
60                // Read the file to get the cache key
61                if let Ok(file) = File::open(&path) {
62                    let reader = BufReader::new(file);
63                    if let Ok(cached_entry) = serde_json::from_reader::<_, ConfigEntry>(reader) {
64                        let cache_key = Self::cache_key(
65                            &cached_entry.namespace,
66                            &cached_entry.key,
67                            &cached_entry.environment.to_string(),
68                        );
69                        index.insert(cache_key, path);
70                    }
71                }
72            }
73        }
74
75        Ok(())
76    }
77
78    /// Get an entry from the cache
79    pub fn get(&self, namespace: &str, key: &str, env: &str) -> Result<ConfigEntry> {
80        let cache_key = Self::cache_key(namespace, key, env);
81
82        let index = self.index.read().unwrap();
83
84        if let Some(path) = index.get(&cache_key) {
85            let file = File::open(path)?;
86            let reader = BufReader::new(file);
87            let entry = serde_json::from_reader(reader)
88                .map_err(|e| CacheError::Serialization(e.to_string()))?;
89            Ok(entry)
90        } else {
91            Err(CacheError::CacheMiss(cache_key))
92        }
93    }
94
95    /// Put an entry into the cache
96    pub fn put(&self, entry: &ConfigEntry) -> Result<()> {
97        let cache_key = Self::cache_key(&entry.namespace, &entry.key, &entry.environment.to_string());
98        let path = self.cache_file_path(&cache_key);
99
100        // Write to temp file first for atomicity
101        let temp_path = path.with_extension("tmp");
102        {
103            let file = File::create(&temp_path)?;
104            let mut writer = BufWriter::new(file);
105            serde_json::to_writer(&mut writer, entry)
106                .map_err(|e| CacheError::Serialization(e.to_string()))?;
107            writer.flush()?;
108        }
109
110        // Atomic rename
111        fs::rename(&temp_path, &path)?;
112
113        // Update index
114        let mut index = self.index.write().unwrap();
115        index.insert(cache_key, path);
116
117        Ok(())
118    }
119
120    /// Invalidate a specific entry
121    pub fn invalidate(&self, namespace: &str, key: &str, env: &str) -> Result<()> {
122        let cache_key = Self::cache_key(namespace, key, env);
123
124        let mut index = self.index.write().unwrap();
125
126        if let Some(path) = index.remove(&cache_key) {
127            let _ = fs::remove_file(path); // Ignore errors if file doesn't exist
128        }
129
130        Ok(())
131    }
132
133    /// Clear the entire cache
134    pub fn clear(&self) -> Result<()> {
135        let mut index = self.index.write().unwrap();
136        index.clear();
137
138        // Remove all cache files
139        if self.cache_dir.exists() {
140            for entry in fs::read_dir(&self.cache_dir)? {
141                let entry = entry?;
142                let path = entry.path();
143                if path.extension().and_then(|s| s.to_str()) == Some("cache") {
144                    let _ = fs::remove_file(path);
145                }
146            }
147        }
148
149        Ok(())
150    }
151
152    /// Get cache size (number of entries)
153    pub fn size(&self) -> usize {
154        self.index.read().unwrap().len()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use llm_config_core::{ConfigMetadata, ConfigValue, Environment};
162    use tempfile::TempDir;
163    use uuid::Uuid;
164
165    fn create_test_entry(namespace: &str, key: &str, env: Environment) -> ConfigEntry {
166        ConfigEntry {
167            id: Uuid::new_v4(),
168            namespace: namespace.to_string(),
169            key: key.to_string(),
170            value: ConfigValue::String("test-value".to_string()),
171            environment: env,
172            version: 1,
173            metadata: ConfigMetadata {
174                created_at: chrono::Utc::now(),
175                created_by: "test".to_string(),
176                updated_at: chrono::Utc::now(),
177                updated_by: "test".to_string(),
178                tags: vec![],
179                description: None,
180            },
181        }
182    }
183
184    #[test]
185    fn test_l2_creation() {
186        let temp_dir = TempDir::new().unwrap();
187        let cache = L2Cache::new(temp_dir.path()).unwrap();
188        assert_eq!(cache.size(), 0);
189    }
190
191    #[test]
192    fn test_put_and_get() {
193        let temp_dir = TempDir::new().unwrap();
194        let cache = L2Cache::new(temp_dir.path()).unwrap();
195
196        let entry = create_test_entry("ns", "key1", Environment::Development);
197        cache.put(&entry).unwrap();
198
199        let retrieved = cache.get("ns", "key1", "development").unwrap();
200        assert_eq!(retrieved.id, entry.id);
201    }
202
203    #[test]
204    fn test_cache_miss() {
205        let temp_dir = TempDir::new().unwrap();
206        let cache = L2Cache::new(temp_dir.path()).unwrap();
207
208        let result = cache.get("ns", "nonexistent", "development");
209        assert!(result.is_err());
210    }
211
212    #[test]
213    fn test_persistence() {
214        let temp_dir = TempDir::new().unwrap();
215        let entry = create_test_entry("ns", "key1", Environment::Development);
216
217        // Create cache, add entry, drop it
218        {
219            let cache = L2Cache::new(temp_dir.path()).unwrap();
220            cache.put(&entry).unwrap();
221        }
222
223        // Create new cache instance, entry should still be there
224        {
225            let cache = L2Cache::new(temp_dir.path()).unwrap();
226            let retrieved = cache.get("ns", "key1", "development").unwrap();
227            assert_eq!(retrieved.id, entry.id);
228        }
229    }
230
231    #[test]
232    fn test_invalidate() {
233        let temp_dir = TempDir::new().unwrap();
234        let cache = L2Cache::new(temp_dir.path()).unwrap();
235
236        let entry = create_test_entry("ns", "key1", Environment::Development);
237        cache.put(&entry).unwrap();
238
239        assert!(cache.get("ns", "key1", "development").is_ok());
240
241        cache.invalidate("ns", "key1", "development").unwrap();
242
243        assert!(cache.get("ns", "key1", "development").is_err());
244    }
245
246    #[test]
247    fn test_clear() {
248        let temp_dir = TempDir::new().unwrap();
249        let cache = L2Cache::new(temp_dir.path()).unwrap();
250
251        for i in 0..10 {
252            let entry = create_test_entry("ns", &format!("key{}", i), Environment::Development);
253            cache.put(&entry).unwrap();
254        }
255
256        assert_eq!(cache.size(), 10);
257
258        cache.clear().unwrap();
259
260        assert_eq!(cache.size(), 0);
261    }
262}