Skip to main content

oxigdal_edge/
cache.rs

1//! Local-first caching for edge devices
2//!
3//! Provides efficient caching mechanisms optimized for resource-constrained
4//! edge devices with offline-first architecture.
5
6use crate::error::{EdgeError, Result};
7use bytes::Bytes;
8use chrono::{DateTime, Utc};
9use lru::LruCache;
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::num::NonZeroUsize;
14use std::path::PathBuf;
15use std::sync::Arc;
16
17/// Cache eviction policy
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum CachePolicy {
20    /// Least Recently Used
21    Lru,
22    /// Least Frequently Used
23    Lfu,
24    /// Time To Live
25    Ttl,
26    /// Size-based eviction
27    SizeBased,
28}
29
30/// Cache configuration
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct CacheConfig {
33    /// Maximum cache size in bytes
34    pub max_size: usize,
35    /// Cache eviction policy
36    pub policy: CachePolicy,
37    /// Time to live in seconds
38    pub ttl_secs: Option<u64>,
39    /// Enable persistent cache
40    pub persistent: bool,
41    /// Cache directory for persistent storage
42    pub cache_dir: Option<PathBuf>,
43    /// Maximum number of entries
44    pub max_entries: usize,
45}
46
47impl Default for CacheConfig {
48    fn default() -> Self {
49        Self {
50            max_size: crate::DEFAULT_CACHE_SIZE,
51            policy: CachePolicy::Lru,
52            ttl_secs: Some(3600), // 1 hour
53            persistent: false,
54            cache_dir: None,
55            max_entries: 1000,
56        }
57    }
58}
59
60impl CacheConfig {
61    /// Create minimal cache config for embedded devices
62    pub fn minimal() -> Self {
63        Self {
64            max_size: 1024 * 1024, // 1 MB
65            policy: CachePolicy::Lru,
66            ttl_secs: Some(1800), // 30 minutes
67            persistent: false,
68            cache_dir: None,
69            max_entries: 100,
70        }
71    }
72
73    /// Create config for offline-first mode
74    pub fn offline_first() -> Self {
75        Self {
76            max_size: 50 * 1024 * 1024, // 50 MB
77            policy: CachePolicy::Lru,
78            ttl_secs: None, // No expiration
79            persistent: true,
80            cache_dir: Some(PathBuf::from(".oxigdal_cache")),
81            max_entries: 5000,
82        }
83    }
84}
85
86/// Cache entry with metadata
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct CacheEntry {
89    /// Entry key
90    pub key: String,
91    /// Cached data
92    pub data: Bytes,
93    /// Creation timestamp
94    pub created_at: DateTime<Utc>,
95    /// Last access timestamp
96    pub accessed_at: DateTime<Utc>,
97    /// Access count
98    pub access_count: u64,
99    /// Entry size in bytes
100    pub size: usize,
101    /// Optional expiration time
102    pub expires_at: Option<DateTime<Utc>>,
103}
104
105impl CacheEntry {
106    /// Create new cache entry
107    pub fn new(key: String, data: Bytes) -> Self {
108        let now = Utc::now();
109        let size = data.len();
110        Self {
111            key,
112            data,
113            created_at: now,
114            accessed_at: now,
115            access_count: 0,
116            size,
117            expires_at: None,
118        }
119    }
120
121    /// Create entry with TTL
122    pub fn with_ttl(key: String, data: Bytes, ttl_secs: u64) -> Self {
123        let mut entry = Self::new(key, data);
124        entry.expires_at = Some(Utc::now() + chrono::Duration::seconds(ttl_secs as i64));
125        entry
126    }
127
128    /// Check if entry is expired
129    pub fn is_expired(&self) -> bool {
130        if let Some(expires_at) = self.expires_at {
131            Utc::now() > expires_at
132        } else {
133            false
134        }
135    }
136
137    /// Mark entry as accessed
138    pub fn mark_accessed(&mut self) {
139        self.accessed_at = Utc::now();
140        self.access_count = self.access_count.saturating_add(1);
141    }
142}
143
144/// Edge cache implementation
145pub struct Cache {
146    config: CacheConfig,
147    lru_cache: Arc<RwLock<LruCache<String, CacheEntry>>>,
148    metadata: Arc<RwLock<HashMap<String, CacheMetadata>>>,
149    current_size: Arc<RwLock<usize>>,
150    persistent_storage: Option<sled::Db>,
151}
152
153/// Cache metadata for tracking entry statistics
154#[derive(Debug, Clone)]
155struct CacheMetadata {
156    /// Size of the cached entry in bytes
157    size: usize,
158    /// Number of times this entry has been accessed
159    access_count: u64,
160}
161
162impl Cache {
163    /// Create new cache with configuration
164    pub fn new(config: CacheConfig) -> Result<Self> {
165        let max_entries = NonZeroUsize::new(config.max_entries)
166            .ok_or_else(|| EdgeError::invalid_config("max_entries must be greater than 0"))?;
167
168        let lru_cache = Arc::new(RwLock::new(LruCache::new(max_entries)));
169        let metadata = Arc::new(RwLock::new(HashMap::new()));
170        let current_size = Arc::new(RwLock::new(0));
171
172        let persistent_storage = if config.persistent {
173            if let Some(cache_dir) = &config.cache_dir {
174                let db = sled::open(cache_dir).map_err(|e| EdgeError::storage(e.to_string()))?;
175                Some(db)
176            } else {
177                None
178            }
179        } else {
180            None
181        };
182
183        Ok(Self {
184            config,
185            lru_cache,
186            metadata,
187            current_size,
188            persistent_storage,
189        })
190    }
191
192    /// Get entry from cache
193    pub fn get(&self, key: &str) -> Result<Option<Bytes>> {
194        // Try memory cache first
195        let mut cache = self.lru_cache.write();
196        if let Some(entry) = cache.get_mut(key) {
197            if !entry.is_expired() {
198                entry.mark_accessed();
199                return Ok(Some(entry.data.clone()));
200            } else {
201                // Remove expired entry
202                cache.pop(key);
203                let mut meta = self.metadata.write();
204                meta.remove(key);
205            }
206        }
207        drop(cache);
208
209        // Try persistent storage if enabled
210        if let Some(db) = &self.persistent_storage {
211            if let Some(value) = db.get(key).map_err(|e| EdgeError::storage(e.to_string()))? {
212                let entry: CacheEntry = serde_json::from_slice(&value)
213                    .map_err(|e| EdgeError::deserialization(e.to_string()))?;
214
215                if !entry.is_expired() {
216                    // Restore to memory cache
217                    let mut cache = self.lru_cache.write();
218                    cache.put(key.to_string(), entry.clone());
219                    return Ok(Some(entry.data));
220                }
221            }
222        }
223
224        Ok(None)
225    }
226
227    /// Put entry into cache
228    pub fn put(&self, key: String, data: Bytes) -> Result<()> {
229        let entry_size = data.len();
230
231        // Check size constraint
232        if entry_size > self.config.max_size {
233            return Err(EdgeError::cache(format!(
234                "Entry size {} exceeds max cache size {}",
235                entry_size, self.config.max_size
236            )));
237        }
238
239        // Create cache entry
240        let entry = if let Some(ttl) = self.config.ttl_secs {
241            CacheEntry::with_ttl(key.clone(), data, ttl)
242        } else {
243            CacheEntry::new(key.clone(), data)
244        };
245
246        // Evict entries if necessary
247        self.evict_if_needed(entry_size)?;
248
249        // Insert into memory cache
250        let mut cache = self.lru_cache.write();
251        cache.put(key.clone(), entry.clone());
252        drop(cache);
253
254        // Update metadata
255        let mut meta = self.metadata.write();
256        meta.insert(
257            key.clone(),
258            CacheMetadata {
259                size: entry_size,
260                access_count: 0,
261            },
262        );
263        drop(meta);
264
265        // Update size
266        let mut current_size = self.current_size.write();
267        *current_size = current_size.saturating_add(entry_size);
268        drop(current_size);
269
270        // Persist if enabled
271        if let Some(db) = &self.persistent_storage {
272            let serialized =
273                serde_json::to_vec(&entry).map_err(|e| EdgeError::serialization(e.to_string()))?;
274            db.insert(key.as_bytes(), serialized)
275                .map_err(|e| EdgeError::storage(e.to_string()))?;
276        }
277
278        Ok(())
279    }
280
281    /// Remove entry from cache
282    pub fn remove(&self, key: &str) -> Result<Option<Bytes>> {
283        let mut cache = self.lru_cache.write();
284        let entry = cache.pop(key);
285        drop(cache);
286
287        if let Some(ref e) = entry {
288            // Update metadata
289            let mut meta = self.metadata.write();
290            meta.remove(key);
291            drop(meta);
292
293            // Update size
294            let mut current_size = self.current_size.write();
295            *current_size = current_size.saturating_sub(e.size);
296            drop(current_size);
297
298            // Remove from persistent storage
299            if let Some(db) = &self.persistent_storage {
300                db.remove(key.as_bytes())
301                    .map_err(|e| EdgeError::storage(e.to_string()))?;
302            }
303        }
304
305        Ok(entry.map(|e| e.data))
306    }
307
308    /// Clear all cache entries
309    pub fn clear(&self) -> Result<()> {
310        let mut cache = self.lru_cache.write();
311        cache.clear();
312        drop(cache);
313
314        let mut meta = self.metadata.write();
315        meta.clear();
316        drop(meta);
317
318        let mut current_size = self.current_size.write();
319        *current_size = 0;
320        drop(current_size);
321
322        if let Some(db) = &self.persistent_storage {
323            db.clear().map_err(|e| EdgeError::storage(e.to_string()))?;
324        }
325
326        Ok(())
327    }
328
329    /// Get current cache size
330    pub fn size(&self) -> usize {
331        *self.current_size.read()
332    }
333
334    /// Get number of cached entries
335    pub fn len(&self) -> usize {
336        self.lru_cache.read().len()
337    }
338
339    /// Check if cache is empty
340    pub fn is_empty(&self) -> bool {
341        self.len() == 0
342    }
343
344    /// Evict entries based on policy
345    fn evict_if_needed(&self, new_entry_size: usize) -> Result<()> {
346        let current_size = *self.current_size.read();
347        let target_size = self.config.max_size.saturating_sub(new_entry_size);
348
349        if current_size <= target_size {
350            return Ok(());
351        }
352
353        let mut to_evict = Vec::new();
354        let mut freed_size = 0;
355
356        match self.config.policy {
357            CachePolicy::Lru => {
358                // LRU eviction is handled by LruCache automatically
359                let mut cache = self.lru_cache.write();
360                while freed_size < current_size.saturating_sub(target_size) && !cache.is_empty() {
361                    if let Some((key, entry)) = cache.pop_lru() {
362                        freed_size = freed_size.saturating_add(entry.size);
363                        to_evict.push(key);
364                    }
365                }
366            }
367            CachePolicy::Lfu => {
368                // Evict least frequently used
369                let meta = self.metadata.read();
370                let mut entries: Vec<_> = meta.iter().collect();
371                entries.sort_by_key(|(_, m)| m.access_count);
372
373                for (key, metadata) in entries {
374                    if freed_size >= current_size.saturating_sub(target_size) {
375                        break;
376                    }
377                    freed_size = freed_size.saturating_add(metadata.size);
378                    to_evict.push(key.clone());
379                }
380            }
381            CachePolicy::Ttl => {
382                // Evict expired entries first
383                let cache = self.lru_cache.read();
384                for (key, entry) in cache.iter() {
385                    if entry.is_expired() {
386                        freed_size = freed_size.saturating_add(entry.size);
387                        to_evict.push(key.clone());
388                    }
389                }
390            }
391            CachePolicy::SizeBased => {
392                // Evict largest entries first
393                let meta = self.metadata.read();
394                let mut entries: Vec<_> = meta.iter().collect();
395                entries.sort_by_key(|(_, m)| std::cmp::Reverse(m.size));
396
397                for (key, metadata) in entries {
398                    if freed_size >= current_size.saturating_sub(target_size) {
399                        break;
400                    }
401                    freed_size = freed_size.saturating_add(metadata.size);
402                    to_evict.push(key.clone());
403                }
404            }
405        }
406
407        // Remove evicted entries
408        for key in to_evict {
409            self.remove(&key)?;
410        }
411
412        Ok(())
413    }
414
415    /// Get cache statistics
416    pub fn stats(&self) -> CacheStats {
417        CacheStats {
418            entries: self.len(),
419            size_bytes: self.size(),
420            max_size_bytes: self.config.max_size,
421            max_entries: self.config.max_entries,
422            policy: self.config.policy,
423        }
424    }
425}
426
427/// Cache statistics
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct CacheStats {
430    /// Number of entries
431    pub entries: usize,
432    /// Current size in bytes
433    pub size_bytes: usize,
434    /// Maximum size in bytes
435    pub max_size_bytes: usize,
436    /// Maximum number of entries
437    pub max_entries: usize,
438    /// Cache policy
439    pub policy: CachePolicy,
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_cache_creation() {
448        let config = CacheConfig::default();
449        let cache = Cache::new(config);
450        assert!(cache.is_ok());
451    }
452
453    #[test]
454    fn test_cache_put_get() -> Result<()> {
455        let config = CacheConfig::minimal();
456        let cache = Cache::new(config)?;
457
458        let key = "test_key".to_string();
459        let data = Bytes::from("test_data");
460
461        cache.put(key.clone(), data.clone())?;
462        let retrieved = cache.get(&key)?;
463
464        assert_eq!(retrieved, Some(data));
465        Ok(())
466    }
467
468    #[test]
469    fn test_cache_eviction() -> Result<()> {
470        let mut config = CacheConfig::minimal();
471        config.max_size = 100;
472        config.max_entries = 10;
473
474        let cache = Cache::new(config)?;
475
476        // Fill cache
477        for i in 0..5 {
478            let key = format!("key_{}", i);
479            let data = Bytes::from(vec![0u8; 25]);
480            cache.put(key, data)?;
481        }
482
483        // Should trigger eviction
484        let key = "new_key".to_string();
485        let data = Bytes::from(vec![0u8; 25]);
486        cache.put(key.clone(), data.clone())?;
487
488        let retrieved = cache.get(&key)?;
489        assert_eq!(retrieved, Some(data));
490
491        Ok(())
492    }
493
494    #[test]
495    fn test_cache_remove() -> Result<()> {
496        let config = CacheConfig::minimal();
497        let cache = Cache::new(config)?;
498
499        let key = "test_key".to_string();
500        let data = Bytes::from("test_data");
501
502        cache.put(key.clone(), data.clone())?;
503        let removed = cache.remove(&key)?;
504
505        assert_eq!(removed, Some(data));
506        assert_eq!(cache.get(&key)?, None);
507
508        Ok(())
509    }
510
511    #[test]
512    fn test_cache_clear() -> Result<()> {
513        let config = CacheConfig::minimal();
514        let cache = Cache::new(config)?;
515
516        for i in 0..5 {
517            let key = format!("key_{}", i);
518            let data = Bytes::from(format!("data_{}", i));
519            cache.put(key, data)?;
520        }
521
522        assert_eq!(cache.len(), 5);
523
524        cache.clear()?;
525        assert_eq!(cache.len(), 0);
526        assert!(cache.is_empty());
527
528        Ok(())
529    }
530
531    #[test]
532    fn test_entry_expiration() {
533        let key = "test".to_string();
534        let data = Bytes::from("data");
535
536        let entry = CacheEntry::with_ttl(key, data, 0);
537        std::thread::sleep(std::time::Duration::from_millis(10));
538        assert!(entry.is_expired());
539    }
540}