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.
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
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.
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.
Related jump to heading
- Parent topic: Vector Tile Publishing for Zoning Maps
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Joining zoning attributes to tiles without rebuilding geometry — removes the frequent case from this problem entirely
- Change Detection & Geometry Diffing — supplies the change class and the before geometry
- Building a zoning vector tile pyramid with tippecanoe — the manifest the zoom range is read from