Skip to main content

oxigeo_server/
cache.rs

1//! Tile caching system
2//!
3//! Provides multi-level caching for rendered tiles:
4//! - In-memory LRU cache for fast access
5//! - Optional disk cache for persistence
6//! - Cache statistics and monitoring
7
8use bytes::Bytes;
9use lru::LruCache;
10use std::fmt;
11use std::hash::Hash;
12use std::num::NonZeroUsize;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16use thiserror::Error;
17use tracing::{debug, trace, warn};
18
19/// Cache errors
20#[derive(Debug, Error)]
21pub enum CacheError {
22    /// I/O error
23    #[error("Cache I/O error: {0}")]
24    Io(#[from] std::io::Error),
25
26    /// Invalid cache key
27    #[error("Invalid cache key")]
28    InvalidKey,
29
30    /// Cache is full
31    #[error("Cache is full")]
32    Full,
33
34    /// An internal lock was poisoned by a panic in another thread
35    #[error("Cache lock poisoned (a panic occurred while a lock was held)")]
36    Poisoned,
37}
38
39/// Result type for cache operations
40pub type CacheResult<T> = Result<T, CacheError>;
41
42/// Cache key for tiles
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct CacheKey {
45    /// Layer name
46    pub layer: String,
47
48    /// Zoom level
49    pub z: u8,
50
51    /// Tile X coordinate
52    pub x: u32,
53
54    /// Tile Y coordinate
55    pub y: u32,
56
57    /// Image format extension (png, jpg, webp)
58    pub format: String,
59
60    /// Optional style name
61    pub style: Option<String>,
62}
63
64impl CacheKey {
65    /// Create a new cache key
66    pub fn new(layer: String, z: u8, x: u32, y: u32, format: String) -> Self {
67        Self {
68            layer,
69            z,
70            x,
71            y,
72            format,
73            style: None,
74        }
75    }
76
77    /// Create a cache key with style
78    pub fn with_style(mut self, style: String) -> Self {
79        self.style = Some(style);
80        self
81    }
82
83    /// Get the file path for disk cache
84    pub fn to_path(&self, base_dir: &Path) -> PathBuf {
85        let mut path = base_dir.to_path_buf();
86        path.push(&self.layer);
87
88        if let Some(ref style) = self.style {
89            path.push(style);
90        }
91
92        path.push(self.z.to_string());
93        path.push(self.x.to_string());
94        path.push(format!("{}.{}", self.y, self.format));
95        path
96    }
97}
98
99impl fmt::Display for CacheKey {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        if let Some(ref style) = self.style {
102            write!(
103                f,
104                "{}/{}/{}/{}/{}.{}",
105                self.layer, style, self.z, self.x, self.y, self.format
106            )
107        } else {
108            write!(
109                f,
110                "{}/{}/{}/{}.{}",
111                self.layer, self.z, self.x, self.y, self.format
112            )
113        }
114    }
115}
116
117/// Cached tile entry
118#[derive(Debug, Clone)]
119struct CacheEntry {
120    /// Tile data
121    data: Bytes,
122
123    /// When this entry was created
124    created_at: Instant,
125
126    /// Entry size in bytes
127    size: usize,
128
129    /// Access count
130    access_count: u64,
131}
132
133impl CacheEntry {
134    /// Create a new cache entry
135    fn new(data: Bytes) -> Self {
136        let size = data.len();
137        Self {
138            data,
139            created_at: Instant::now(),
140            size,
141            access_count: 0,
142        }
143    }
144
145    /// Check if this entry has expired
146    fn is_expired(&self, ttl: Duration) -> bool {
147        self.created_at.elapsed() > ttl
148    }
149
150    /// Record an access
151    fn record_access(&mut self) {
152        self.access_count += 1;
153    }
154}
155
156/// Cache statistics
157#[derive(Debug, Clone, Default)]
158pub struct CacheStats {
159    /// Number of cache hits
160    pub hits: u64,
161
162    /// Number of cache misses
163    pub misses: u64,
164
165    /// Total number of entries
166    pub entry_count: usize,
167
168    /// Total size in bytes
169    pub total_size: usize,
170
171    /// Number of evictions
172    pub evictions: u64,
173
174    /// Number of expirations
175    pub expirations: u64,
176
177    /// Number of disk reads
178    pub disk_reads: u64,
179
180    /// Number of disk writes
181    pub disk_writes: u64,
182
183    /// Number of cache-insertion failures (memory-promotion or disk-write errors)
184    pub put_failures: u64,
185}
186
187impl CacheStats {
188    /// Calculate hit rate
189    pub fn hit_rate(&self) -> f64 {
190        let total = self.hits + self.misses;
191        if total == 0 {
192            0.0
193        } else {
194            self.hits as f64 / total as f64
195        }
196    }
197
198    /// Calculate average entry size
199    pub fn avg_entry_size(&self) -> f64 {
200        if self.entry_count == 0 {
201            0.0
202        } else {
203            self.total_size as f64 / self.entry_count as f64
204        }
205    }
206}
207
208/// Tile cache configuration
209#[derive(Debug, Clone)]
210pub struct TileCacheConfig {
211    /// Maximum memory size in bytes
212    pub max_memory_bytes: usize,
213
214    /// Optional disk cache directory
215    pub disk_cache_dir: Option<PathBuf>,
216
217    /// Time-to-live for cached entries
218    pub ttl: Duration,
219
220    /// Enable statistics tracking
221    pub enable_stats: bool,
222
223    /// Compress cached data
224    pub compression: bool,
225}
226
227impl Default for TileCacheConfig {
228    fn default() -> Self {
229        Self {
230            max_memory_bytes: 256 * 1024 * 1024, // 256 MB
231            disk_cache_dir: None,
232            ttl: Duration::from_secs(3600), // 1 hour
233            enable_stats: true,
234            compression: false,
235        }
236    }
237}
238
239/// Multi-level tile cache
240pub struct TileCache {
241    /// In-memory LRU cache
242    memory_cache: Arc<Mutex<LruCache<CacheKey, CacheEntry>>>,
243
244    /// Current memory usage
245    memory_usage: Arc<Mutex<usize>>,
246
247    /// Cache configuration
248    config: TileCacheConfig,
249
250    /// Cache statistics
251    stats: Arc<Mutex<CacheStats>>,
252}
253
254/// Minimum cache capacity (100 entries)
255const MIN_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(100) {
256    Some(n) => n,
257    None => unreachable!(),
258};
259
260impl TileCache {
261    /// Create a new tile cache
262    pub fn new(config: TileCacheConfig) -> Self {
263        // Calculate capacity based on assumed average tile size of 10KB
264        let estimated_capacity = config.max_memory_bytes / (10 * 1024);
265        // Use min capacity of 100, max of estimated_capacity
266        let capacity = NonZeroUsize::new(estimated_capacity)
267            .unwrap_or(MIN_CACHE_CAPACITY)
268            .max(MIN_CACHE_CAPACITY);
269
270        Self {
271            memory_cache: Arc::new(Mutex::new(LruCache::new(capacity))),
272            memory_usage: Arc::new(Mutex::new(0)),
273            config,
274            stats: Arc::new(Mutex::new(CacheStats::default())),
275        }
276    }
277
278    /// Get a tile from the cache
279    pub fn get(&self, key: &CacheKey) -> Option<Bytes> {
280        trace!("Cache lookup: {}", key.to_string());
281
282        // Try memory cache first
283        if let Some(data) = self.get_from_memory(key) {
284            self.record_hit();
285            return Some(data);
286        }
287
288        // Try disk cache if enabled
289        if self.config.disk_cache_dir.is_some()
290            && let Some(data) = self.get_from_disk(key)
291        {
292            // Promote to memory cache. A failure here is non-fatal (the disk hit
293            // is still returned) but must not be silently swallowed, or a broken
294            // memory cache would degrade to perpetual disk-only reads unnoticed.
295            if let Err(e) = self.put_in_memory(key.clone(), data.clone()) {
296                warn!("Failed to promote disk-cache hit into memory cache: {}", e);
297                self.record_put_failure();
298            }
299            self.record_hit();
300            return Some(data);
301        }
302
303        self.record_miss();
304        None
305    }
306
307    /// Put a tile in the cache
308    pub fn put(&self, key: CacheKey, data: Bytes) -> CacheResult<()> {
309        trace!("Caching tile: {}", key.to_string());
310
311        // Store in memory cache
312        self.put_in_memory(key.clone(), data.clone())?;
313
314        // Store in disk cache if enabled. A disk write failure (e.g. disk full or
315        // permission denied) must not fail the overall put — the tile is already
316        // in memory — but it must be surfaced so operators can detect a broken
317        // disk cache instead of silent perpetual disk-cache misses.
318        if self.config.disk_cache_dir.is_some()
319            && let Err(e) = self.put_on_disk(&key, &data)
320        {
321            warn!("Failed to write tile to disk cache ({}): {}", key, e);
322            self.record_put_failure();
323        }
324
325        Ok(())
326    }
327
328    /// Get from memory cache
329    fn get_from_memory(&self, key: &CacheKey) -> Option<Bytes> {
330        let mut cache = self.memory_cache.lock().ok()?;
331
332        // Check if entry exists and is not expired
333        let is_expired = if let Some(entry) = cache.peek(key) {
334            entry.is_expired(self.config.ttl)
335        } else {
336            return None;
337        };
338
339        if is_expired {
340            trace!("Entry expired: {}", key.to_string());
341            self.record_expiration();
342            let entry = cache.pop(key)?;
343            self.update_memory_usage(|usage| usage.saturating_sub(entry.size));
344            return None;
345        }
346
347        // Entry exists and is not expired - get it and record access
348        if let Some(entry) = cache.get_mut(key) {
349            entry.record_access();
350            Some(entry.data.clone())
351        } else {
352            None
353        }
354    }
355
356    /// Put in memory cache
357    fn put_in_memory(&self, key: CacheKey, data: Bytes) -> CacheResult<()> {
358        let entry = CacheEntry::new(data);
359        let entry_size = entry.size;
360
361        let mut cache = self.memory_cache.lock().map_err(|_| CacheError::Full)?;
362
363        // Evict entries if necessary
364        while self.get_memory_usage() + entry_size > self.config.max_memory_bytes {
365            if let Some((_, evicted)) = cache.pop_lru() {
366                debug!("Evicting entry from memory cache");
367                self.update_memory_usage(|usage| usage.saturating_sub(evicted.size));
368                self.record_eviction();
369            } else {
370                break;
371            }
372        }
373
374        // Insert new entry
375        if let Some(old_entry) = cache.put(key, entry) {
376            self.update_memory_usage(|usage| usage.saturating_sub(old_entry.size));
377        }
378
379        self.update_memory_usage(|usage| usage + entry_size);
380
381        Ok(())
382    }
383
384    /// Get from disk cache
385    fn get_from_disk(&self, key: &CacheKey) -> Option<Bytes> {
386        let base_dir = self.config.disk_cache_dir.as_ref()?;
387        let path = key.to_path(base_dir);
388
389        match std::fs::read(&path) {
390            Ok(data) => {
391                trace!("Disk cache hit: {}", path.display());
392                self.record_disk_read();
393                Some(Bytes::from(data))
394            }
395            Err(_) => None,
396        }
397    }
398
399    /// Put on disk cache
400    fn put_on_disk(&self, key: &CacheKey, data: &Bytes) -> CacheResult<()> {
401        let base_dir =
402            self.config
403                .disk_cache_dir
404                .as_ref()
405                .ok_or(CacheError::Io(std::io::Error::new(
406                    std::io::ErrorKind::NotFound,
407                    "No disk cache directory",
408                )))?;
409
410        let path = key.to_path(base_dir);
411
412        // Create parent directories
413        if let Some(parent) = path.parent() {
414            std::fs::create_dir_all(parent)?;
415        }
416
417        // Write tile to disk
418        std::fs::write(&path, data)?;
419        self.record_disk_write();
420
421        trace!("Wrote to disk cache: {}", path.display());
422        Ok(())
423    }
424
425    /// Clear all cached entries
426    ///
427    /// If an internal lock is poisoned (a prior panic occurred while it was
428    /// held), the poisoned guard is recovered via `into_inner()` so the clear
429    /// still takes effect, and the poisoning is surfaced via a warning rather
430    /// than silently leaving stale state behind.
431    pub fn clear(&self) -> CacheResult<()> {
432        // Clear memory cache, recovering from a poisoned lock.
433        match self.memory_cache.lock() {
434            Ok(mut cache) => cache.clear(),
435            Err(poison) => {
436                warn!("memory_cache lock poisoned during clear(); recovering and clearing");
437                poison.into_inner().clear();
438            }
439        }
440
441        self.update_memory_usage(|_| 0);
442
443        // Clear disk cache if enabled
444        if let Some(ref dir) = self.config.disk_cache_dir
445            && dir.exists()
446        {
447            std::fs::remove_dir_all(dir)?;
448            std::fs::create_dir_all(dir)?;
449        }
450
451        // Reset stats, recovering from a poisoned lock.
452        match self.stats.lock() {
453            Ok(mut stats) => *stats = CacheStats::default(),
454            Err(poison) => {
455                warn!("stats lock poisoned during clear(); recovering and resetting");
456                *poison.into_inner() = CacheStats::default();
457            }
458        }
459
460        debug!("Cache cleared");
461        Ok(())
462    }
463
464    /// Get cache statistics
465    ///
466    /// On lock poisoning, the last-known stats are recovered (they remain valid
467    /// after a panic) and a warning is emitted, instead of silently returning a
468    /// zeroed default that would hide the underlying fault from operators.
469    pub fn stats(&self) -> CacheStats {
470        match self.stats.lock() {
471            Ok(stats) => stats.clone(),
472            Err(poison) => {
473                warn!("stats lock poisoned; returning last-known stats");
474                poison.into_inner().clone()
475            }
476        }
477    }
478
479    /// Get current memory usage
480    fn get_memory_usage(&self) -> usize {
481        match self.memory_usage.lock() {
482            Ok(usage) => *usage,
483            Err(poison) => {
484                warn!("memory_usage lock poisoned; returning last-known usage");
485                *poison.into_inner()
486            }
487        }
488    }
489
490    /// Update memory usage
491    fn update_memory_usage<F>(&self, f: F)
492    where
493        F: FnOnce(usize) -> usize,
494    {
495        if let Ok(mut usage) = self.memory_usage.lock() {
496            *usage = f(*usage);
497        }
498
499        if let Ok(mut stats) = self.stats.lock() {
500            stats.total_size = self.get_memory_usage();
501        }
502    }
503
504    /// Record a cache hit
505    fn record_hit(&self) {
506        if self.config.enable_stats
507            && let Ok(mut stats) = self.stats.lock()
508        {
509            stats.hits += 1;
510        }
511    }
512
513    /// Record a cache miss
514    fn record_miss(&self) {
515        if self.config.enable_stats
516            && let Ok(mut stats) = self.stats.lock()
517        {
518            stats.misses += 1;
519        }
520    }
521
522    /// Record an eviction
523    fn record_eviction(&self) {
524        if self.config.enable_stats
525            && let Ok(mut stats) = self.stats.lock()
526        {
527            stats.evictions += 1;
528        }
529    }
530
531    /// Record an expiration
532    fn record_expiration(&self) {
533        if self.config.enable_stats
534            && let Ok(mut stats) = self.stats.lock()
535        {
536            stats.expirations += 1;
537        }
538    }
539
540    /// Record a disk read
541    fn record_disk_read(&self) {
542        if self.config.enable_stats
543            && let Ok(mut stats) = self.stats.lock()
544        {
545            stats.disk_reads += 1;
546        }
547    }
548
549    /// Record a disk write
550    fn record_disk_write(&self) {
551        if self.config.enable_stats
552            && let Ok(mut stats) = self.stats.lock()
553        {
554            stats.disk_writes += 1;
555        }
556    }
557
558    /// Record a cache-insertion failure (memory promotion or disk write)
559    fn record_put_failure(&self) {
560        if self.config.enable_stats
561            && let Ok(mut stats) = self.stats.lock()
562        {
563            stats.put_failures += 1;
564        }
565    }
566}
567
568impl Clone for TileCache {
569    fn clone(&self) -> Self {
570        Self {
571            memory_cache: Arc::clone(&self.memory_cache),
572            memory_usage: Arc::clone(&self.memory_usage),
573            config: self.config.clone(),
574            stats: Arc::clone(&self.stats),
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn test_cache_key_to_string() {
585        let key = CacheKey::new("landsat".to_string(), 10, 512, 384, "png".to_string());
586        assert_eq!(key.to_string(), "landsat/10/512/384.png");
587
588        let key_with_style = key.with_style("default".to_string());
589        assert_eq!(key_with_style.to_string(), "landsat/default/10/512/384.png");
590    }
591
592    #[test]
593    fn test_cache_put_get() {
594        let config = TileCacheConfig::default();
595        let cache = TileCache::new(config);
596
597        let key = CacheKey::new("test".to_string(), 0, 0, 0, "png".to_string());
598        let data = Bytes::from(vec![1, 2, 3, 4, 5]);
599
600        cache.put(key.clone(), data.clone()).expect("put failed");
601
602        let retrieved = cache.get(&key).expect("get failed");
603        assert_eq!(retrieved, data);
604    }
605
606    #[test]
607    fn test_cache_miss() {
608        let config = TileCacheConfig::default();
609        let cache = TileCache::new(config);
610
611        let key = CacheKey::new("test".to_string(), 0, 0, 0, "png".to_string());
612        assert!(cache.get(&key).is_none());
613
614        let stats = cache.stats();
615        assert_eq!(stats.misses, 1);
616        assert_eq!(stats.hits, 0);
617    }
618
619    #[test]
620    fn test_cache_stats() {
621        let config = TileCacheConfig::default();
622        let cache = TileCache::new(config);
623
624        let key1 = CacheKey::new("test".to_string(), 0, 0, 0, "png".to_string());
625        let key2 = CacheKey::new("test".to_string(), 0, 0, 1, "png".to_string());
626        let data = Bytes::from(vec![1, 2, 3]);
627
628        cache.put(key1.clone(), data.clone()).expect("put failed");
629        cache.put(key2.clone(), data.clone()).expect("put failed");
630
631        cache.get(&key1);
632        cache.get(&key2);
633        cache.get(&CacheKey::new(
634            "nonexistent".to_string(),
635            0,
636            0,
637            0,
638            "png".to_string(),
639        ));
640
641        let stats = cache.stats();
642        assert_eq!(stats.hits, 2);
643        assert_eq!(stats.misses, 1);
644        assert!(stats.hit_rate() > 0.6);
645    }
646
647    #[test]
648    fn test_cache_clear() {
649        let config = TileCacheConfig::default();
650        let cache = TileCache::new(config);
651
652        let key = CacheKey::new("test".to_string(), 0, 0, 0, "png".to_string());
653        let data = Bytes::from(vec![1, 2, 3]);
654
655        cache.put(key.clone(), data).expect("put failed");
656        assert!(cache.get(&key).is_some());
657
658        cache.clear().expect("clear failed");
659        assert!(cache.get(&key).is_none());
660    }
661}