Detecting Tile Pyramid Gaps at High Zoom
A missing tile produces no error. Nothing logs, nothing counts, nothing fails — the build worker simply never wrote the object, and the map shows a blank square to whoever pans over it. That silence is why pyramid gaps are usually reported by users rather than by monitoring, and why they cluster at high zoom, where the tile count is largest and the build queue is most likely to have shed work. This guide covers how to detect gaps by comparing against a computed expectation rather than counting what exists, how to make that check affordable at zoom levels holding millions of tiles, and how to distinguish a genuine gap from a legitimately empty cell. It belongs to raster and tile pipeline observability under geospatial observability architecture fundamentals.
Problem framing: counting is not detecting
The naive check compares today’s tile count against yesterday’s. It fails in the two cases that matter.
It fails when the extent grows. A layer that gained a new region should have more tiles; a count comparison reports growth and passes, while the new region is entirely missing and the old one grew by a rebuild artefact. Only a comparison against the extent-derived expectation notices.
It fails when the gap is old. A pyramid that has been missing four hundred tiles for a month has a stable count, so a day-over-day comparison sees nothing wrong. The gap becomes invisible precisely because it persisted, which is the opposite of what you want.
The expectation-based check has one real cost: at high zoom the expected set is enormous. A modest national layer at zoom 14 covers millions of tiles, and listing an object store to count them on every scrape is neither cheap nor kind to the store. The practical designs all reduce that cost by sampling or by maintaining the count incrementally rather than by measuring it.
Implementation: an affordable expectation check
Three techniques, in increasing order of cost and precision. Most platforms want the first two.
Manifest accounting. The build worker already knows how many tiles it intended to write and how many writes succeeded. Emitting both as counters gives an exact completeness figure for the run at no additional cost, and it catches every gap introduced by that run.
# Emitted by the build worker — exact, free, and blind to pre-existing gaps.
tiles_expected = meter.create_counter("gis.tile.build_expected_total")
tiles_written = meter.create_counter("gis.tile.build_written_total")
tiles_failed = meter.create_counter("gis.tile.build_failed_total")
for zoom in levels:
planned = expected_count(extent, zoom)
tiles_expected.add(planned, {"layer": layer, "zoom": str(zoom)})
for index in plan(extent, zoom):
try:
store.put(render(index))
tiles_written.add(1, {"layer": layer, "zoom": str(zoom)})
except RenderError as exc:
# `reason` is what turns a count into a diagnosis later.
tiles_failed.add(1, {"layer": layer, "zoom": str(zoom),
"reason": exc.kind})
Stratified sampling against the store. Manifest accounting cannot see a tile that was written and later deleted, or a gap that predates your instrumentation. A periodic sampled probe covers that: draw a few hundred tile indices uniformly from the expected set at each level, HEAD each one, and report the hit ratio. Uniform sampling of a few hundred cells detects any gap covering more than roughly one percent of a level with high probability, which is the size range that matters, and it costs a few hundred requests per level per sweep rather than millions.
import random
def sampled_completeness(layer, extent, zoom, n=400):
x0, x1, y0, y1 = tile_range(*extent, zoom)
total = (x1 - x0 + 1) * (y1 - y0 + 1)
n = min(n, total)
hits = 0
for _ in range(n):
x = random.randint(x0, x1)
y = random.randint(y0, y1)
if store.exists(layer, zoom, x, y) or is_legitimately_empty(layer, zoom, x, y):
hits += 1
return hits / n # gauge: gis.tile.level_sampled_complete_ratio
Full enumeration at low zoom only. Levels below roughly zoom 10 have small enough tile counts to enumerate exhaustively, and a gap at low zoom is disproportionately visible to users because it covers a large area. Enumerate cheaply where you can; sample where you cannot.
The is_legitimately_empty predicate is what keeps the ratio honest. A land-cover layer has no tiles over open ocean, and a layer clipped to an administrative boundary has none outside it. Deriving the predicate from the same geometry that produced the extent — a coverage mask, not a hand-maintained exclusion list — is what stops the check from decaying as the layer changes.
Verification: prove the check can see a gap you made
Delete a known block of tiles in a staging pyramid and confirm each mechanism reacts as designed. The sampled probe should show the ratio fall in proportion to the deleted fraction; the manifest counters should be unaffected, since the deletion happened outside a build; the low-zoom enumeration should report the exact missing indices if the block reaches that far up the pyramid.
That contrast is the point of the exercise. It demonstrates concretely that manifest accounting cannot see post-hoc deletion, which is the argument for keeping the sampled probe even though the manifest numbers look authoritative.
Then verify the empty-cell predicate by deleting a block that should be empty — ocean cells in a land layer — and confirming the ratio does not move. A predicate that is too narrow produces a permanently degraded ratio that people learn to ignore.
Gotchas
Sampling uniformly over a heavily clipped extent. If most of the bounding box is legitimately empty, uniform sampling spends nearly all its budget on cells that do not exist and the effective sample size collapses. Sample from the coverage mask rather than from the bounding rectangle.
Reporting completeness as a layer average. A gap confined to one level disappears into an average across levels, exactly as shown in the parent topic. Always report and alert on the worst level.
Counting a zero-byte object as present. A failed write that left a truncated object satisfies an existence check while rendering blank. Where the store exposes object size cheaply, treat implausibly small objects as absent; the size distribution alert in the parent topic catches the systemic version of this.
Letting the expectation drift from the registry. If the extent used by the checker is cached separately from the one used by the builder, a boundary change silently makes the two disagree and completeness reads above or below one for reasons unrelated to any gap. Read both from the same registry entry.
Probing only the deepest level. High zoom holds the most tiles, so it feels like the place to look, but a gap at low zoom covers vastly more ground per missing tile and is far more visible to users. Sweep every instrumented level on the same schedule rather than concentrating the sample where the count is largest.
FAQ
How large a sample do I need per level?
For detecting a gap covering a fraction of a level, a sample of cells misses it with probability . At and that is about 1.8%, which is comfortable for an hourly sweep. Halving the detectable gap size requires doubling the sample, so pushing much below one percent gets expensive quickly — and gaps smaller than one percent of a high-zoom level are rarely user-visible.
Should the probe hit the object store or the serving endpoint?
Both, for different reasons. Probing the store answers “was it built”; probing the serving endpoint answers “can a client get it”, which additionally covers cache and routing faults. Running the same sampled index set against both and comparing is a cheap way to localise a fault to build versus serve.
What about layers where the extent legitimately changes daily?
Recompute the expectation from the registry on every sweep rather than caching it. The cost is one extent lookup; the benefit is that a shrinking extent shows up as an extent change rather than as a mysterious completeness gain.
How does this relate to coverage monitoring for vector layers?
They answer the same question in different media: is the data present everywhere it should be. The vector-side machinery, including the baseline-extent comparison, is covered in spatial coverage and extent monitoring, and the two checks should share the registered extent so a boundary change updates both.
Can I skip this if the builder reports success?
No — that is exactly the manifest-accounting blind spot. A builder reports on what it attempted; it cannot report on a tile that a later lifecycle policy expired, an operator deleted, or a partial multipart upload left truncated. The sampled probe exists precisely to cover the gap between “the build said it worked” and “the tiles are there”.
Related
- Raster and tile pipeline observability — the parent topic defining the pyramid metric set.
- Monitoring cache invalidation lag for map tiles — the sibling guide covering the serve side.
- Tile publish queue overflow runbook — the incident procedure for the backlog that usually causes a contiguous gap.