Pruning tile caches under a flash write budget
The read-only archive covered in offline tile and basemap storage never wears out the flash it lives on, because it is written once at provisioning and read forever. The writable cache beside it — the tiles a device pulls opportunistically when a link is available, for areas the archive does not cover — is the part that can quietly destroy an SD card in a year. This page sizes that cache against an endurance budget and builds a pruning policy that honours it, within the hardware envelope of the Core Edge GIS Fundamentals guide.
Why endurance, not capacity, sets the policy
Flash cells tolerate a finite number of program/erase cycles: roughly 3 000 for consumer TLC SD cards, 10 000 or more for industrial eMMC with SLC-mode regions. A cache that writes and rewrites the same region consumes that budget at a rate the capacity number gives no hint of.
The arithmetic is worth doing once. Total bytes writable over the device’s life is approximately capacity × endurance cycles ÷ write amplification. For a 16 GB card at 3 000 cycles with a write amplification of 3 — a realistic figure for small random writes through a filesystem — that is about 16 TB. A cache that refills 400 MB a day burns 146 GB a year, which sounds trivial against 16 TB until the same card is also carrying the spool, the logs and the journal. It is the aggregate, not any one writer, that ends the card’s life, and the tile cache is usually the largest and the least necessary of them.
That reframes the design goal. The cache does not need to be as large as possible or as fresh as possible; it needs to write as rarely as possible while still covering the areas that matter. Every policy decision below follows from that.
A pruning policy with a write budget
The policy below combines three rules: a hard byte budget per day, a value-ordered eviction that never drops a pinned tile, and a refusal to rewrite a tile that is already present regardless of age. The third rule is the one that does most of the work — most cache churn comes from re-fetching tiles the device already has because a freshness check said they were old.
# tile_cache.py — writable tile cache with an explicit daily write budget.
# Single writer task; readers hit the archive first and this second.
# No fsync per tile: a lost tile after a power cut is a cache miss, not damage.
import os
import sqlite3
import time
DAY_S = 86_400
class BudgetedTileCache:
def __init__(self, path: str, max_bytes: int, daily_write_bytes: int):
self.max_bytes = max_bytes
self.daily = daily_write_bytes
self.db = sqlite3.connect(path, isolation_level=None)
self.db.execute("PRAGMA journal_mode = WAL")
# NORMAL, not FULL: a cache entry is recoverable by re-fetching.
self.db.execute("PRAGMA synchronous = NORMAL")
self.db.execute(
"CREATE TABLE IF NOT EXISTS tiles ("
" z INT, x INT, y INT, blob BLOB NOT NULL,"
" bytes INT NOT NULL, hits INT NOT NULL DEFAULT 0,"
" pinned INT NOT NULL DEFAULT 0, last_used INT NOT NULL,"
" PRIMARY KEY (z, x, y)) WITHOUT ROWID")
self._window_start = int(time.time())
self._written = 0
# --- budget ---------------------------------------------------------
def _roll_window(self):
now = int(time.time())
if now - self._window_start >= DAY_S:
self._window_start = now
self._written = 0
def can_write(self, n_bytes: int) -> bool:
self._roll_window()
return self._written + n_bytes <= self.daily
# --- reads ----------------------------------------------------------
def get(self, z: int, x: int, y: int) -> bytes | None:
row = self.db.execute(
"SELECT blob FROM tiles WHERE z=? AND x=? AND y=?", (z, x, y)
).fetchone()
if row is None:
return None
# A hit costs one small row update; batch it if the read rate is high.
self.db.execute(
"UPDATE tiles SET hits = hits + 1, last_used = ? "
"WHERE z=? AND x=? AND y=?", (int(time.time()), z, x, y))
return row[0]
# --- writes ---------------------------------------------------------
def put(self, z: int, x: int, y: int, blob: bytes, pinned: bool = False) -> bool:
"""Insert unless present. Returns False when the budget refuses it."""
present = self.db.execute(
"SELECT 1 FROM tiles WHERE z=? AND x=? AND y=?", (z, x, y)).fetchone()
if present:
return True # never rewrite what we already hold
if not self.can_write(len(blob)):
return False
self._evict_for(len(blob))
self.db.execute(
"INSERT INTO tiles (z, x, y, blob, bytes, pinned, last_used) "
"VALUES (?,?,?,?,?,?,?)",
(z, x, y, blob, len(blob), int(pinned), int(time.time())))
self._written += len(blob)
return True
def _evict_for(self, need: int):
used = self.db.execute(
"SELECT COALESCE(SUM(bytes), 0) FROM tiles").fetchone()[0]
if used + need <= self.max_bytes:
return
# Value order: unpinned first, then fewest hits, then oldest use.
# One statement, one transaction, one set of pages dirtied.
self.db.execute(
"DELETE FROM tiles WHERE (z, x, y) IN ("
" SELECT z, x, y FROM tiles WHERE pinned = 0"
" ORDER BY hits ASC, last_used ASC"
" LIMIT (SELECT COUNT(*) / 8 + 1 FROM tiles WHERE pinned = 0))")
Two details in that class are the ones that keep the write budget honest. put returns early when the tile is already present, which turns a repeated pan over the same area into zero writes rather than a refresh storm. And eviction removes an eighth of the unpinned population in a single statement rather than one tile at a time — a bulk delete dirties far fewer pages than the same number of individual deletes, and the pages it frees get reused by the next inserts instead of being returned and re-allocated.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| Flash endurance | Unbounded refills consume the card’s cycle budget | Hard daily byte budget; put refuses beyond it and the fetcher backs off |
| Flash capacity | The cache competes with the spool for free space | max_bytes ceiling with bulk eviction well before the filesystem fills |
| RAM | A large cache index would not fit | WITHOUT ROWID table keyed on (z, x, y); SQLite pages the index, nothing resident scales with tile count |
| Power loss | A partial write could corrupt the cache | synchronous = NORMAL plus WAL: a torn tail is rolled back, and a lost tile is a cache miss rather than damage |
| CPU | Eviction scans competing with the pipeline | One bulk statement per overflow, not per insert; runs on the writer task |
Choosing what to pin
Eviction quality matters more than eviction speed, and it comes down to which tiles are marked pinned. Three categories earn it:
Route corridor tiles, for the currently assigned route plus a buffer, follow the banding described in fallback routing and offline navigation. These are the tiles whose absence directly impedes the job.
Site tiles at high zoom for the handful of locations where an operator needs detail. These are expensive to re-fetch (there are many of them per site) and cheap to keep (there are few sites).
The low-zoom fallback layer, zoom 0 to 6 for the whole world, which costs a few megabytes and guarantees the viewport is never empty. Pinning it removes the worst failure mode the map has.
Everything else is disposable by construction. A tile someone panned across once during a shift has no claim on flash that survives to the next shift, and treating it as evictable is what keeps the budget available for the three categories that matter.
Gotchas and edge cases
- A hit update is a write. The
UPDATEingetdirties a page on every cache hit, which on a busy pan is more writes than the inserts. Batch them: accumulate hit counts in memory and flush every few minutes, accepting that a power cut loses the last window’s statistics. VACUUMis a whole-file rewrite. Running it to reclaim space after a large eviction writes the entire cache again — precisely the operation the budget exists to prevent. UsePRAGMA auto_vacuum = INCREMENTALand reclaim a few pages at a time, or simply let the free pages be reused.- The budget window must survive a reboot. An in-memory counter resets on every restart, and a device that reboots hourly then has an unlimited budget. Persist the window start and the running total alongside the cache, in the same transaction as the insert that updates them.
- Do not cache what the archive already has. A tile present in the read-only archive should never be written to the cache. Check the archive first on both the read and the write path; skipping the second check is a common way to double the storage cost of the entire basemap.
- Wear levelling hides the problem until it does not. The controller spreads writes across the card, so nothing degrades visibly until the spare-block pool is exhausted, at which point failures arrive quickly. Track read latency over time — the drift covered in the device constraints guide — as the earliest usable signal.
Integrating with the fetcher
The cache’s budget has to reach the component that decides to fetch, or put simply refuses writes after a fetch has already spent the radio time and the energy to obtain the bytes:
async def fetch_and_store(cache, client, z, x, y):
"""Skip the network entirely once the budget is spent."""
if not cache.can_write(8192): # typical tile size as an estimate
return None # no fetch: saves radio, not just flash
blob = await client.get_tile(z, x, y)
if blob is None:
return None
cache.put(z, x, y, blob)
return blob
That ordering matters on a battery-powered node, where the radio costs more energy than the write. Checking the budget before the fetch turns a storage policy into a power policy at no extra cost, which is the same reasoning applied to sync in power and duty cycling.
Related
- Offline Tile & Basemap Storage — the read-only archive this cache complements.
- Serving vector tiles from a read-only SQLite cache — the reader that checks the archive first and this cache second.
- PMTiles vs MBTiles for read-only edge basemaps — the container the archive ships in.