Defining Freshness SLOs for Tile Layers

A freshness objective is two numbers and a weighting: how old a tile may be, and what share of the weighted serving area must comply. Derive the age from how fast the underlying phenomenon changes — hours for a flood extent, weeks for a road network, months for orthophotos — and set the share from a month of measurement rather than from aspiration. Then alert on how fast the error budget is burning, so the first notification arrives while there is still budget left rather than after the objective has already been missed.

When to Use This Pattern

  • A tile layer is published to consumers who make decisions based on how current it is.
  • Sources update at different rates, so one number cannot serve every layer.
  • Staleness has gone unnoticed before — the failure this objective exists to make visible.
  • A ledger exists with a build time per tile, since the SLI is computed from it.

Complete Working Example

The objective is data; the SLI is a query; the burn rate is a recording rule. Nothing here needs new instrumentation.

from __future__ import annotations

from dataclasses import dataclass

import psycopg
from prometheus_client import Gauge

FRESHNESS_SLI = Gauge("tile_freshness_sli", "Weighted share within the age target",
                      ("layer",))
FRESHNESS_TARGET = Gauge("tile_freshness_target", "The objective, for the dashboard",
                         ("layer",))


@dataclass(frozen=True)
class FreshnessObjective:
    layer: str
    max_age_hours: float      # from the phenomenon, not from the pipeline
    target: float             # weighted share that must comply
    window_days: int = 30

    @property
    def budget_hours(self) -> float:
        """How much non-compliance the window permits, as a wall-clock figure."""
        return (1.0 - self.target) * self.window_days * 24


OBJECTIVES = [
    # A flood extent is useless at four hours old; imagery is fine at a month.
    FreshnessObjective("flood_extent", max_age_hours=2, target=0.99),
    FreshnessObjective("traffic", max_age_hours=0.25, target=0.98),
    FreshnessObjective("roads", max_age_hours=24 * 7, target=0.99),
    FreshnessObjective("ortho", max_age_hours=24 * 30, target=0.95),
]


SLI_SQL = """
SELECT
  sum(weight) FILTER (
      WHERE built_at > now() - (%(max_age_hours)s * interval '1 hour')
  ) / NULLIF(sum(weight), 0)                       AS sli,
  -- Reported alongside, because it is what a human wants to see next.
  max(EXTRACT(EPOCH FROM now() - built_at)) / 3600 AS oldest_hours
FROM   tile_ledger
WHERE  layer = %(layer)s AND in_serving_area
"""


def publish_freshness(conn: psycopg.Connection) -> None:
    for obj in OBJECTIVES:
        with conn.cursor() as cur:
            cur.execute(SLI_SQL, {"layer": obj.layer, "max_age_hours": obj.max_age_hours})
            sli, oldest = cur.fetchone()
        FRESHNESS_SLI.labels(layer=obj.layer).set(float(sli or 0.0))
        FRESHNESS_TARGET.labels(layer=obj.layer).set(obj.target)

The alerting is two burn-rate rules per layer — one fast, one slow — which is what turns the objective into a warning rather than a post-mortem:

groups:
  - name: freshness-slo
    rules:
      # The SLI is computed once and reused, so both rules agree by construction.
      - record: slo:freshness_error_ratio
        expr: 1 - tile_freshness_sli

      # Fast burn: at this rate the month's budget is gone in about two days.
      - alert: FreshnessBudgetBurningFast
        expr: |
          slo:freshness_error_ratio
            > 14.4 * (1 - tile_freshness_target)
        for: 15m
        labels: { severity: page }
        annotations:
          summary: "{{ $labels.layer }} freshness budget burning at 14x — 2 days to exhaustion"

      # Slow burn: not urgent, and this is how a month's budget disappears unnoticed.
      - alert: FreshnessBudgetEroding
        expr: |
          slo:freshness_error_ratio
            > 3 * (1 - tile_freshness_target)
        for: 6h
        labels: { severity: ticket }
        annotations:
          summary: "{{ $labels.layer }} freshness budget eroding at 3x for six hours"
The target follows the phenomenonTraffic changes in minutes and carries a fifteen-minute target. Flood extent changes hourly with a two-hour target. Roads change over months with a seven-day target. Orthophotos change over years with a thirty-day target.layerhow fast it changesmax age · targettrafficminutes15 min · 98%flood_extenthours2 h · 99%roadsmonths7 d · 99%orthoyears30 d · 95%Read the middle column and the right one follows almost mechanically. That is the sign the target is derived rather than chosen.
A single platform-wide freshness target would be four hundred times too loose for traffic and four hundred times too tight for orthophotos.

Expressing the budget in hours rather than as a percentage is a small change that does a lot of work in conversation. “Ninety-five per cent freshness” invites a debate about whether ninety-four is really so bad; “thirty-six hours of permitted staleness this month, of which we have used twenty-two” is a quantity people reason about the way they reason about money. It also makes the trade-off explicit when someone proposes a risky change: the question becomes whether the change is worth some of the fourteen hours remaining, which is a decision rather than an argument.

Percentage or hoursExpressed as ninety-five per cent, the objective invites a debate about the threshold. Expressed as thirty-six hours with twenty-two spent, it invites a decision about the remainder.AS A PERCENTAGE“freshness is 94.6% against a 95% target”→ “is 94.6 really a problem?” — a debate with no natural endAS A BUDGET22 h spent14 h remaining→ “is this migration worth four of the fourteen?” — a decision somebody can make
The two rows describe the same measurement. Only the second one produces an answer within the meeting it is raised in.

Parameter & Option Reference

Parameter Example Spatial notes
max_age_hours 2 to 720 From the phenomenon’s rate of change. The single most important number and the only one nobody can compute for you.
target 0.95–0.99 From a month of measurement, set slightly below what the pipeline achieves today.
window_days 30 The same across objectives, so budgets are comparable.
weight population Stated in the published objective. Unweighted area flatters any large pyramid.
in_serving_area boolean column The promise covers a defined region, versioned, not the whole grid.
fast burn 14.4× Exhausts a 30-day budget in about two days. Pages.
slow burn Exhausts it in ten days. Tickets, and this is the one that catches erosion.

Verification & Testing

Test the arithmetic and the boundary, because both are easy to get subtly wrong and neither is visible once deployed.

def test_budget_hours_arithmetic() -> None:
    obj = FreshnessObjective("ortho", max_age_hours=720, target=0.95, window_days=30)
    # 5% of 720 hours in the window = 36 hours of permitted non-compliance.
    assert obj.budget_hours == pytest.approx(36.0)


def test_sli_is_weighted_not_counted(conn, seeded_ledger) -> None:
    """One stale city must cost more than a thousand stale ocean tiles."""
    make_stale(conn, layer="ortho", region="oslo")
    weighted = compute_sli(conn, OBJECTIVES[3])

    reset(conn)
    make_stale(conn, layer="ortho", region="north_sea", tiles=1000)
    ocean = compute_sli(conn, OBJECTIVES[3])

    assert weighted < ocean, "the weighting is not being applied"


def test_boundary_is_inclusive_and_consistent(conn) -> None:
    """A tile exactly at the age limit must be treated the same way every run."""
    insert_tile(conn, layer="ortho", age_hours=720.0)
    first = compute_sli(conn, OBJECTIVES[3])
    second = compute_sli(conn, OBJECTIVES[3])
    assert first == second


def test_empty_serving_area_does_not_divide_by_zero(conn) -> None:
    assert compute_sli(conn, FreshnessObjective("empty_layer", 24, 0.99)) == 0.0

The reporting query is worth having as a saved view, because “how are we doing against the objective” is asked more often than any alert fires — usually by someone outside the team, and usually about one layer:

SELECT l.layer,
       round(100 * l.sli::numeric, 2)            AS sli_pct,
       round(100 * o.target::numeric, 2)         AS target_pct,
       round(l.oldest_hours::numeric, 1)         AS oldest_hours,
       round((1 - (1 - l.sli) / (1 - o.target))::numeric, 3) AS budget_remaining
FROM   freshness_sli l
JOIN   freshness_objective o USING (layer)
ORDER  BY budget_remaining;
Two burn rates, two different failuresA fast burn consumes the budget in two days and pages. A slow burn consumes it over ten days without any single moment looking alarming, and only the slow rule detects it.budgetday 0day 15day 3014× — pages within 15 minutes3× — a ticket, and nothing looks urgentOnly the fast rule would fire on the red line; only the slow rule would ever fire on the amber one.
The amber line is the one that costs a team its objective without a single alarming moment, which is why the slow rule is not optional.

Common Pitfalls

  • One target for every layer. A number that suits orthophotos is meaningless for a flood extent. The target belongs to the phenomenon, and layers with similar dynamics can share one.
  • Measuring average age. An average hides a stale region inside a healthy mean. The SLI is a share of compliant area, not a central tendency.
  • Unweighted area. For a national pyramid, most tiles are uninhabited. Without weighting the objective can be met while every city is a month behind.
  • Alerting on the SLI crossing the target. By then the objective is already missed. Burn rate gives warning while there is still budget, which is the whole reason to express it as a budget.
  • Measuring built_at when consumers care about capture time. For imagery those differ by weeks. If the promise is about the world rather than about the pipeline, carry the acquisition time through and measure that.
  • Never revisiting the target. A target set when the pipeline was slower may now be trivially met, which means the objective has stopped providing signal. Review it when the window’s shape changes, not on a fixed schedule.

Frequently Asked Questions

Where does the max-age number come from?

From how quickly the thing being mapped changes, and from what a wrong answer costs. Traffic changes minute to minute and a stale answer sends someone down a closed road. A road network changes over months and a week-old copy is indistinguishable from current for almost every use. Asking “what decision does this support, and how fast would that decision change” produces the number more reliably than any benchmark.

Should the objective count tiles that have never been built?

Yes — they are infinitely stale, and excluding them lets a pipeline meet its freshness objective on a layer that is half missing. That does mean freshness and coverage interact, which is a reason some teams combine them into one “usable” ratio. Keeping them separate and letting missing tiles count against freshness is the simpler arrangement and it fails safely.

What if a source legitimately publishes less often than the target?

Then the target is wrong, or the layer needs a different promise. An objective that cannot be met by a correctly-functioning pipeline is a broken objective, and it will be silenced within a month. Where a source publishes quarterly, the freshness promise is about quarters, however much anyone would prefer otherwise.

How does this interact with the tile cache's staleness budget?

They are the same idea at different layers, and the numbers should agree. A cache that serves tiles up to 48 hours old while the SLO promises 24 is quietly breaching the objective every time it does so. Deriving the cache’s budget from the SLO — rather than choosing both independently — keeps them consistent; see falling back to cached tiles when a breaker opens.

Can the objective vary by region?

It can, and it is usually not worth the complexity. A single target with a sensible weighting expresses “we care more about cities” adequately, and per-region targets multiply the objectives without changing many decisions. The exception is a genuinely different product in one region — a high-frequency layer over a single city — which is better modelled as its own layer with its own objective.

Data-Quality SLOs for Spatial Pipelines