Recovering a Cold Tile Cache Without Melting the Render Pool
A fully cold cache turns every request into a render, and a pool sized for a one per cent miss rate cannot absorb a hundred per cent one — so the recovery is about keeping the instantaneous miss rate inside what the renderer can drain, not about rendering faster.
Core Diagnostic and Recovery Workflow
The failure mode is a positive feedback loop, not a slowdown. Requests arrive faster than they can be served, the queue grows, latency rises past the client timeout, clients retry, and the retries add load to a queue that is already unbounded. Nothing in that loop self-corrects.
Four levers, in the order they should be reached for:
- Do not create it. Purge in stages by region so only part of the key space is cold at any moment. This is much cheaper than any recovery and it is available every time an invalidation is planned rather than forced.
- Coalesce duplicate misses. Concurrent requests for the same absent tile should trigger one render. On a popular tile this converts hundreds of renders into one.
- Throttle or shed at the edge. A request that will time out anyway should not occupy a worker. Serving a stale tile is better still where the cache can.
- Re-seed the top of the demand curve before restoring full traffic, using the ranking described in Seeding Tile Caches by Traffic-Weighted Priority.
Production-Ready Python Implementation
import threading
class CoalescingRenderer:
"""Ensure concurrent misses for the same tile trigger exactly one render."""
def __init__(self, render_fn):
self._render = render_fn
self._lock = threading.Lock()
self._inflight: dict = {}
def get(self, key):
with self._lock:
event = self._inflight.get(key)
if event is None:
event = self._inflight[key] = {"done": threading.Event(),
"value": None, "error": None}
leader = True
else:
leader = False
if not leader:
event["done"].wait()
if event["error"]:
raise event["error"]
return event["value"]
try:
event["value"] = self._render(key)
except Exception as exc: # noqa: BLE001 — re-raised below
event["error"] = exc
finally:
event["done"].set()
with self._lock:
self._inflight.pop(key, None)
if event["error"]:
raise event["error"]
return event["value"]
def staged_purge_plan(regions, tiles_per_region: dict,
drain_rate_per_s: float, headroom: float = 0.5) -> list:
"""Order regions and give each a re-warm window the pool can actually absorb."""
if not 0 < headroom <= 1:
raise ValueError("headroom must be a fraction of drain capacity")
usable = drain_rate_per_s * headroom
plan = []
for region in regions:
n = tiles_per_region[region]
plan.append({"region": region, "tiles": n,
"wait_seconds": round(n / usable, 1)})
return plan
The coalescing renderer’s value is entirely in the duplicate case, and the duplicate case is what a cold cache produces most of. When a popular tile is missing, every viewer looking at that area requests it within the same second; without coalescing each request renders it independently.
staged_purge_plan deliberately leaves headroom. Purging a region and immediately purging the next, on the assumption that the pool will keep up, is how a staged purge becomes a full one.
Header-Inspection and Verification Best Practices
- Alert on queue depth, not latency. The queue crosses the danger line minutes before response times look abnormal, and by then the recovery is longer.
- Serve stale on miss where the data allows. A tile from before the purge is a far better answer than a timeout, and for most map data a few minutes of staleness is invisible.
- Cap the retry storm. Client timeouts produce retries that add load to an overloaded queue. Return a fast error above a queue-depth threshold so retries arrive against a shedding server rather than a stalled one.
- Verify the purge actually propagated. A staged purge whose second stage begins before the first has taken effect at every edge is a full purge with extra steps — the verification loop described in Debugging Stale Tiles in CDN-Backed Map Servers applies here too.
- Rehearse it. A staged purge that has never been run will be run for the first time during an incident. Exercise it on a quiet region during a quiet hour.
Integration and Next Steps
Everything here is downstream of an invalidation decision, so the cheapest improvement is upstream: versioned tile URLs, as described in Tile Cache and Invalidation, remove purging from the vocabulary entirely — a style change publishes a new key space and the old one ages out. Where purging is unavoidable, the seed estimate from Estimating Tile Seed Time and Storage Before You Run It gives the per-stage window, and the demand ranking decides which region to purge when.
Frequently Asked Questions
What actually breaks when a cache goes cold?
The render queue. A pool sized for a one per cent miss rate suddenly faces a hundredfold increase in work, so the queue grows faster than it drains and every request waits behind an unbounded backlog. Response times do not degrade gracefully — they degrade until clients time out, and the timeouts produce retries that add further load. It is a positive feedback loop rather than a slowdown, which is why the mitigations are all about limiting inbound work rather than speeding up rendering.
Why does request coalescing help so much?
Because a cold cache produces enormous duplication. When a popular tile is missing, every viewer looking at that area requests it within the same second, and without coalescing each of those requests renders the same tile independently. With coalescing the first request renders and the rest wait on its result. On a busy tile at a bad moment this is the difference between one render and several hundred, and the implementation is a lock and a dictionary of in-flight keys.
Is it better to shed requests or to queue them?
Shed, once the queue passes a threshold. A queued request that will time out anyway has occupied a worker slot for nothing and produced no value, while a request rejected quickly lets the client retry when capacity exists. Better still, serve a stale tile where the cache retains one: for most map data a few minutes of staleness is imperceptible, and it is unambiguously a better answer than a timeout.
How long should a staged purge take?
Long enough that each stage’s miss rate stays inside the pool’s drain capacity — which the seed estimate already quantifies. If a region holds 200 000 tiles and the pool renders 40 per second within the headroom you are willing to give live traffic, that region needs roughly 80 minutes before the next stage begins. Purging faster than the pool can refill simply reconstructs the full-purge scenario one region at a time, with none of the benefit and all of the load.
Related
- Estimating Tile Seed Time and Storage Before You Run It — the figures that set each stage’s window.
- Tile Cache and Invalidation — the versioning strategy that avoids the situation entirely.
Back to Tile Seeding and Cache Warming