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.

Annual flash writes by source against a card's lifetime budget A stacked comparison of annual writes on a 16 gigabyte consumer card with a lifetime budget of about 16 terabytes. Telemetry spool writes 6 gigabytes a year, logs write 11, the filesystem journal writes 14, and an aggressive tile cache that refills 400 megabytes a day writes 146. Together they consume 177 gigabytes a year, giving a card life of about ninety years on paper — but a cache that refills continuously during a mapping campaign can reach 4 gigabytes a day, which alone consumes 1.5 terabytes a year and cuts the same card to roughly ten. Annual writes per source — one of these is not like the others telemetry spoollogsfs journaltile cache · 400 MB/daytile cache · campaign mode 6 GB11 GB14 GB146 GBcampaign mode: 1.5 TB/yr — about ten years of card life Budget: 16 GB × 3 000 cycles ÷ 3× amplification ≈ 16 TB.
Three of these writers are bounded by what the device observes. The fourth is bounded only by how much map someone panned across, which is why it needs an explicit budget.

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.

Eviction order across the cache population The cache population sorted by eviction priority. Pinned route, site and fallback tiles occupy the protected end and are never evicted. Then unpinned tiles with zero hits since insertion go first, followed by tiles with one or two hits ordered by age, and finally frequently used unpinned tiles which survive longest. An arrow shows eviction sweeping from the left, removing an eighth of the unpinned population at a time. Evicted first on the left, protected on the right 0 hits since insert panned across once, never revisited 1–2 hits, oldest first incidental coverage frequently used unpinned but earning it pinned — never evicted route · sites · z0–z6 fallback one bulk DELETE removes an eighth of the unpinned population Deleting in bulk dirties a fraction of the pages that the same number of single-row deletes would, which is itself a write-budget decision. Hit counts are the only signal here that reflects what the operator actually looked at, which is why they outrank recency.
Recency alone evicts the tile under the vehicle in favour of one the operator glanced at a minute ago. Hit count first, age second is the ordering that matches how a field map is used.

Gotchas and edge cases

  • A hit update is a write. The UPDATE in get dirties 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.
  • VACUUM is 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. Use PRAGMA auto_vacuum = INCREMENTAL and 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.
Pages dirtied per hour by hit accounting, immediate versus batched Two write profiles for the same 4 200 cache hits in an hour. Updating the hit counter on every read dirties about 3 100 pages, roughly 12 megabytes of writes, because each update touches a different row. Accumulating counts in memory and flushing once every five minutes dirties 84 pages, about 340 kilobytes, since repeated hits on the same tile collapse into one update. The cost of batching is losing at most five minutes of statistics on a power cut. The reads were the biggest writer — 4 200 hits in one hour update per hit ≈12 MB 3 100 pages dirtied flush every 5 min 84 pages · ≈340 KB Repeated hits on the same tile collapse into a single update, and a pan revisits the same tiles constantly — that is where the 37× comes from. Cost of the trade: a power cut loses up to five minutes of hit statistics, which affects eviction quality and nothing else.
Read accounting is the one part of a cache that can write more than the caching does. Batch it, and accept that the statistics are approximate.

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.