tiles.rs
| 1 | //! An OSM tile caching proxy. |
| 2 | //! |
| 3 | //! The web UI never talks to `tile.openstreetmap.org` directly. Two reasons, and |
| 4 | //! only the second is about performance: the browser would leak every viewer's IP |
| 5 | //! and viewport to a third party, and a small deployment re-requesting the same |
| 6 | //! city block all day is exactly the traffic the OSM tile usage policy asks |
| 7 | //! proxies to absorb. |
| 8 | //! |
| 9 | //! Bytes live at `{cache_dir}/tiles/{z}/{x}/{y}.png`. The `tiles` table is the |
| 10 | //! index and the byte accounting; the filesystem alone could not answer "what is |
| 11 | //! the least recently used tile" without walking it. |
| 12 | |
| 13 | use std::path::{Path as FsPath, PathBuf}; |
| 14 | use std::sync::OnceLock; |
| 15 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 16 | use std::time::Duration; |
| 17 | |
| 18 | use anyhow::{Context, Result}; |
| 19 | use axum::Router; |
| 20 | use axum::extract::{Path, State}; |
| 21 | use axum::http::{StatusCode, header}; |
| 22 | use axum::response::{IntoResponse, Response}; |
| 23 | use axum::routing::get; |
| 24 | use sqlx::SqlitePool; |
| 25 | use tokio::io::AsyncWriteExt; |
| 26 | use tower_sessions::Session; |
| 27 | use tracing::{debug, warn}; |
| 28 | |
| 29 | use crate::api::{ApiError, Shared, current_user}; |
| 30 | use crate::db::now; |
| 31 | |
| 32 | /// Deepest zoom the proxy will fetch. OSM itself stops at 19, and accepting more |
| 33 | /// only lets a caller mint cache entries upstream will never satisfy. |
| 34 | const MAX_ZOOM: u8 = 19; |
| 35 | |
| 36 | /// Upper bound on an accepted response body. A PNG tile is a few tens of KiB; a |
| 37 | /// hostile or misconfigured upstream must not be able to fill the disk with one |
| 38 | /// response. |
| 39 | const MAX_TILE_BYTES: usize = 256 * 1024; |
| 40 | |
| 41 | /// Used when upstream sends no `Cache-Control: max-age`. |
| 42 | /// |
| 43 | /// This deliberately overrides a bare `no-cache`, which is what |
| 44 | /// `tile.openstreetmap.org` answers with. Honouring it would send every single |
| 45 | /// tile request upstream and make the proxy worse than useless to the very |
| 46 | /// servers it exists to spare. A rendered tile changes on the order of days. |
| 47 | const DEFAULT_TTL_S: i64 = 7 * 86_400; |
| 48 | |
| 49 | /// How long the *browser* may reuse a tile without asking us again. Tiles are |
| 50 | /// immutable in practice, so this is the cheapest hit of all: no request at all. |
| 51 | const BROWSER_CACHE_CONTROL: &str = "public, max-age=86400"; |
| 52 | |
| 53 | const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); |
| 54 | |
| 55 | /// Merged into the main router by [`crate::api::router`]. |
| 56 | /// |
| 57 | /// No `.png` suffix on the route: axum allows one parameter per path segment, so |
| 58 | /// `{y}.png` is rejected at startup. Leaflet does not care about the extension, |
| 59 | /// and the `Content-Type` header is what a browser actually reads. |
| 60 | pub fn router() -> Router<Shared> { |
| 61 | Router::new().route("/tiles/{z}/{x}/{y}", get(tile)) |
| 62 | } |
| 63 | |
| 64 | /// One client for the whole process, so connections to the tile server are kept |
| 65 | /// alive across requests instead of paying a TLS handshake per tile. |
| 66 | fn client() -> &'static reqwest::Client { |
| 67 | static CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); |
| 68 | CLIENT.get_or_init(|| { |
| 69 | reqwest::Client::builder() |
| 70 | .timeout(UPSTREAM_TIMEOUT) |
| 71 | .build() |
| 72 | // The builder only fails on a broken TLS backend, and a default |
| 73 | // client still works — refusing to serve any map at all would be a |
| 74 | // worse answer than one without keep-alive tuning. |
| 75 | .unwrap_or_default() |
| 76 | }) |
| 77 | } |
| 78 | |
| 79 | #[derive(sqlx::FromRow)] |
| 80 | struct TileRow { |
| 81 | etag: Option<String>, |
| 82 | last_modified: Option<String>, |
| 83 | expires_at: i64, |
| 84 | } |
| 85 | |
| 86 | /// Serve one tile, from cache when possible. |
| 87 | /// |
| 88 | /// Requires a signed-in session, like every other read path. An unauthenticated |
| 89 | /// `/tiles` endpoint is an open proxy: strangers would burn this deployment's OSM |
| 90 | /// quota and get its IP blocked. Leaflet loads tiles as same-origin `<img>` |
| 91 | /// requests, so the session cookie rides along without any JavaScript help. |
| 92 | async fn tile( |
| 93 | State(state): State<Shared>, |
| 94 | session: Session, |
| 95 | Path((z, x, y)): Path<(u8, u32, u32)>, |
| 96 | ) -> Result<Response, ApiError> { |
| 97 | current_user(&state, &session).await?; |
| 98 | // Before anything touches the filesystem: the extractor guarantees these are |
| 99 | // integers, not that they name a tile that can exist. Without this a garbage |
| 100 | // request creates a directory tree. |
| 101 | if !in_range(z, x, y) { |
| 102 | return Err(ApiError::BadRequest(format!( |
| 103 | "no such tile: z must be 0..={MAX_ZOOM} and x, y must be below 2^z" |
| 104 | ))); |
| 105 | } |
| 106 | |
| 107 | let path = tile_path(&state.cfg.cache_dir, z, x, y); |
| 108 | let row: Option<TileRow> = sqlx::query_as( |
| 109 | "SELECT etag, last_modified, expires_at FROM tiles WHERE z = ? AND x = ? AND y = ?", |
| 110 | ) |
| 111 | .bind(i64::from(z)) |
| 112 | .bind(i64::from(x)) |
| 113 | .bind(i64::from(y)) |
| 114 | .fetch_optional(&state.db.read) |
| 115 | .await?; |
| 116 | |
| 117 | // The row and the file can disagree — a manually cleared cache directory, a |
| 118 | // half-restored backup. The bytes are the truth; a row without them is a miss. |
| 119 | let cached = match &row { |
| 120 | Some(_) => tokio::fs::read(&path).await.ok(), |
| 121 | None => None, |
| 122 | }; |
| 123 | if row.is_some() && cached.is_none() { |
| 124 | delete_row(&state.db.write, z, x, y).await?; |
| 125 | } |
| 126 | |
| 127 | let at = now(); |
| 128 | if let (Some(meta), Some(bytes)) = (&row, &cached) |
| 129 | && meta.expires_at > at |
| 130 | { |
| 131 | touch(&state.db.write, z, x, y, at, None).await?; |
| 132 | return Ok(png(bytes.clone())); |
| 133 | } |
| 134 | |
| 135 | // Only when we hold the bytes a 304 would refer to. Sending a validator we |
| 136 | // cannot honour would turn every request into an empty response. |
| 137 | let conditional = row |
| 138 | .as_ref() |
| 139 | .filter(|_| cached.is_some()) |
| 140 | .map(|m| (m.etag.clone(), m.last_modified.clone())); |
| 141 | match fetch(&state.cfg, z, x, y, conditional).await { |
| 142 | // The case that actually keeps the OSM quota happy: a revalidation costs |
| 143 | // a few hundred bytes and refreshes a tile we already hold. |
| 144 | Ok(Fetched::NotModified { expires_at }) => match cached { |
| 145 | Some(bytes) => { |
| 146 | touch(&state.db.write, z, x, y, at, Some(expires_at)).await?; |
| 147 | Ok(png(bytes)) |
| 148 | } |
| 149 | // Upstream answered 304 to a request that carried no validator. |
| 150 | // Serving the zero bytes we hold would render a broken image. |
| 151 | None => Err(ApiError::Internal(anyhow::anyhow!( |
| 152 | "tile upstream sent 304 for a tile we do not have" |
| 153 | ))), |
| 154 | }, |
| 155 | Ok(Fetched::Body { |
| 156 | bytes, |
| 157 | etag, |
| 158 | last_modified, |
| 159 | expires_at, |
| 160 | }) => { |
| 161 | write_tile(&path, &bytes).await?; |
| 162 | upsert( |
| 163 | &state.db.write, |
| 164 | z, |
| 165 | x, |
| 166 | y, |
| 167 | &etag, |
| 168 | &last_modified, |
| 169 | at, |
| 170 | expires_at, |
| 171 | bytes.len() as i64, |
| 172 | ) |
| 173 | .await?; |
| 174 | Ok(png(bytes)) |
| 175 | } |
| 176 | Err(e) => match cached { |
| 177 | // A stale tile is a correct-looking map. An error is a grey square in |
| 178 | // the middle of one, which reads as a broken deployment. |
| 179 | Some(bytes) => { |
| 180 | debug!(error = %e, z, x, y, "serving a stale tile; upstream is unavailable"); |
| 181 | Ok(png(bytes)) |
| 182 | } |
| 183 | None => Err(ApiError::Internal(e)), |
| 184 | }, |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | fn in_range(z: u8, x: u32, y: u32) -> bool { |
| 189 | z <= MAX_ZOOM && u64::from(x) < 1u64 << z && u64::from(y) < 1u64 << z |
| 190 | } |
| 191 | |
| 192 | fn tile_path(cache_dir: &FsPath, z: u8, x: u32, y: u32) -> PathBuf { |
| 193 | cache_dir.join(format!("tiles/{z}/{x}/{y}.png")) |
| 194 | } |
| 195 | |
| 196 | fn upstream_url(template: &str, z: u8, x: u32, y: u32) -> String { |
| 197 | template |
| 198 | .replace("{z}", &z.to_string()) |
| 199 | .replace("{x}", &x.to_string()) |
| 200 | .replace("{y}", &y.to_string()) |
| 201 | } |
| 202 | |
| 203 | fn png(bytes: Vec<u8>) -> Response { |
| 204 | ( |
| 205 | StatusCode::OK, |
| 206 | [ |
| 207 | (header::CONTENT_TYPE, "image/png"), |
| 208 | (header::CACHE_CONTROL, BROWSER_CACHE_CONTROL), |
| 209 | ], |
| 210 | bytes, |
| 211 | ) |
| 212 | .into_response() |
| 213 | } |
| 214 | |
| 215 | enum Fetched { |
| 216 | NotModified { |
| 217 | expires_at: i64, |
| 218 | }, |
| 219 | Body { |
| 220 | bytes: Vec<u8>, |
| 221 | etag: Option<String>, |
| 222 | last_modified: Option<String>, |
| 223 | expires_at: i64, |
| 224 | }, |
| 225 | } |
| 226 | |
| 227 | /// One upstream GET, conditional when we already hold a copy. |
| 228 | /// |
| 229 | /// The `User-Agent` is not decoration: the OSM tile usage policy prohibits |
| 230 | /// library defaults and blocks unidentified proxies without notice, which is why |
| 231 | /// [`crate::config::Config::validate`] refuses to start without a contact address. |
| 232 | async fn fetch( |
| 233 | cfg: &crate::config::Config, |
| 234 | z: u8, |
| 235 | x: u32, |
| 236 | y: u32, |
| 237 | conditional: Option<(Option<String>, Option<String>)>, |
| 238 | ) -> Result<Fetched> { |
| 239 | let url = upstream_url(&cfg.tile_upstream_url, z, x, y); |
| 240 | let mut req = client() |
| 241 | .get(&url) |
| 242 | .header(header::USER_AGENT, cfg.tile_user_agent()); |
| 243 | if let Some((etag, last_modified)) = conditional { |
| 244 | if let Some(etag) = etag { |
| 245 | req = req.header(header::IF_NONE_MATCH, etag); |
| 246 | } |
| 247 | if let Some(lm) = last_modified { |
| 248 | req = req.header(header::IF_MODIFIED_SINCE, lm); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | let resp = req.send().await.with_context(|| format!("GET {url}"))?; |
| 253 | let status = resp.status(); |
| 254 | let expires_at = now() + max_age_of(resp.headers()).unwrap_or(DEFAULT_TTL_S); |
| 255 | if status == reqwest::StatusCode::NOT_MODIFIED { |
| 256 | return Ok(Fetched::NotModified { expires_at }); |
| 257 | } |
| 258 | if !status.is_success() { |
| 259 | anyhow::bail!("tile upstream answered {status} for {url}"); |
| 260 | } |
| 261 | |
| 262 | let etag = header_string(resp.headers(), header::ETAG); |
| 263 | let last_modified = header_string(resp.headers(), header::LAST_MODIFIED); |
| 264 | let bytes = read_capped(resp) |
| 265 | .await |
| 266 | .with_context(|| format!("body of {url}"))?; |
| 267 | Ok(Fetched::Body { |
| 268 | bytes, |
| 269 | etag, |
| 270 | last_modified, |
| 271 | expires_at, |
| 272 | }) |
| 273 | } |
| 274 | |
| 275 | /// Read the body chunk by chunk, refusing to buffer more than |
| 276 | /// [`MAX_TILE_BYTES`]. `Response::bytes` would happily allocate whatever the |
| 277 | /// server sends, and `Content-Length` is the sender's claim, not a bound. |
| 278 | async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> { |
| 279 | let mut out = Vec::new(); |
| 280 | while let Some(chunk) = resp.chunk().await? { |
| 281 | if out.len() + chunk.len() > MAX_TILE_BYTES { |
| 282 | anyhow::bail!("tile body exceeds {MAX_TILE_BYTES} bytes"); |
| 283 | } |
| 284 | out.extend_from_slice(&chunk); |
| 285 | } |
| 286 | Ok(out) |
| 287 | } |
| 288 | |
| 289 | fn header_string(headers: &reqwest::header::HeaderMap, name: header::HeaderName) -> Option<String> { |
| 290 | headers |
| 291 | .get(name) |
| 292 | .and_then(|v| v.to_str().ok()) |
| 293 | .map(str::to_string) |
| 294 | } |
| 295 | |
| 296 | /// `max-age` from a `Cache-Control` header, in seconds. |
| 297 | fn max_age_of(headers: &reqwest::header::HeaderMap) -> Option<i64> { |
| 298 | let value = headers.get(header::CACHE_CONTROL)?.to_str().ok()?; |
| 299 | value |
| 300 | .split(',') |
| 301 | .filter_map(|part| part.trim().strip_prefix("max-age=")) |
| 302 | .find_map(|n| n.trim().parse::<i64>().ok()) |
| 303 | .filter(|n| *n > 0) |
| 304 | } |
| 305 | |
| 306 | /// Write the bytes, then rename into place. |
| 307 | /// |
| 308 | /// The rename is the point: a concurrent reader either sees the previous file or |
| 309 | /// the complete new one, never a half-written PNG. |
| 310 | async fn write_tile(path: &FsPath, bytes: &[u8]) -> Result<()> { |
| 311 | let dir = path.parent().context("tile path has no parent")?; |
| 312 | tokio::fs::create_dir_all(dir) |
| 313 | .await |
| 314 | .with_context(|| format!("creating {}", dir.display()))?; |
| 315 | |
| 316 | // In the same directory, so the rename stays within one filesystem. |
| 317 | static SEQ: AtomicU64 = AtomicU64::new(0); |
| 318 | let temp = dir.join(format!( |
| 319 | ".{}.{}.tmp", |
| 320 | std::process::id(), |
| 321 | SEQ.fetch_add(1, Ordering::Relaxed) |
| 322 | )); |
| 323 | let mut file = tokio::fs::File::create(&temp) |
| 324 | .await |
| 325 | .with_context(|| format!("creating {}", temp.display()))?; |
| 326 | let written = async { |
| 327 | file.write_all(bytes).await?; |
| 328 | file.sync_all().await |
| 329 | } |
| 330 | .await; |
| 331 | if let Err(e) = written { |
| 332 | let _ = tokio::fs::remove_file(&temp).await; |
| 333 | return Err(anyhow::Error::new(e).context("writing a tile")); |
| 334 | } |
| 335 | tokio::fs::rename(&temp, path) |
| 336 | .await |
| 337 | .with_context(|| format!("renaming into {}", path.display()))?; |
| 338 | Ok(()) |
| 339 | } |
| 340 | |
| 341 | async fn touch( |
| 342 | pool: &SqlitePool, |
| 343 | z: u8, |
| 344 | x: u32, |
| 345 | y: u32, |
| 346 | at: i64, |
| 347 | expires_at: Option<i64>, |
| 348 | ) -> Result<(), sqlx::Error> { |
| 349 | sqlx::query( |
| 350 | "UPDATE tiles SET last_access = ?, expires_at = COALESCE(?, expires_at) \ |
| 351 | WHERE z = ? AND x = ? AND y = ?", |
| 352 | ) |
| 353 | .bind(at) |
| 354 | .bind(expires_at) |
| 355 | .bind(i64::from(z)) |
| 356 | .bind(i64::from(x)) |
| 357 | .bind(i64::from(y)) |
| 358 | .execute(pool) |
| 359 | .await |
| 360 | .map(|_| ()) |
| 361 | } |
| 362 | |
| 363 | async fn delete_row(pool: &SqlitePool, z: u8, x: u32, y: u32) -> Result<(), sqlx::Error> { |
| 364 | sqlx::query("DELETE FROM tiles WHERE z = ? AND x = ? AND y = ?") |
| 365 | .bind(i64::from(z)) |
| 366 | .bind(i64::from(x)) |
| 367 | .bind(i64::from(y)) |
| 368 | .execute(pool) |
| 369 | .await |
| 370 | .map(|_| ()) |
| 371 | } |
| 372 | |
| 373 | #[allow(clippy::too_many_arguments)] |
| 374 | async fn upsert( |
| 375 | pool: &SqlitePool, |
| 376 | z: u8, |
| 377 | x: u32, |
| 378 | y: u32, |
| 379 | etag: &Option<String>, |
| 380 | last_modified: &Option<String>, |
| 381 | at: i64, |
| 382 | expires_at: i64, |
| 383 | bytes: i64, |
| 384 | ) -> Result<(), sqlx::Error> { |
| 385 | sqlx::query( |
| 386 | "INSERT INTO tiles (z, x, y, etag, last_modified, fetched_at, expires_at, bytes, last_access) \ |
| 387 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \ |
| 388 | ON CONFLICT (z, x, y) DO UPDATE SET \ |
| 389 | etag = excluded.etag, last_modified = excluded.last_modified, \ |
| 390 | fetched_at = excluded.fetched_at, expires_at = excluded.expires_at, \ |
| 391 | bytes = excluded.bytes, last_access = excluded.last_access", |
| 392 | ) |
| 393 | .bind(i64::from(z)) |
| 394 | .bind(i64::from(x)) |
| 395 | .bind(i64::from(y)) |
| 396 | .bind(etag) |
| 397 | .bind(last_modified) |
| 398 | .bind(at) |
| 399 | .bind(expires_at) |
| 400 | .bind(bytes) |
| 401 | .bind(at) |
| 402 | .execute(pool) |
| 403 | .await |
| 404 | .map(|_| ()) |
| 405 | } |
| 406 | |
| 407 | // --------------------------------------------------------------------------- |
| 408 | // Eviction |
| 409 | // --------------------------------------------------------------------------- |
| 410 | |
| 411 | /// How often the cache is measured against its ceiling. |
| 412 | const EVICT_INTERVAL: Duration = Duration::from_secs(10 * 60); |
| 413 | |
| 414 | /// Rows deleted per pass, so the write lock is never held for long — the same |
| 415 | /// reasoning as [`crate::retention`]'s batches. |
| 416 | const EVICT_BATCH: i64 = 500; |
| 417 | |
| 418 | /// Eviction stops here rather than at the ceiling, so a cache sitting exactly at |
| 419 | /// the limit does not evict a tile on every single fetch. |
| 420 | fn low_water(max_bytes: u64) -> i64 { |
| 421 | (max_bytes / 10 * 9) as i64 |
| 422 | } |
| 423 | |
| 424 | /// Enforce `max_cache_bytes`, oldest access first. |
| 425 | /// |
| 426 | /// On a timer rather than on the request path: eviction is a whole-table sum and |
| 427 | /// a batch of deletes, and making a map pan pay for that would be felt. |
| 428 | pub fn spawn_eviction( |
| 429 | pool: SqlitePool, |
| 430 | cache_dir: PathBuf, |
| 431 | max_bytes: u64, |
| 432 | ) -> tokio::task::JoinHandle<()> { |
| 433 | tokio::spawn(async move { |
| 434 | let mut ticker = tokio::time::interval(EVICT_INTERVAL); |
| 435 | // Sleep first: startup already has enough to do. |
| 436 | ticker.tick().await; |
| 437 | loop { |
| 438 | ticker.tick().await; |
| 439 | if let Err(e) = evict_once(&pool, &cache_dir, max_bytes).await { |
| 440 | warn!(error = %e, "tile eviction failed; will retry next tick"); |
| 441 | } |
| 442 | } |
| 443 | }) |
| 444 | } |
| 445 | |
| 446 | /// Delete least-recently-used tiles until the cache is under [`low_water`]. |
| 447 | /// |
| 448 | /// Returns the number of tiles removed. |
| 449 | async fn evict_once(pool: &SqlitePool, cache_dir: &FsPath, max_bytes: u64) -> Result<u64> { |
| 450 | let total: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(bytes), 0) FROM tiles") |
| 451 | .fetch_one(pool) |
| 452 | .await |
| 453 | .context("summing the tile cache")?; |
| 454 | if total <= max_bytes as i64 { |
| 455 | return Ok(0); |
| 456 | } |
| 457 | |
| 458 | let mut remaining = total; |
| 459 | let target = low_water(max_bytes); |
| 460 | let mut removed = 0; |
| 461 | // ponytail: one row deleted per statement, driven by the tiles_last_access |
| 462 | // index. Fine up to the ~100k rows a 1 GiB cache holds; if a deployment runs |
| 463 | // a much larger ceiling, delete by a last_access cutoff in one statement. |
| 464 | while remaining > target { |
| 465 | let batch: Vec<(i64, i64, i64, i64)> = |
| 466 | sqlx::query_as("SELECT z, x, y, bytes FROM tiles ORDER BY last_access LIMIT ?") |
| 467 | .bind(EVICT_BATCH) |
| 468 | .fetch_all(pool) |
| 469 | .await |
| 470 | .context("listing the least recently used tiles")?; |
| 471 | if batch.is_empty() { |
| 472 | break; |
| 473 | } |
| 474 | |
| 475 | for (z, x, y, bytes) in batch { |
| 476 | sqlx::query("DELETE FROM tiles WHERE z = ? AND x = ? AND y = ?") |
| 477 | .bind(z) |
| 478 | .bind(x) |
| 479 | .bind(y) |
| 480 | .execute(pool) |
| 481 | .await |
| 482 | .context("evicting a tile row")?; |
| 483 | // A leftover file is only wasted space, and the next fetch of that |
| 484 | // tile overwrites it — so a failed unlink must not abort the sweep. |
| 485 | let path = tile_path(cache_dir, z as u8, x as u32, y as u32); |
| 486 | let _ = tokio::fs::remove_file(&path).await; |
| 487 | remaining -= bytes; |
| 488 | removed += 1; |
| 489 | if remaining <= target { |
| 490 | break; |
| 491 | } |
| 492 | } |
| 493 | } |
| 494 | debug!(removed, total, target, "tile cache eviction"); |
| 495 | Ok(removed) |
| 496 | } |
| 497 | |
| 498 | #[cfg(test)] |
| 499 | mod tests { |
| 500 | use super::*; |
| 501 | |
| 502 | /// A throwaway database, migrated and ready. Deliberately a local copy of |
| 503 | /// `db::tests::test_db`: that module is private to `db.rs`, so it is not |
| 504 | /// reachable from here even under `cfg(test)`. |
| 505 | async fn test_db() -> (crate::db::Db, tempfile::TempDir) { |
| 506 | let dir = tempfile::tempdir().expect("temp dir"); |
| 507 | let db = crate::db::Db::open(&dir.path().join("test.db")) |
| 508 | .await |
| 509 | .expect("open"); |
| 510 | (db, dir) |
| 511 | } |
| 512 | |
| 513 | #[test] |
| 514 | fn tiles_outside_the_pyramid_are_rejected() { |
| 515 | assert!(in_range(0, 0, 0)); |
| 516 | assert!(in_range(1, 1, 1)); |
| 517 | assert!(in_range(19, (1 << 19) - 1, (1 << 19) - 1)); |
| 518 | |
| 519 | assert!(!in_range(20, 0, 0), "zoom beyond what OSM serves"); |
| 520 | assert!(!in_range(1, 2, 0), "x must be below 2^z"); |
| 521 | assert!(!in_range(1, 0, 2), "y must be below 2^z"); |
| 522 | assert!(!in_range(0, 1, 0)); |
| 523 | assert!(!in_range(19, 1 << 19, 0)); |
| 524 | assert!(!in_range(3, u32::MAX, u32::MAX)); |
| 525 | } |
| 526 | |
| 527 | #[test] |
| 528 | fn the_upstream_url_is_built_from_the_template() { |
| 529 | assert_eq!( |
| 530 | upstream_url("https://tile.openstreetmap.org/{z}/{x}/{y}.png", 7, 66, 44), |
| 531 | "https://tile.openstreetmap.org/7/66/44.png" |
| 532 | ); |
| 533 | // Subdomain-style templates put the placeholders elsewhere; the |
| 534 | // substitution must not care where they are. |
| 535 | assert_eq!( |
| 536 | upstream_url("https://t.example/{x}-{y}@{z}", 3, 1, 2), |
| 537 | "https://t.example/1-2@3" |
| 538 | ); |
| 539 | } |
| 540 | |
| 541 | #[test] |
| 542 | fn the_cache_path_mirrors_the_request_path() { |
| 543 | assert_eq!( |
| 544 | tile_path(FsPath::new("/var/cache/ot"), 7, 66, 44), |
| 545 | PathBuf::from("/var/cache/ot/tiles/7/66/44.png") |
| 546 | ); |
| 547 | } |
| 548 | |
| 549 | #[test] |
| 550 | fn an_upstream_max_age_sets_the_expiry() { |
| 551 | let mut headers = reqwest::header::HeaderMap::new(); |
| 552 | assert_eq!( |
| 553 | max_age_of(&headers), |
| 554 | None, |
| 555 | "no header means the default TTL" |
| 556 | ); |
| 557 | |
| 558 | headers.insert( |
| 559 | header::CACHE_CONTROL, |
| 560 | "public, max-age=604800".parse().expect("literal"), |
| 561 | ); |
| 562 | assert_eq!(max_age_of(&headers), Some(604_800)); |
| 563 | |
| 564 | // `no-cache` carries no max-age, so the default applies rather than a |
| 565 | // zero TTL that would revalidate on every single request. |
| 566 | headers.insert(header::CACHE_CONTROL, "no-cache".parse().expect("literal")); |
| 567 | assert_eq!(max_age_of(&headers), None); |
| 568 | } |
| 569 | |
| 570 | #[tokio::test] |
| 571 | async fn a_tile_is_renamed_into_place_rather_than_written_in_pieces() { |
| 572 | let dir = tempfile::tempdir().expect("temp dir"); |
| 573 | let path = tile_path(dir.path(), 4, 1, 2); |
| 574 | write_tile(&path, b"\x89PNG").await.expect("write"); |
| 575 | assert_eq!( |
| 576 | tokio::fs::read(&path).await.expect("read"), |
| 577 | b"\x89PNG", |
| 578 | "the file must exist with its full contents" |
| 579 | ); |
| 580 | |
| 581 | // No temp file survives a successful write. |
| 582 | let leftovers: Vec<_> = std::fs::read_dir(path.parent().expect("parent")) |
| 583 | .expect("read_dir") |
| 584 | .filter_map(|e| e.ok()) |
| 585 | .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) |
| 586 | .collect(); |
| 587 | assert!(leftovers.is_empty(), "a temp file was left behind"); |
| 588 | } |
| 589 | |
| 590 | /// Inserts `n` tiles of `bytes` each, oldest access first. |
| 591 | async fn seed(pool: &SqlitePool, dir: &FsPath, n: u32, bytes: i64) { |
| 592 | for i in 0..n { |
| 593 | let path = tile_path(dir, 1, i, 0); |
| 594 | write_tile(&path, &vec![0u8; bytes as usize]) |
| 595 | .await |
| 596 | .expect("tile file"); |
| 597 | upsert(pool, 1, i, 0, &None, &None, i64::from(i), 0, bytes) |
| 598 | .await |
| 599 | .expect("row"); |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | #[tokio::test] |
| 604 | async fn eviction_removes_the_least_recently_used_tiles_down_to_the_low_water_mark() { |
| 605 | let (db, _db_dir) = test_db().await; |
| 606 | let cache = tempfile::tempdir().expect("temp dir"); |
| 607 | // 10 tiles of 100 bytes against a 500-byte ceiling: 1000 bytes cached, |
| 608 | // and eviction must stop at 450, not at 500. |
| 609 | seed(&db.write, cache.path(), 10, 100).await; |
| 610 | |
| 611 | let removed = evict_once(&db.write, cache.path(), 500) |
| 612 | .await |
| 613 | .expect("evict"); |
| 614 | assert_eq!(removed, 6, "1000 bytes down to 450 needs six tiles gone"); |
| 615 | |
| 616 | let survivors: Vec<i64> = sqlx::query_scalar("SELECT x FROM tiles ORDER BY x") |
| 617 | .fetch_all(&db.read) |
| 618 | .await |
| 619 | .expect("rows"); |
| 620 | assert_eq!( |
| 621 | survivors, |
| 622 | vec![6, 7, 8, 9], |
| 623 | "the oldest accesses must go first" |
| 624 | ); |
| 625 | |
| 626 | assert!( |
| 627 | !tile_path(cache.path(), 1, 0, 0).exists(), |
| 628 | "an evicted row must take its file with it, or the accounting lies" |
| 629 | ); |
| 630 | assert!(tile_path(cache.path(), 1, 9, 0).exists()); |
| 631 | } |
| 632 | |
| 633 | #[tokio::test] |
| 634 | async fn a_cache_under_its_ceiling_is_left_alone() { |
| 635 | let (db, _db_dir) = test_db().await; |
| 636 | let cache = tempfile::tempdir().expect("temp dir"); |
| 637 | seed(&db.write, cache.path(), 4, 100).await; |
| 638 | |
| 639 | assert_eq!( |
| 640 | evict_once(&db.write, cache.path(), 1_000) |
| 641 | .await |
| 642 | .expect("evict"), |
| 643 | 0 |
| 644 | ); |
| 645 | let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tiles") |
| 646 | .fetch_one(&db.read) |
| 647 | .await |
| 648 | .expect("count"); |
| 649 | assert_eq!(count, 4); |
| 650 | } |
| 651 | |
| 652 | /// A fresh access must move a tile to the back of the eviction queue, which |
| 653 | /// is the entire reason `last_access` is written on a cache hit. |
| 654 | #[tokio::test] |
| 655 | async fn a_recently_served_tile_outlives_an_older_one() { |
| 656 | let (db, _db_dir) = test_db().await; |
| 657 | let cache = tempfile::tempdir().expect("temp dir"); |
| 658 | seed(&db.write, cache.path(), 4, 100).await; |
| 659 | touch(&db.write, 1, 0, 0, 9_999, None).await.expect("touch"); |
| 660 | |
| 661 | evict_once(&db.write, cache.path(), 200) |
| 662 | .await |
| 663 | .expect("evict"); |
| 664 | |
| 665 | let survivors: Vec<i64> = sqlx::query_scalar("SELECT x FROM tiles ORDER BY x") |
| 666 | .fetch_all(&db.read) |
| 667 | .await |
| 668 | .expect("rows"); |
| 669 | assert!( |
| 670 | survivors.contains(&0), |
| 671 | "tile 0 was just served and must not be the first evicted, got {survivors:?}" |
| 672 | ); |
| 673 | } |
| 674 | |
| 675 | #[tokio::test] |
| 676 | async fn a_refreshed_tile_keeps_one_row_rather_than_accumulating() { |
| 677 | let (db, _db_dir) = test_db().await; |
| 678 | upsert(&db.write, 5, 1, 2, &None, &None, 1, 100, 10) |
| 679 | .await |
| 680 | .expect("insert"); |
| 681 | upsert( |
| 682 | &db.write, |
| 683 | 5, |
| 684 | 1, |
| 685 | 2, |
| 686 | &Some("\"abc\"".into()), |
| 687 | &None, |
| 688 | 2, |
| 689 | 200, |
| 690 | 20, |
| 691 | ) |
| 692 | .await |
| 693 | .expect("update"); |
| 694 | |
| 695 | let rows: Vec<(i64, Option<String>, i64)> = |
| 696 | sqlx::query_as("SELECT bytes, etag, expires_at FROM tiles") |
| 697 | .fetch_all(&db.read) |
| 698 | .await |
| 699 | .expect("rows"); |
| 700 | assert_eq!(rows.len(), 1); |
| 701 | assert_eq!(rows[0].0, 20, "byte accounting must follow the new body"); |
| 702 | assert_eq!(rows[0].1.as_deref(), Some("\"abc\"")); |
| 703 | assert_eq!(rows[0].2, 200); |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | // ponytail: two simultaneous requests for the same missing tile both fetch it. |
| 708 | // A duplicated upstream GET is cheap and the atomic rename keeps the file |
| 709 | // consistent, so this is not worth a single-flight map. If the same viewport |
| 710 | // ever gets opened by enough people at once to matter, key a |
| 711 | // `DashMap<(z, x, y), broadcast::Sender<_>>` on the coordinates and have the |
| 712 | // second caller await the first. |
| 713 |