Seeding Tile Caches by Traffic-Weighted Priority
Order the seed queue by observed request counts rather than by tile coordinates, so that the first minutes of a seed run warm the tiles readers actually ask for — which matters both when the seed is racing live traffic after a purge and when it is interrupted before it finishes.
Core Algorithm and Workflow
Demand for map tiles is extremely uneven. Readers look at cities, coastlines and the areas a particular product is about; large parts of any coverage are requested rarely or never. A seed ordered by tile coordinate spends its first hours on whatever happens to sort first, which correlates with nothing.
Building a demand ranking is four steps:
- Aggregate the logs. Parse access log lines into z/x/y keys and count. Discard non-tile paths, error responses and obvious abuse.
- Decay older observations. Weight yesterday more heavily than last week, so a shift in interest is reflected rather than averaged away.
- Promote low zooms unconditionally. A zoom 6 tile costs almost nothing and is requested in nearly every session. It should be seeded before any high-zoom tile regardless of its individual count.
- Roll up to metatiles. The renderer produces metatiles, so the queue should be ordered by metatile — otherwise the seed asks for four tiles from one render pass at four different times.
Production-Ready Python Implementation
import math
import re
from collections import defaultdict
TILE_PATH = re.compile(r"/tiles/(\d{1,2})/(\d+)/(\d+)\.(?:png|pbf|mvt)")
def aggregate_demand(log_lines, day_of, half_life_days: float = 3.0) -> dict:
"""Decayed request counts per tile key from access logs.
`day_of(line)` returns the age of a line in days. Recent requests are
weighted more heavily so a shift in interest is reflected rather than
averaged out over the whole window.
"""
counts = defaultdict(float)
for line in log_lines:
m = TILE_PATH.search(line)
if not m or " 200 " not in line:
continue
z, x, y = (int(g) for g in m.groups())
weight = 0.5 ** (day_of(line) / half_life_days)
counts[(z, x, y)] += weight
return dict(counts)
def seed_queue(candidate_keys, demand: dict, cheap_zoom_max: int = 10,
metatile: int = 4) -> list:
"""Order tiles for seeding: cheap zooms first, then by decayed demand.
Keys are rolled up to metatile groups so the seed renders at the same
granularity the tile server does.
"""
groups = defaultdict(float)
members = defaultdict(list)
for z, x, y in candidate_keys:
gx, gy = x // metatile, y // metatile
groups[(z, gx, gy)] += demand.get((z, x, y), 0.0)
members[(z, gx, gy)].append((z, x, y))
def rank(group):
z, _, _ = group
tier = 0 if z <= cheap_zoom_max else 1 # cheap zooms unconditionally first
return (tier, z if tier == 0 else 0, -groups[group])
return [members[g] for g in sorted(groups, key=rank)]
Ranking cheap zooms by zoom rather than by demand inside their tier is deliberate: at zoom 6 the whole coverage is a few thousand tiles, they are requested in nearly every session, and rendering them takes minutes. Sorting them against each other by demand optimises something that does not need optimising.
The decay half-life of three days is a starting point rather than a constant. A product with strong weekly seasonality wants a longer half-life so weekend patterns are not treated as noise; a news-driven map service wants a shorter one.
Performance Tuning and Cartographic Best Practices
- Checkpoint the queue position. A seed that is interrupted at eighty per cent should resume, not restart. Writing a cursor every few hundred groups costs nothing and turns an interruption from a disaster into a pause.
- Roll up before ordering, not after. Ordering individual tiles and then grouping them re-sorts the groups arbitrarily and loses the ranking.
- Exclude keys already fresh. Issue conditional requests and skip anything still valid. On an incremental re-seed this routinely removes most of the queue.
- Keep the aggregation cheap. A week of logs for a busy service is tens of millions of lines. Aggregate incrementally as logs arrive rather than reprocessing the window each time a seed is planned.
- Re-rank periodically, not per seed. The demand ranking is a slowly changing artefact. Recompute it daily, store it, and let each seed read the stored ranking — which also makes seed behaviour reproducible for debugging.
Integration and Next Steps
The ranking this produces feeds the concurrency-bounded seeder described in Tile Seeding and Cache Warming, and the same ordering is what makes a staged recovery after a purge tractable — re-seeding the top of the ranking before reopening traffic is far cheaper than letting readers generate the load. Because the ranking is derived from logs, it also answers a question that comes up separately: which parts of the coverage are worth investing render quality in at all.
Frequently Asked Questions
How much log history is needed to rank tiles usefully?
Roughly a week. Long enough to average out a single unusual day, short enough that the ranking still reflects current interest. Ranking is a much easier problem than prediction: even a noisy week cleanly separates tiles receiving thousands of requests from those receiving none, and that separation is the whole of what the ordering needs. Extending the window to a month adds precision to a comparison that was never close.
What about tiles with no history at all?
They rank last and render on demand, which is the right outcome — a tile nobody has ever requested is the definition of one not worth pre-rendering. The genuine exception is newly published coverage, where there is no history because there was no data. Seed those from a geographic rule for the first few days and let observed demand take over once it exists, rather than treating the absence of logs as evidence of absence of interest.
Should the ranking use unique visitors or raw request counts?
Raw counts. The cache serves requests, not people, and a single automated client requesting one tile repeatedly is a perfectly good reason to have that tile warm. Filter out clear abuse before aggregating — a client requesting the entire pyramid sequentially is scraping, not using the map — but do not deduplicate by client, because that optimises for a metric the cache does not experience.
Does the seed order matter if the whole job will finish anyway?
Yes, in two situations that both occur regularly. A seed following a purge is racing live traffic, and every minute during which the top of the ranking is already warm is a minute of load the render pool does not have to absorb. And seeds get interrupted — by a deploy, a quota, an operator with a different priority — so whatever work completed before the interruption should be the work that mattered most. The ordering is free; both situations are common.
Related
- Estimating Tile Seed Time and Storage Before You Run It — sizing the job the ranking will order.
- Recovering a Cold Tile Cache Without Melting the Render Pool — where the ranking matters most.
Back to Tile Seeding and Cache Warming