Skip to main content

oxigdal_services/tile_cache/
cache.rs

1//! Tile cache data structures: TileKey, CachedTile, TileCache, TilePrefetcher.
2//!
3//! Implements an LRU tile cache with byte-budget eviction, staleness checking,
4//! FNV-1a ETag generation, and a prefetcher that enumerates neighboring tiles.
5
6use std::collections::{HashMap, VecDeque};
7
8// ── TileFormat ────────────────────────────────────────────────────────────────
9
10/// The serialization format of a tile.
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum TileFormat {
13    /// Mapbox Vector Tile — `application/vnd.mapbox-vector-tile`
14    Mvt,
15    /// PNG raster tile — `image/png`
16    Png,
17    /// JPEG raster tile — `image/jpeg`
18    Jpeg,
19    /// WebP raster tile — `image/webp`
20    Webp,
21    /// JSON tile (e.g. UTFGrid) — `application/json`
22    Json,
23}
24
25impl TileFormat {
26    /// Returns the file extension used in tile paths.
27    #[must_use]
28    pub fn extension(&self) -> &'static str {
29        match self {
30            TileFormat::Mvt => "mvt",
31            TileFormat::Png => "png",
32            TileFormat::Jpeg => "jpg",
33            TileFormat::Webp => "webp",
34            TileFormat::Json => "json",
35        }
36    }
37
38    /// Returns the MIME content-type string.
39    #[must_use]
40    pub fn content_type(&self) -> &'static str {
41        match self {
42            TileFormat::Mvt => "application/vnd.mapbox-vector-tile",
43            TileFormat::Png => "image/png",
44            TileFormat::Jpeg => "image/jpeg",
45            TileFormat::Webp => "image/webp",
46            TileFormat::Json => "application/json",
47        }
48    }
49}
50
51// ── TileKey ───────────────────────────────────────────────────────────────────
52
53/// Uniquely identifies a tile by zoom level, column, row, layer name, and format.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct TileKey {
56    /// Zoom level (0–22).
57    pub z: u8,
58    /// Tile column.
59    pub x: u32,
60    /// Tile row.
61    pub y: u32,
62    /// Layer name.
63    pub layer: String,
64    /// Serialization format.
65    pub format: TileFormat,
66}
67
68impl TileKey {
69    /// Creates a new `TileKey`.
70    pub fn new(z: u8, x: u32, y: u32, layer: impl Into<String>, format: TileFormat) -> Self {
71        Self {
72            z,
73            x,
74            y,
75            layer: layer.into(),
76            format,
77        }
78    }
79
80    /// Returns the canonical path string `"{layer}/{z}/{x}/{y}.{ext}"`.
81    #[must_use]
82    pub fn path_string(&self) -> String {
83        format!(
84            "{}/{}/{}/{}.{}",
85            self.layer,
86            self.z,
87            self.x,
88            self.y,
89            self.format.extension()
90        )
91    }
92
93    /// Returns the MIME content-type for this tile's format.
94    #[must_use]
95    pub fn content_type(&self) -> &'static str {
96        self.format.content_type()
97    }
98}
99
100// ── TileEncoding ──────────────────────────────────────────────────────────────
101
102/// Content-encoding applied to the tile bytes.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum TileEncoding {
105    /// No content-encoding (identity).
106    Identity,
107    /// Gzip-compressed.
108    Gzip,
109    /// Brotli-compressed.
110    Brotli,
111}
112
113// ── CachedTile ────────────────────────────────────────────────────────────────
114
115/// A tile stored in the cache together with its metadata.
116#[derive(Debug, Clone)]
117pub struct CachedTile {
118    /// The tile's unique key.
119    pub key: TileKey,
120    /// Raw tile bytes.
121    pub data: Vec<u8>,
122    /// ETag string (quoted FNV-1a hex).
123    pub etag: String,
124    /// Unix timestamp when the tile was cached.
125    pub created_at: u64,
126    /// Unix timestamp of the most recent access.
127    pub accessed_at: u64,
128    /// Number of times this tile has been accessed (starts at 1 on creation).
129    pub access_count: u64,
130    /// Size of `data` in bytes.
131    pub size_bytes: u64,
132    /// Content-encoding of `data`.
133    pub encoding: TileEncoding,
134}
135
136impl CachedTile {
137    /// Creates a new `CachedTile`, computing the ETag from `data`.
138    pub fn new(key: TileKey, data: Vec<u8>, timestamp: u64) -> Self {
139        let etag = Self::compute_etag(&data);
140        let size_bytes = data.len() as u64;
141        Self {
142            key,
143            data,
144            etag,
145            created_at: timestamp,
146            accessed_at: timestamp,
147            access_count: 1,
148            size_bytes,
149            encoding: TileEncoding::Identity,
150        }
151    }
152
153    /// Computes a quoted FNV-1a 64-bit hex ETag for `data`.
154    fn compute_etag(data: &[u8]) -> String {
155        const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
156        const FNV_PRIME: u64 = 1_099_511_628_211;
157        let mut hash = FNV_OFFSET;
158        for &byte in data {
159            hash ^= u64::from(byte);
160            hash = hash.wrapping_mul(FNV_PRIME);
161        }
162        format!("\"{hash:016x}\"")
163    }
164
165    /// Returns `true` if the tile has exceeded `max_age_secs` since creation.
166    #[must_use]
167    pub fn is_stale(&self, max_age_secs: u64, now: u64) -> bool {
168        now >= self.created_at.saturating_add(max_age_secs)
169    }
170}
171
172// ── CacheStats ────────────────────────────────────────────────────────────────
173
174/// A snapshot of `TileCache` statistics.
175#[derive(Debug, Clone)]
176pub struct CacheStats {
177    /// Number of entries currently in the cache.
178    pub entry_count: usize,
179    /// Total bytes occupied by cached tile data.
180    pub total_bytes: u64,
181    /// Cumulative cache hits.
182    pub hit_count: u64,
183    /// Cumulative cache misses.
184    pub miss_count: u64,
185    /// Cumulative evictions.
186    pub eviction_count: u64,
187    /// Hit rate in the range `[0.0, 1.0]`.
188    pub hit_rate: f64,
189}
190
191// ── TileCache ─────────────────────────────────────────────────────────────────
192
193/// LRU tile cache with entry-count and byte-budget eviction.
194///
195/// Entries at the **back** of `access_order` are most recently used;
196/// the **front** is evicted first.
197pub struct TileCache {
198    entries: HashMap<TileKey, CachedTile>,
199    access_order: VecDeque<TileKey>,
200    /// Maximum number of entries before LRU eviction.
201    pub max_entries: usize,
202    /// Maximum total bytes before LRU eviction.
203    pub max_bytes: u64,
204    /// Current total bytes of cached tile data.
205    pub current_bytes: u64,
206    /// Cumulative cache hits.
207    pub hit_count: u64,
208    /// Cumulative cache misses.
209    pub miss_count: u64,
210    /// Cumulative evictions.
211    pub eviction_count: u64,
212}
213
214impl TileCache {
215    /// Creates a new `TileCache` with the given capacity limits.
216    pub fn new(max_entries: usize, max_bytes: u64) -> Self {
217        Self {
218            entries: HashMap::new(),
219            access_order: VecDeque::new(),
220            max_entries,
221            max_bytes,
222            current_bytes: 0,
223            hit_count: 0,
224            miss_count: 0,
225            eviction_count: 0,
226        }
227    }
228
229    /// Looks up `key` in the cache.  On a hit the access metadata is updated
230    /// and the key is promoted to the back of the LRU queue.
231    pub fn get(&mut self, key: &TileKey, now: u64) -> Option<&CachedTile> {
232        if self.entries.contains_key(key) {
233            self.hit_count += 1;
234            // Promote to back of access_order
235            if let Some(pos) = self.access_order.iter().position(|k| k == key) {
236                self.access_order.remove(pos);
237            }
238            self.access_order.push_back(key.clone());
239            // Update access metadata
240            if let Some(tile) = self.entries.get_mut(key) {
241                tile.accessed_at = now;
242                tile.access_count += 1;
243            }
244            self.entries.get(key)
245        } else {
246            self.miss_count += 1;
247            None
248        }
249    }
250
251    /// Inserts `tile` into the cache, evicting LRU entries as needed.
252    pub fn insert(&mut self, tile: CachedTile) {
253        // If the key already exists, remove old size first
254        if let Some(old) = self.entries.remove(&tile.key) {
255            self.current_bytes = self.current_bytes.saturating_sub(old.size_bytes);
256            if let Some(pos) = self.access_order.iter().position(|k| k == &old.key) {
257                self.access_order.remove(pos);
258            }
259        }
260
261        let key = tile.key.clone();
262        self.current_bytes += tile.size_bytes;
263        self.entries.insert(key.clone(), tile);
264        self.access_order.push_back(key);
265
266        // Evict until within limits
267        while self.entries.len() > self.max_entries
268            || (self.current_bytes > self.max_bytes && self.entries.len() > 1)
269        {
270            self.evict_lru();
271        }
272    }
273
274    /// Removes `key` from the cache.  Returns `true` if the entry existed.
275    pub fn invalidate(&mut self, key: &TileKey) -> bool {
276        if let Some(tile) = self.entries.remove(key) {
277            self.current_bytes = self.current_bytes.saturating_sub(tile.size_bytes);
278            if let Some(pos) = self.access_order.iter().position(|k| k == key) {
279                self.access_order.remove(pos);
280            }
281            true
282        } else {
283            false
284        }
285    }
286
287    /// Removes all tiles belonging to `layer`.  Returns the number of entries removed.
288    pub fn invalidate_layer(&mut self, layer: &str) -> u64 {
289        let keys_to_remove: Vec<TileKey> = self
290            .entries
291            .keys()
292            .filter(|k| k.layer == layer)
293            .cloned()
294            .collect();
295        let count = keys_to_remove.len() as u64;
296        for key in keys_to_remove {
297            self.invalidate(&key);
298        }
299        count
300    }
301
302    /// Removes all tiles whose zoom level falls within `[min_z, max_z]`.
303    /// Returns the number of entries removed.
304    pub fn invalidate_zoom_range(&mut self, min_z: u8, max_z: u8) -> u64 {
305        let keys_to_remove: Vec<TileKey> = self
306            .entries
307            .keys()
308            .filter(|k| k.z >= min_z && k.z <= max_z)
309            .cloned()
310            .collect();
311        let count = keys_to_remove.len() as u64;
312        for key in keys_to_remove {
313            self.invalidate(&key);
314        }
315        count
316    }
317
318    /// Returns the cache hit rate in `[0.0, 1.0]`, or `0.0` if no requests.
319    #[must_use]
320    pub fn hit_rate(&self) -> f64 {
321        let total = self.hit_count + self.miss_count;
322        if total == 0 {
323            0.0
324        } else {
325            self.hit_count as f64 / total as f64
326        }
327    }
328
329    /// Returns a snapshot of current cache statistics.
330    #[must_use]
331    pub fn stats(&self) -> CacheStats {
332        CacheStats {
333            entry_count: self.entries.len(),
334            total_bytes: self.current_bytes,
335            hit_count: self.hit_count,
336            miss_count: self.miss_count,
337            eviction_count: self.eviction_count,
338            hit_rate: self.hit_rate(),
339        }
340    }
341
342    /// Evicts the least-recently-used entry (front of `access_order`).
343    fn evict_lru(&mut self) {
344        if let Some(key) = self.access_order.pop_front() {
345            if let Some(tile) = self.entries.remove(&key) {
346                self.current_bytes = self.current_bytes.saturating_sub(tile.size_bytes);
347                self.eviction_count += 1;
348            }
349        }
350    }
351}
352
353// ── TilePrefetcher ────────────────────────────────────────────────────────────
354
355/// Pre-fetches neighboring tiles based on the current access pattern.
356pub struct TilePrefetcher {
357    /// How many tiles around the current tile to prefetch (Chebyshev radius).
358    pub radius: u8,
359    /// Prefetch this many zoom levels above and below the current zoom.
360    pub max_zoom_delta: u8,
361}
362
363impl TilePrefetcher {
364    /// Creates a new `TilePrefetcher` with the given `radius` and a default
365    /// `max_zoom_delta` of 1.
366    pub fn new(radius: u8) -> Self {
367        Self {
368            radius,
369            max_zoom_delta: 1,
370        }
371    }
372
373    /// Returns the set of tiles to prefetch for a given `key`.
374    ///
375    /// Includes the ring of neighbors at the same zoom level plus rings at
376    /// `zoom ± 1 .. max_zoom_delta`.  The tile itself is excluded.
377    pub fn neighbors(&self, key: &TileKey) -> Vec<TileKey> {
378        let mut result: Vec<TileKey> = Vec::new();
379
380        // Same zoom level
381        let same_zoom_ring = self.ring_at_zoom(key, key.z, self.radius);
382        result.extend(same_zoom_ring);
383
384        // Adjacent zoom levels
385        for delta in 1..=self.max_zoom_delta {
386            if key.z >= delta {
387                let lower_zoom = key.z - delta;
388                // Scale coordinates down
389                let scaled_x = key.x >> delta;
390                let scaled_y = key.y >> delta;
391                let parent_key = TileKey::new(
392                    lower_zoom,
393                    scaled_x,
394                    scaled_y,
395                    key.layer.clone(),
396                    key.format.clone(),
397                );
398                let ring = self.ring_at_zoom(&parent_key, lower_zoom, self.radius);
399                for t in ring {
400                    if !result.iter().any(|r| r == &t) {
401                        result.push(t);
402                    }
403                }
404            }
405            let upper_zoom = key.z.saturating_add(delta);
406            if upper_zoom != key.z {
407                // Scale coordinates up
408                let scaled_x = key.x << delta;
409                let scaled_y = key.y << delta;
410                let child_key = TileKey::new(
411                    upper_zoom,
412                    scaled_x,
413                    scaled_y,
414                    key.layer.clone(),
415                    key.format.clone(),
416                );
417                let ring = self.ring_at_zoom(&child_key, upper_zoom, self.radius);
418                for t in ring {
419                    if !result.iter().any(|r| r == &t) {
420                        result.push(t);
421                    }
422                }
423            }
424        }
425
426        // Remove the key itself
427        result.retain(|t| t != key);
428        result
429    }
430
431    /// Returns all tiles in a square ring of `radius` around `key` at `zoom`.
432    ///
433    /// Coordinates are clamped to zero via saturating arithmetic so that boundary
434    /// tiles are valid (x, y ≥ 0).
435    pub fn ring_at_zoom(&self, key: &TileKey, zoom: u8, radius: u8) -> Vec<TileKey> {
436        let r = radius as i64;
437        let mut tiles = Vec::new();
438        for dx in -r..=r {
439            for dy in -r..=r {
440                let nx = if dx < 0 {
441                    key.x.saturating_sub((-dx) as u32)
442                } else {
443                    key.x.saturating_add(dx as u32)
444                };
445                let ny = if dy < 0 {
446                    key.y.saturating_sub((-dy) as u32)
447                } else {
448                    key.y.saturating_add(dy as u32)
449                };
450                tiles.push(TileKey::new(
451                    zoom,
452                    nx,
453                    ny,
454                    key.layer.clone(),
455                    key.format.clone(),
456                ));
457            }
458        }
459        tiles
460    }
461}