tiles.rs
⎇
Raw
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
13use std::path::{Path as FsPath, PathBuf};
14use std::sync::OnceLock;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18use anyhow::{Context, Result};
19use axum::Router;
20use axum::extract::{Path, State};
21use axum::http::{StatusCode, header};
22use axum::response::{IntoResponse, Response};
23use axum::routing::get;
24use sqlx::SqlitePool;
25use tokio::io::AsyncWriteExt;
26use tower_sessions::Session;
27use tracing::{debug, warn};
28
29use crate::api::{ApiError, Shared, current_user};
30use 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.
34const 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.
39const 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.
47const 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.
51const BROWSER_CACHE_CONTROL: &str = "public, max-age=86400";
52
53const 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.
60pub 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.
66fn 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)]
80struct 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.
92async 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
188fn 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
192fn tile_path(cache_dir: &FsPath, z: u8, x: u32, y: u32) -> PathBuf {
193 cache_dir.join(format!("tiles/{z}/{x}/{y}.png"))
194}
195
196fn 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
203fn 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
215enum 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.
232async 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.
278async 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
289fn 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.
297fn 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.
310async 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
341async 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
363async 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)]
374async 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.
412const 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.
416const 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.
420fn 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.
428pub 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.
449async 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)]
499mod 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