Skip to main content

oxigeo_streaming/tile/
cache.rs

1//! Tile caching for improved performance.
2
3use super::protocol::{TileCoordinate, TileResponse};
4use crate::error::{Result, StreamingError};
5use dashmap::DashMap;
6use lru::LruCache;
7use std::num::NonZeroUsize;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{Duration, SystemTime};
12use tokio::fs;
13use tokio::sync::RwLock;
14use tracing::debug;
15
16/// zlib compression level used for the disk cache when `compress` is enabled.
17const DISK_COMPRESS_LEVEL: u8 = 6;
18
19/// Configuration for tile cache.
20#[derive(Debug, Clone)]
21pub struct TileCacheConfig {
22    /// Maximum number of tiles in memory cache
23    pub max_memory_tiles: usize,
24
25    /// Maximum size of disk cache in bytes
26    pub max_disk_bytes: u64,
27
28    /// Directory for disk cache
29    pub disk_cache_dir: Option<PathBuf>,
30
31    /// Enable compression for disk cache
32    pub compress: bool,
33
34    /// TTL for cached tiles in seconds
35    pub ttl_seconds: u64,
36}
37
38impl Default for TileCacheConfig {
39    fn default() -> Self {
40        Self {
41            max_memory_tiles: 1000,
42            max_disk_bytes: 1024 * 1024 * 1024, // 1GB
43            disk_cache_dir: None,
44            compress: false,
45            ttl_seconds: 3600, // 1 hour
46        }
47    }
48}
49
50/// Metadata for a tile persisted to the disk cache.
51#[derive(Clone)]
52struct DiskEntry {
53    /// On-disk path of the (possibly compressed) tile bytes.
54    path: PathBuf,
55    /// The tile's real content type, preserved so reloads report the correct MIME.
56    content_type: String,
57    /// When the tile was written (for TTL enforcement on disk hits).
58    cached_at: SystemTime,
59    /// Number of bytes actually stored on disk (after optional compression).
60    stored_bytes: u64,
61    /// Whether the on-disk bytes are zlib-compressed.
62    compressed: bool,
63}
64
65/// Tile cache implementation.
66pub struct TileCache {
67    config: TileCacheConfig,
68    memory_cache: Arc<RwLock<LruCache<TileCoordinate, CachedTile>>>,
69    disk_cache_map: Arc<DashMap<TileCoordinate, DiskEntry>>,
70    /// Cumulative bytes currently held by the disk cache.
71    disk_bytes: Arc<AtomicU64>,
72}
73
74struct CachedTile {
75    response: TileResponse,
76    cached_at: std::time::Instant,
77}
78
79impl TileCache {
80    /// Create a new tile cache.
81    pub fn new(config: TileCacheConfig) -> Result<Self> {
82        let max_size = NonZeroUsize::new(config.max_memory_tiles)
83            .ok_or_else(|| StreamingError::ConfigError("Invalid cache size".to_string()))?;
84
85        Ok(Self {
86            config,
87            memory_cache: Arc::new(RwLock::new(LruCache::new(max_size))),
88            disk_cache_map: Arc::new(DashMap::new()),
89            disk_bytes: Arc::new(AtomicU64::new(0)),
90        })
91    }
92
93    /// Get a tile from cache.
94    pub async fn get(&self, coord: &TileCoordinate) -> Option<TileResponse> {
95        // Check memory cache
96        let mut cache = self.memory_cache.write().await;
97        if let Some(cached) = cache.get(coord)
98            && !self.is_expired(&cached.cached_at)
99        {
100            debug!("Memory cache hit for tile {}", coord);
101            return Some(cached.response.clone());
102        }
103        drop(cache);
104
105        // Check disk cache — but honor the TTL for disk hits too.
106        let entry = self.disk_cache_map.get(coord).map(|e| e.value().clone());
107        if let Some(entry) = entry {
108            if self.is_disk_expired(entry.cached_at) {
109                debug!("Disk cache entry for tile {} expired; evicting", coord);
110                self.evict_disk(coord).await;
111                return None;
112            }
113            if let Ok(response) = self.load_from_disk(coord, &entry).await {
114                debug!("Disk cache hit for tile {}", coord);
115                // Promote to memory cache
116                self.put_memory(coord, response.clone()).await.ok();
117                return Some(response);
118            }
119        }
120
121        None
122    }
123
124    /// Put a tile in cache.
125    pub async fn put(&self, response: TileResponse) -> Result<()> {
126        let coord = response.coord;
127
128        // Store in memory cache
129        self.put_memory(&coord, response.clone()).await?;
130
131        // Store in disk cache if enabled
132        if self.config.disk_cache_dir.is_some() {
133            self.put_disk(&coord, response).await?;
134        }
135
136        Ok(())
137    }
138
139    /// Put a tile in memory cache.
140    async fn put_memory(&self, coord: &TileCoordinate, response: TileResponse) -> Result<()> {
141        let mut cache = self.memory_cache.write().await;
142        cache.put(
143            *coord,
144            CachedTile {
145                response,
146                cached_at: std::time::Instant::now(),
147            },
148        );
149        Ok(())
150    }
151
152    /// Put a tile in disk cache.
153    ///
154    /// The tile's real content type is preserved, the bytes are optionally
155    /// zlib-compressed (when `config.compress` is set), and the cumulative disk
156    /// usage is accounted for and bounded by `config.max_disk_bytes` via
157    /// oldest-first eviction.
158    async fn put_disk(&self, coord: &TileCoordinate, response: TileResponse) -> Result<()> {
159        let cache_dir =
160            self.config.disk_cache_dir.as_ref().ok_or_else(|| {
161                StreamingError::ConfigError("Disk cache not configured".to_string())
162            })?;
163
164        let ext = ext_for_content_type(&response.content_type);
165        let compressed = self.config.compress;
166        let file_name = if compressed {
167            format!("{}/{}/{}.{}.z", coord.z, coord.x, coord.y, ext)
168        } else {
169            format!("{}/{}/{}.{}", coord.z, coord.x, coord.y, ext)
170        };
171        let path = cache_dir.join(file_name);
172
173        // Create parent directory
174        if let Some(parent) = path.parent() {
175            fs::create_dir_all(parent)
176                .await
177                .map_err(StreamingError::Io)?;
178        }
179
180        // Optionally compress the payload (Pure-Rust zlib via oxiarc-deflate).
181        let bytes_to_write: Vec<u8> = if compressed {
182            oxiarc_deflate::zlib_compress(&response.data, DISK_COMPRESS_LEVEL).map_err(|e| {
183                StreamingError::Other(format!("disk cache zlib compression failed: {e}"))
184            })?
185        } else {
186            response.data.to_vec()
187        };
188        let stored_bytes = bytes_to_write.len() as u64;
189
190        // Write tile data
191        fs::write(&path, &bytes_to_write)
192            .await
193            .map_err(StreamingError::Io)?;
194
195        // If this coordinate already had a disk entry, remove its old file and
196        // reclaim its byte accounting before inserting the new one.
197        if let Some((_, old)) = self.disk_cache_map.remove(coord) {
198            if old.path != path {
199                fs::remove_file(&old.path).await.ok();
200            }
201            self.disk_bytes.fetch_sub(
202                old.stored_bytes
203                    .min(self.disk_bytes.load(Ordering::Relaxed)),
204                Ordering::Relaxed,
205            );
206        }
207
208        self.disk_cache_map.insert(
209            *coord,
210            DiskEntry {
211                path,
212                content_type: response.content_type.clone(),
213                cached_at: SystemTime::now(),
214                stored_bytes,
215                compressed,
216            },
217        );
218        self.disk_bytes.fetch_add(stored_bytes, Ordering::Relaxed);
219
220        // Enforce the disk-size budget, evicting oldest entries first.
221        self.enforce_disk_limit().await;
222
223        Ok(())
224    }
225
226    /// Evict oldest disk entries until the cumulative size is within
227    /// `config.max_disk_bytes`.
228    async fn enforce_disk_limit(&self) {
229        while self.disk_bytes.load(Ordering::Relaxed) > self.config.max_disk_bytes {
230            // Find the oldest entry (smallest `cached_at`).
231            let oldest = self
232                .disk_cache_map
233                .iter()
234                .min_by_key(|e| e.value().cached_at)
235                .map(|e| *e.key());
236            match oldest {
237                Some(coord) => self.evict_disk(&coord).await,
238                None => break, // nothing left to evict
239            }
240        }
241    }
242
243    /// Remove a single tile from the disk cache (file + accounting + map entry).
244    async fn evict_disk(&self, coord: &TileCoordinate) {
245        if let Some((_, entry)) = self.disk_cache_map.remove(coord) {
246            fs::remove_file(&entry.path).await.ok();
247            let current = self.disk_bytes.load(Ordering::Relaxed);
248            self.disk_bytes
249                .fetch_sub(entry.stored_bytes.min(current), Ordering::Relaxed);
250        }
251    }
252
253    /// Load a tile from disk cache, decompressing and restoring the real
254    /// content type recorded when it was stored.
255    async fn load_from_disk(
256        &self,
257        coord: &TileCoordinate,
258        entry: &DiskEntry,
259    ) -> Result<TileResponse> {
260        let raw = fs::read(&entry.path).await.map_err(StreamingError::Io)?;
261        let data = if entry.compressed {
262            oxiarc_deflate::zlib_decompress(&raw).map_err(|e| {
263                StreamingError::Other(format!("disk cache zlib decompression failed: {e}"))
264            })?
265        } else {
266            raw
267        };
268
269        Ok(TileResponse::new(
270            *coord,
271            bytes::Bytes::from(data),
272            entry.content_type.clone(),
273        ))
274    }
275
276    /// Check if a cached tile is expired.
277    fn is_expired(&self, cached_at: &std::time::Instant) -> bool {
278        cached_at.elapsed() > Duration::from_secs(self.config.ttl_seconds)
279    }
280
281    /// Check if a disk-cached tile is expired (based on wall-clock `cached_at`).
282    fn is_disk_expired(&self, cached_at: SystemTime) -> bool {
283        SystemTime::now()
284            .duration_since(cached_at)
285            .unwrap_or(Duration::ZERO)
286            > Duration::from_secs(self.config.ttl_seconds)
287    }
288
289    /// Clear all caches.
290    pub async fn clear(&self) -> Result<()> {
291        let mut cache = self.memory_cache.write().await;
292        cache.clear();
293        drop(cache);
294
295        self.disk_cache_map.clear();
296        self.disk_bytes.store(0, Ordering::Relaxed);
297
298        if let Some(cache_dir) = &self.config.disk_cache_dir
299            && cache_dir.exists()
300        {
301            fs::remove_dir_all(cache_dir)
302                .await
303                .map_err(StreamingError::Io)?;
304        }
305
306        Ok(())
307    }
308
309    /// Get cache statistics.
310    pub async fn stats(&self) -> CacheStats {
311        let cache = self.memory_cache.read().await;
312        CacheStats {
313            memory_tiles: cache.len(),
314            disk_tiles: self.disk_cache_map.len(),
315            disk_bytes: self.disk_bytes.load(Ordering::Relaxed),
316            max_memory_tiles: self.config.max_memory_tiles,
317        }
318    }
319}
320
321/// Map a tile content type to a file extension for the disk cache.
322fn ext_for_content_type(content_type: &str) -> &'static str {
323    // Ignore any parameters after ';' (e.g. "image/png; charset=binary").
324    let base = content_type
325        .split(';')
326        .next()
327        .unwrap_or(content_type)
328        .trim();
329    match base {
330        "image/png" => "png",
331        "image/jpeg" => "jpg",
332        "image/webp" => "webp",
333        "application/x-protobuf" | "application/vnd.mapbox-vector-tile" => "pbf",
334        "application/geo+json" => "geojson",
335        "application/json" => "json",
336        _ => "bin",
337    }
338}
339
340/// Cache statistics.
341#[derive(Debug, Clone)]
342pub struct CacheStats {
343    /// Number of tiles in memory cache
344    pub memory_tiles: usize,
345
346    /// Number of tiles in disk cache
347    pub disk_tiles: usize,
348
349    /// Cumulative bytes held by the disk cache (after optional compression).
350    pub disk_bytes: u64,
351
352    /// Maximum memory cache size
353    pub max_memory_tiles: usize,
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use bytes::Bytes;
360
361    #[tokio::test]
362    async fn test_memory_cache() {
363        let config = TileCacheConfig {
364            max_memory_tiles: 10,
365            ..Default::default()
366        };
367
368        let cache = TileCache::new(config).ok();
369        assert!(cache.is_some());
370
371        if let Some(cache) = cache {
372            let coord = TileCoordinate::new(10, 512, 384);
373            let response =
374                TileResponse::new(coord, Bytes::from(vec![0u8; 1024]), "image/png".to_string());
375
376            cache.put(response).await.ok();
377
378            let retrieved = cache.get(&coord).await;
379            assert!(retrieved.is_some());
380        }
381    }
382
383    fn temp_cache_dir(tag: &str) -> PathBuf {
384        std::env::temp_dir().join(format!(
385            "oxigeo_streaming_cache_{}_{}_{}",
386            tag,
387            std::process::id(),
388            SystemTime::now()
389                .duration_since(std::time::UNIX_EPOCH)
390                .map(|d| d.as_nanos())
391                .unwrap_or(0)
392        ))
393    }
394
395    #[tokio::test]
396    async fn test_disk_cache_preserves_content_type_and_compresses() {
397        let dir = temp_cache_dir("ct");
398        let config = TileCacheConfig {
399            max_memory_tiles: 1, // force eviction from memory so disk is exercised
400            disk_cache_dir: Some(dir.clone()),
401            compress: true,
402            ttl_seconds: 3600,
403            ..Default::default()
404        };
405        let cache = TileCache::new(config).expect("cache");
406
407        let coord = TileCoordinate::new(4, 2, 3);
408        // A highly compressible JPEG payload with a non-png content type.
409        let payload = Bytes::from(vec![7u8; 4096]);
410        let response = TileResponse::new(coord, payload.clone(), "image/jpeg".to_string());
411        cache.put(response).await.expect("put");
412
413        // Evict from memory by inserting another tile (max_memory_tiles = 1).
414        let other = TileCoordinate::new(4, 9, 9);
415        cache
416            .put(TileResponse::new(
417                other,
418                Bytes::from(vec![1u8; 8]),
419                "image/png".to_string(),
420            ))
421            .await
422            .expect("put other");
423
424        // Now the original comes from disk: content type preserved (NOT png),
425        // and the decompressed bytes are intact.
426        let got = cache.get(&coord).await.expect("disk hit");
427        assert_eq!(got.content_type, "image/jpeg");
428        assert_eq!(&got.data[..], &payload[..]);
429
430        // Compression actually shrank the on-disk footprint.
431        let stats = cache.stats().await;
432        assert!(
433            stats.disk_bytes < 4096,
434            "compressed disk bytes should be < raw"
435        );
436
437        cache.clear().await.ok();
438    }
439
440    #[tokio::test]
441    async fn test_disk_cache_enforces_max_disk_bytes() {
442        let dir = temp_cache_dir("limit");
443        let config = TileCacheConfig {
444            max_memory_tiles: 100,
445            max_disk_bytes: 3000, // room for ~2 of the 1024-byte tiles below
446            disk_cache_dir: Some(dir.clone()),
447            compress: false,
448            ttl_seconds: 3600,
449        };
450        let cache = TileCache::new(config).expect("cache");
451
452        for i in 0..5u32 {
453            let coord = TileCoordinate::new(5, i, 0);
454            cache
455                .put(TileResponse::new(
456                    coord,
457                    Bytes::from(vec![i as u8; 1024]),
458                    "application/octet-stream".to_string(),
459                ))
460                .await
461                .expect("put");
462        }
463
464        let stats = cache.stats().await;
465        assert!(
466            stats.disk_bytes <= 3000,
467            "disk cache must stay within max_disk_bytes, got {}",
468            stats.disk_bytes
469        );
470        assert!(
471            stats.disk_tiles < 5,
472            "some tiles must have been evicted, have {}",
473            stats.disk_tiles
474        );
475
476        cache.clear().await.ok();
477    }
478
479    #[tokio::test]
480    async fn test_disk_cache_respects_ttl() {
481        let dir = temp_cache_dir("ttl");
482        let config = TileCacheConfig {
483            max_memory_tiles: 1,
484            disk_cache_dir: Some(dir.clone()),
485            compress: false,
486            ttl_seconds: 0, // everything is immediately stale
487            ..Default::default()
488        };
489        let cache = TileCache::new(config).expect("cache");
490
491        let coord = TileCoordinate::new(3, 1, 1);
492        cache
493            .put(TileResponse::new(
494                coord,
495                Bytes::from(vec![9u8; 16]),
496                "image/png".to_string(),
497            ))
498            .await
499            .expect("put");
500
501        // Push out of memory cache.
502        cache
503            .put(TileResponse::new(
504                TileCoordinate::new(3, 2, 2),
505                Bytes::from(vec![0u8; 4]),
506                "image/png".to_string(),
507            ))
508            .await
509            .expect("put other");
510
511        // With ttl_seconds = 0 the disk entry is expired and must not be served.
512        assert!(
513            cache.get(&coord).await.is_none(),
514            "expired disk tile must not be returned"
515        );
516
517        cache.clear().await.ok();
518    }
519}