Invalidating tile caches when a rezone lands

The pipeline is right, the tiles were rebuilt, the purge was issued, and one user still sees last month’s zoning on one parcel — the one whose boundary happens to fall on a tile edge, in the one tile the purge missed. Cache invalidation for a parcel layer is unusually error-prone because a parcel is not in one tile: it is in several per zoom, sometimes in two neighbours at once, and after a boundary change it is in tiles it no longer touches. This guide computes the invalidation set correctly and confirms the purge actually happened, completing the publishing loop in vector tile publishing for zoning maps.

Diagnosis: three ways an invalidation set comes out short jump to heading

The before geometry is ignored. A parcel that shrank, split, or moved still has a rendering in tiles covering its old extent. Invalidating only the new bounds leaves a ghost of the old shape on the map indefinitely, and no future rebuild touches it because the parcel is no longer there.

Three ways an invalidation set comes out short A three-row by three-column matrix of invalidation mistakes. Rows are ignoring the before geometry, missing boundary tiles, and assuming the zoom range instead of reading the manifest. Columns are what gets left stale, whether a future rebuild will ever fix it, and how the mistake is detected. Ignoring the before geometry leaves a ghost that no future rebuild touches, because the parcel no longer occupies those tiles. Three ways an invalidation set comes out short what stays stale will a rebuild fix it? detected by Before geometry ignored a ghost of the old shape no — never audit expected vs purged Boundary tiles missed one seam tile only on a full rebuild a boundary-parcel test Zoom range assumed a whole zoom level no read zooms from the manifest detectable partial persists
The first row is the one that never self-heals: a parcel that moved no longer occupies its old tiles, so no future rebuild will ever refresh them and the ghost persists until the whole pyramid is regenerated.

Boundary tiles are missed. A parcel whose edge falls on a tile boundary is clipped into both neighbours. An integer-truncating tile calculation returns one of them.

The zoom range is assumed rather than read. The pyramid serves z13–16 for parcels and z8–12 for districts; invalidating “z10 to z16” for a parcel change purges four district tiles unnecessarily and — worse — a code change that adds z17 leaves it uninvalidated forever.

def audit_invalidation(change, purged_urls, pyramid_manifest):
    """What SHOULD have been purged against what was. Run this after an incident."""
    expected = set()
    for geom in (change.geometry_before, change.geometry_after):
        if geom is None:
            continue
        expected |= tiles_for_bounds(*geom.bounds,
                                     zooms=pyramid_manifest["layers"]["parcels"])
    expected_urls = {f"/tiles/{t.key()}.pbf" for t in expected}
    return {"expected": len(expected_urls),
            "purged": len(purged_urls),
            "missed": sorted(expected_urls - set(purged_urls)),
            "extra": sorted(set(purged_urls) - expected_urls)}

Step-by-step implementation jump to heading

1. Compute tile coverage inclusively jump to heading

import math
from dataclasses import dataclass


@dataclass(frozen=True)
class Tile:
    z: int
    x: int
    y: int

    def key(self) -> str:
        return f"{self.z}/{self.x}/{self.y}"


def tiles_for_bounds(minx, miny, maxx, maxy, zooms) -> set[Tile]:
    """Web-Mercator coverage for a lon/lat bounding box, INCLUSIVE on both ends.

    The inclusive range is the whole point: a parcel whose edge lands exactly on a
    tile boundary is clipped into both neighbours, and a truncating calculation
    returns only one. That is the stale-seam bug, and it survives every test because
    it needs one parcel, at one zoom, on one edge.
    """
    out = set()
    for z in zooms:
        n = 2 ** z
        x0 = int(math.floor((minx + 180.0) / 360.0 * n))
        x1 = int(math.floor((maxx + 180.0) / 360.0 * n))

        def ytile(lat):
            r = math.radians(max(min(lat, 85.05112878), -85.05112878))
            return int(math.floor((1.0 - math.asinh(math.tan(r)) / math.pi) / 2.0 * n))

        y0, y1 = ytile(maxy), ytile(miny)          # note: y is inverted
        for x in range(max(0, x0), min(n - 1, x1) + 1):
            for y in range(max(0, y0), min(n - 1, y1) + 1):
                out.add(Tile(z, x, y))
    return out
Tiles one parcel occupies, interior against sitting on a seam Grouped bar chart of how many tiles one parcel occupies at each zoom from 13 to 16, comparing a parcel in the interior of a tile against one whose edge falls on a tile boundary. An interior parcel occupies 2 tiles at zoom 13 and 14, 4 at zoom 15 and 9 at zoom 16, totalling 17. A seam parcel occupies 4, 4, 6 and 12, totalling 26 — about half again as many. Tiles one parcel occupies, interior against sitting on a seam 0 5 10 15 2 4 z13 2 4 z14 4 6 z15 9 12 z16 Tiles containing the parcel interior parcel parcel on a tile seam Totals across z13–16: 17 tiles interior, 26 on a seam.
A parcel on a tile seam occupies about half again as many tiles as an interior one, and the extra ones are exactly what a truncating tile calculation misses. Seventeen against twenty-six is the difference between a clean purge and one stale seam nobody can reproduce.

2. Route by change class, and refuse to guess jump to heading

GEOMETRY_CLASSES = {"boundary_adjustment", "split", "merge"}
ATTRIBUTE_CLASSES = {"rezone", "overlay_only", "attribute_only"}


def plan_invalidation(changes, manifest) -> dict:
    parcel_zooms = range(manifest["layers"]["parcels"][0],
                         manifest["layers"]["parcels"][1] + 1)
    state_ids, rebuild = set(), set()

    for ch in changes:
        if ch.change_class in ATTRIBUTE_CLASSES:
            state_ids.add(ch.feature_id)           # a file write, not a tile rebuild
        elif ch.change_class in GEOMETRY_CLASSES:
            for geom in (ch.geometry_before, ch.geometry_after):
                if geom is not None:
                    rebuild |= tiles_for_bounds(*geom.bounds, zooms=parcel_zooms)
            state_ids.add(ch.feature_id)
        else:
            # A new change class must not fall through to "attribute only", which
            # would leave a geometry change uninvalidated and the map wrong.
            raise ValueError(
                f"unclassified change {ch.change_class!r}: refusing to guess whether "
                f"it touches geometry")

    return {
        "state_file_ids": sorted(state_ids),
        "tiles_to_rebuild": sorted(t.key() for t in rebuild),
        "purge_urls": [f"/tiles/{k}.pbf" for k in sorted(t.key() for t in rebuild)],
    }

3. Confirm the purge rather than assuming it jump to heading

import logging

log = logging.getLogger(__name__)


def purge_and_confirm(cdn, urls, *, sample=0.1) -> dict:
    """Purge APIs are eventually consistent and occasionally silent. An unconfirmed
    purge is the mechanism by which a correct pipeline publishes a wrong map."""
    accepted, rejected = cdn.purge(urls)
    if rejected:
        log.error("purge rejected %d of %d URLs", len(rejected), len(urls))

    # Verify a sample by requesting with a cache-revealing header and checking the age.
    import random
    checks = random.sample(accepted, max(1, int(len(accepted) * sample)))
    stale = []
    for url in checks:
        resp = cdn.fetch(url, headers={"Cache-Control": "no-cache"})
        if resp.headers.get("age") and int(resp.headers["age"]) > 60:
            stale.append(url)

    return {"requested": len(urls), "accepted": len(accepted),
            "rejected": rejected, "sampled": len(checks), "still_stale": stale,
            "confirmed": not rejected and not stale}

4. Prefer a versioned path where you can jump to heading

For a geometry rebuild, a versioned pyramid path removes the purge problem entirely: /tiles/parcels-20260811T0230Z/{z}/{x}/{y}.pbf is a URL no cache has ever seen. The cost is a cold cache and a style-source update, which for a monthly geometry rebuild is a much better trade than an invalidation set that has to be exactly right. Keep purging for the state file, whose path must stay stable so clients can fetch it without a new style.

Verification & testing jump to heading

def test_before_and_after_geometry_are_both_invalidated():
    ch = change(cls="boundary_adjustment",
                before=box(-105.10, 40.50, -105.09, 40.51),
                after=box(-105.12, 40.50, -105.11, 40.51))     # moved west
    plan = plan_invalidation([ch], MANIFEST)
    after_only = tiles_for_bounds(*ch.geometry_after.bounds, zooms=range(13, 17))
    assert len(plan["tiles_to_rebuild"]) > len(after_only), \
        "the old extent was not invalidated — a ghost will remain"


def test_a_parcel_on_a_tile_boundary_hits_both_tiles():
    """Place the parcel's edge exactly on a z14 tile boundary."""
    edge_lon = tile_west_edge_lon(z=14, x=3423)
    ch = change(cls="boundary_adjustment",
                before=None,
                after=box(edge_lon - 0.0002, 40.50, edge_lon + 0.0002, 40.501))
    tiles = {t for t in tiles_for_bounds(*ch.geometry_after.bounds, zooms=[14])}
    assert len({t.x for t in tiles}) == 2, "the seam tile was missed"


def test_a_rezone_rebuilds_no_tiles():
    plan = plan_invalidation([change(cls="rezone")], MANIFEST)
    assert plan["tiles_to_rebuild"] == []
    assert plan["state_file_ids"]


def test_an_unknown_change_class_raises():
    with pytest.raises(ValueError, match="refusing to guess"):
        plan_invalidation([change(cls="something_new")], MANIFEST)


def test_zoom_range_comes_from_the_manifest():
    manifest = dict(MANIFEST, layers={"parcels": [13, 17]})     # a new zoom added
    plan = plan_invalidation([change(cls="split")], manifest)
    assert any(k.startswith("17/") for k in plan["tiles_to_rebuild"])


def test_unconfirmed_purge_is_reported_not_swallowed():
    result = purge_and_confirm(FlakyCDN(reject=["/tiles/14/3423/6192.pbf"]), URLS)
    assert result["confirmed"] is False and result["rejected"]

Failure recovery jump to heading

One parcel showing old zoning. Almost always a seam tile. Recompute the invalidation set with the inclusive range, diff it against what was purged, and purge the difference. Then add the boundary test, because this recurs whenever the tile-coordinate maths is touched.

Four invalidation incidents and what actually fixes them A four-row by three-column matrix of invalidation incidents. Rows are one parcel showing old zoning, a ghost of an old parcel shape, a purge that reported success but did not take, and a new change class treated as attribute-only. Columns are the usual cause, the immediate fix, and the durable fix. The durable fix for two of the four is to stop relying on purging and move the pyramid to a versioned path. Four invalidation incidents and what actually fixes them usual cause immediate fix durable fix One parcel showing old zoning a missed seam tile purge the difference inclusive ranges + a test A ghost of an old shape before geometry ignored purge the old extent store geometry_before Purge reported success, did not take eventual consistency purge again, confirm versioned tile path New change class treated as attribute-only silent fall-through re-plan those changes raise on unknown classes clean partial the cause
Two of the four have the same durable fix: stop depending on a purge API being consistent and honest, and put the version in the path. Correctness then does not rely on a third party confirming anything.

A ghost of an old parcel shape. The before geometry was not invalidated, and no future rebuild will fix it because the parcel no longer occupies those tiles. Purge the old extent explicitly using the change record’s geometry_before — which is the reason that field is worth storing.

A purge that reported success but did not take. Sampled confirmation exists for this. If it recurs, stop relying on purging for geometry: move the pyramid to a versioned path, where correctness does not depend on a third party’s eventual consistency.

A new change class silently treated as attribute-only. The ValueError prevents this going forward. For changes already published under the old code, identify them by class and re-plan the invalidation for the geometry ones.

Frequently asked questions jump to heading

Why invalidate tiles covering the old geometry?

Because a cached tile still contains the parcel’s previous rendering. If the parcel moved, shrank or was split, it no longer occupies those tiles, so no future rebuild ever touches them — the stale shape persists until the whole pyramid is regenerated. Invalidating both extents costs a handful of extra tiles and removes the class entirely.

How many tiles does one changed parcel actually touch?

Around twenty across a five-zoom parcel range, more if it sits on tile boundaries, since tile size shrinks faster than the parcel does as zoom increases. That is trivial for one rezone and is precisely why a county-wide re-code becomes a full pyramid rebuild rather than a targeted purge.

Is a versioned tile path better than purging?

For geometry rebuilds, usually yes: a new path is in no cache, so correctness stops depending on a purge API being consistent and honest. The costs are a cold cache and a style update pointing at the new source. The state file still needs a stable path and a short max-age, because clients must be able to pick up attribute changes without a new style.

Should the planner ever guess about an unknown change class?

No. Defaulting an unrecognised class to attribute-only leaves a geometry change uninvalidated, which produces exactly the wrong map this design exists to prevent — and it does so silently, months after somebody added the class. Raising forces a one-line decision at the time the class is introduced.