1use 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
16const DISK_COMPRESS_LEVEL: u8 = 6;
18
19#[derive(Debug, Clone)]
21pub struct TileCacheConfig {
22 pub max_memory_tiles: usize,
24
25 pub max_disk_bytes: u64,
27
28 pub disk_cache_dir: Option<PathBuf>,
30
31 pub compress: bool,
33
34 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, disk_cache_dir: None,
44 compress: false,
45 ttl_seconds: 3600, }
47 }
48}
49
50#[derive(Clone)]
52struct DiskEntry {
53 path: PathBuf,
55 content_type: String,
57 cached_at: SystemTime,
59 stored_bytes: u64,
61 compressed: bool,
63}
64
65pub struct TileCache {
67 config: TileCacheConfig,
68 memory_cache: Arc<RwLock<LruCache<TileCoordinate, CachedTile>>>,
69 disk_cache_map: Arc<DashMap<TileCoordinate, DiskEntry>>,
70 disk_bytes: Arc<AtomicU64>,
72}
73
74struct CachedTile {
75 response: TileResponse,
76 cached_at: std::time::Instant,
77}
78
79impl TileCache {
80 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 pub async fn get(&self, coord: &TileCoordinate) -> Option<TileResponse> {
95 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 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 self.put_memory(coord, response.clone()).await.ok();
117 return Some(response);
118 }
119 }
120
121 None
122 }
123
124 pub async fn put(&self, response: TileResponse) -> Result<()> {
126 let coord = response.coord;
127
128 self.put_memory(&coord, response.clone()).await?;
130
131 if self.config.disk_cache_dir.is_some() {
133 self.put_disk(&coord, response).await?;
134 }
135
136 Ok(())
137 }
138
139 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 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 if let Some(parent) = path.parent() {
175 fs::create_dir_all(parent)
176 .await
177 .map_err(StreamingError::Io)?;
178 }
179
180 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 fs::write(&path, &bytes_to_write)
192 .await
193 .map_err(StreamingError::Io)?;
194
195 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 self.enforce_disk_limit().await;
222
223 Ok(())
224 }
225
226 async fn enforce_disk_limit(&self) {
229 while self.disk_bytes.load(Ordering::Relaxed) > self.config.max_disk_bytes {
230 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, }
240 }
241 }
242
243 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 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 fn is_expired(&self, cached_at: &std::time::Instant) -> bool {
278 cached_at.elapsed() > Duration::from_secs(self.config.ttl_seconds)
279 }
280
281 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 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 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
321fn ext_for_content_type(content_type: &str) -> &'static str {
323 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#[derive(Debug, Clone)]
342pub struct CacheStats {
343 pub memory_tiles: usize,
345
346 pub disk_tiles: usize,
348
349 pub disk_bytes: u64,
351
352 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, 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 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 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 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 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, 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, ..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 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 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}