Purging Tile Caches After a Partition Republish
Purging a whole layer to correct one region evicts the warm working set, converts a small republish into a renderer traffic spike, and takes minutes to recover from. Purging only the affected tiles takes seconds and costs nothing — provided the purge reaches every cache layer, which is the step most often skipped and never noticed until a consumer reports data that changed everywhere except at one edge. This guide computes the exact tile set a republished partition covers, purges it across every layer, and verifies the purge rather than assuming it. It is the invalidation half of Edge Caching and Tile Delivery Topology within Federated Ownership & Routing Architecture, and it is unnecessary for layers using the immutable addressing described there.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | python3 ≥ 3.11, curl, edge CLI with a purge API |
Purge must be scriptable |
| Addressing | Mutable tile addresses | Immutable addressing needs no purge |
| Partition | The republished partition’s bounding box in EPSG:4326 |
From the publishing DAG run |
| Matrix set | WebMercatorQuad, zoom range from the product manifest |
Tile maths is grid-specific |
| Cache layers | Origin cache, shared cache, and every edge PoP enumerated | An unenumerated layer is an unpurged layer |
| Environment | PRODUCT, PARTITION_BBOX, ZMIN, ZMAX |
Exported before running |
Step-by-Step Implementation
1. Enumerate the tiles the partition actually covers
Purging by prefix is tempting and wrong: a prefix covers a whole zoom level, and the partition covers a fraction of it. The tile maths is small and exact.
# tile_range.py — the tiles a bbox covers at each zoom, in WebMercatorQuad.
import math
import sys
def lonlat_to_tile(lon: float, lat: float, z: int) -> tuple[int, int]:
"""WebMercatorQuad tile containing a coordinate. Latitude is clamped to the
projection's valid range: EPSG:3857 is undefined beyond about ±85.05°."""
lat = max(-85.05112878, min(85.05112878, lat))
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
return max(0, min(n - 1, x)), max(0, min(n - 1, y))
def tiles_for_bbox(bbox: tuple[float, float, float, float], zmin: int, zmax: int):
"""Every tile intersecting the bbox, zmin..zmax inclusive. Note the y inversion:
tile y increases southward while latitude increases northward."""
minx, miny, maxx, maxy = bbox
for z in range(zmin, zmax + 1):
x0, y1 = lonlat_to_tile(minx, miny, z)
x1, y0 = lonlat_to_tile(maxx, maxy, z)
for x in range(min(x0, x1), max(x0, x1) + 1):
for y in range(min(y0, y1), max(y0, y1) + 1):
yield z, x, y
if __name__ == "__main__":
bbox = tuple(float(v) for v in sys.argv[1].split(","))
for z, x, y in tiles_for_bbox(bbox, int(sys.argv[2]), int(sys.argv[3])):
print(f"{z}/{x}/{y}")
Verify the count is plausible before purging anything. An unexpectedly large count usually means the bbox is wrong, and purging on it would evict most of the layer:
python3 tile_range.py "$PARTITION_BBOX" "$ZMIN" "$ZMAX" | wc -l
# A city-scale partition at z6–z14 is typically thousands. Millions means a bad bbox.
2. Purge every layer, not merely the edge
Each layer has its own purge API and its own idea of what it holds. A purge that reaches the edge and misses the shared cache leaves the edge to re-fetch the stale object it just discarded.
#!/usr/bin/env bash
# purge_partition.sh — purge one partition's tiles across every cache layer.
# Idempotent: purging an absent object is a no-op, so re-running is always safe.
set -euo pipefail
PRODUCT="${1:?product}"; BBOX="${2:?bbox}"; ZMIN="${3:?zmin}"; ZMAX="${4:?zmax}"
# Order matters: purge from the origin outward, so an edge that re-fetches during
# the purge fetches the new object rather than re-caching the old one.
LAYERS=(origin-cache.internal shared-cache.internal edge.internal)
python3 tile_range.py "$BBOX" "$ZMIN" "$ZMAX" > /tmp/tiles.txt
total=$(wc -l < /tmp/tiles.txt)
echo "purging ${total} tile(s) across ${#LAYERS[@]} layer(s)"
for layer in "${LAYERS[@]}"; do
# Batch rather than one request per tile; a per-tile purge of 50k tiles is an outage.
split -l 500 /tmp/tiles.txt /tmp/batch_
for batch in /tmp/batch_*; do
jq -Rsc --arg p "$PRODUCT" \
'{product: $p, tiles: (split("\n") | map(select(length > 0)))}' < "$batch" \
| curl -sS -X POST "https://${layer}/purge" \
-H 'content-type: application/json' -d @- > /dev/null
done
rm -f /tmp/batch_*
echo " purged: ${layer}"
done
Verify the purge landed at every layer, by sampling rather than trusting the API’s response:
sample=$(shuf -n 5 /tmp/tiles.txt)
for layer in origin-cache.internal shared-cache.internal edge.internal; do
for t in $sample; do
printf '%-24s %-12s ' "$layer" "$t"
curl -sS -o /dev/null -D - "https://${layer}/${PRODUCT}/${t}.mvt" \
| grep -iE '^(x-cache|x-spatial-version):' | tr '\n' ' '; echo
done
done
# Every layer must report a MISS and the new version. A HIT with the old version
# at any layer is an unpurged layer, and it will keep serving stale until expiry.
3. Watch the hit ratio recover
A purge is not free, and the recovery curve tells you whether the scope was right.
# Hit ratio before, during and after. A scoped purge dips a few points and recovers
# within minutes; a layer-wide purge collapses the ratio and takes the renderer with it.
for i in $(seq 1 12); do
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=sum(rate(tile_cache_hits_total{product="'"$PRODUCT"'"}[2m]))
/ sum(rate(tile_requests_total{product="'"$PRODUCT"'"}[2m]))' \
| jq -r '"\(now|floor) \(.data.result[0].value[1])"'
sleep 60
done
4. Wire the purge into the publish, not into an operator’s hands
A purge that depends on someone remembering to run it will be forgotten exactly once, and that once will be the republish that mattered.
# publish_hook.py — the purge is a step of the publish, with the bbox from the run.
def on_partition_published(run) -> None:
"""Called by the orchestrator after an atomic publish. The bbox comes from the
partition definition, so the purge scope can never drift from what changed."""
if run.manifest["spec"].get("addressing") == "immutable":
return # nothing to purge; addresses are new
bbox = run.partition.bbox_4326
zoom = next(p["zoom"] for p in run.manifest["spec"]["ports"] if p["type"] == "tiles")
run.exec([
"./purge_partition.sh", run.product,
",".join(str(v) for v in bbox), str(zoom["min"]), str(zoom["max"]),
])
Configuration Reference
| Parameter | Scope | Value | Effect |
|---|---|---|---|
| Purge scope | Publish hook | The republished partition’s bbox | Layer-wide purge evicts the warm set |
| Zoom range | Manifest | ports[].zoom |
Purging beyond the published range wastes calls |
| Batch size | Purge script | 500 tiles |
Per-tile purge of a large range is itself an outage |
| Layer order | Purge script | Origin → shared → edge | Reverse order lets an edge re-cache the old object |
| Verification sample | Verify step | 5 tiles per layer |
Trusting the purge API’s response is how layers get missed |
| Latitude clamp | Tile maths | ±85.05112878 |
EPSG:3857 is undefined beyond it |
Common Failure Modes & Fixes
Most consumers see new data; some see old. Root cause: one cache layer — usually a regional edge PoP added after the purge script was written — is not in the layer list. Fix: derive the layer list from the edge provider’s PoP inventory rather than hard-coding it, and verify by sampling every layer.
Hit ratio collapses after every republish. Root cause: purging by prefix or by layer rather than by partition. Fix: the tile enumeration above; the difference is typically two orders of magnitude in evicted objects.
Purge takes twenty minutes. Root cause: one HTTP request per tile. Fix: batch, as above. Most purge APIs accept hundreds of paths per call, and the difference is minutes to seconds.
Tiles at the highest zoom are still stale. Root cause: the purge zoom range was hard-coded and the product’s published range was extended. Fix: read the zoom range from the manifest, so the two cannot diverge.
Purge succeeds and the old data returns minutes later. Root cause: the layers were purged edge-first, so an edge re-fetched from a shared cache that still held the old object. Fix: purge origin-outward.
FAQ
Why not just shorten the cache lifetime instead of purging?
Because the two solve different problems and the cheap-looking option is expensive. Shortening s-maxage bounds staleness for every tile all the time, which means paying continuous origin load to handle an event that happens a few times a day. Purging pays nothing until a republish and then pays exactly for what changed. The right configuration usually uses both: a lifetime long enough that ordinary traffic is nearly all cache hits, plus a scoped purge on republish to close the gap immediately rather than waiting for expiry.
How do I purge when the partition’s bbox is not a rectangle?
Purge the bounding rectangle of the partition, accepting that it covers some tiles that did not change. The alternative — enumerating tiles that intersect an arbitrary polygon — is more code, is slower to compute, and saves relatively little because tile grids are coarse compared with most partition shapes. The over-purge is bounded by the difference between the partition’s area and its envelope, and for typical administrative or grid-aligned partitions that is small. Where a partition is genuinely long and thin, splitting it into several rectangles is a better trade than exact polygon enumeration.
Should the purge block the publish from completing?
No — publish atomically first, then purge. The publish is the operation that must be transactional; the purge is a cache correction that can be retried freely because it is idempotent. Blocking publication on a purge means a slow or failing edge API can prevent a validated artifact from becoming available, which is the wrong dependency direction. Run the purge as a post-publish hook, retry it on failure, and alert if it fails repeatedly — consumers will get the new data at expiry regardless, just later than intended.
How can I tell whether a purge is even needed?
By the layer’s addressing. If tile addresses carry the product version, a republish creates entirely new addresses and nothing cached is stale — the old version’s tiles simply age out unused. That is why the publish hook above returns immediately for immutable layers. For mutable layers a purge is always needed, and the fact that it is easy to forget is the strongest argument for moving a layer to immutable addressing whenever its consumers can resolve a version.
Related
- Edge Caching and Tile Delivery Topology — the parent topic, including immutable addressing
- Configuring Stale-While-Revalidate for Tile CDNs — bounding staleness between purges
- Choosing Cache Keys for Vector Tile Endpoints — what a purge has to address