Automating Monthly OSM PBF Refresh for Routing Engines

This page solves one exact task: running the download, clip, build and promotion of a routing graph as a scheduled, restartable pipeline, with a gate between “built” and “serving” so an unvalidated graph never answers a query.

The refresh is deceptively simple — download a file, run an importer, restart a service — and every one of those three steps has a failure mode that produces a working service with wrong answers. A truncated download imports cleanly and yields a smaller network; an importer interrupted halfway leaves partial graph files that some engines load without complaint; and a restart mid-batch means one scoring run used two different graphs.

Prerequisites

  • An orchestrator. Airflow, a workflow runner, or a scheduled job with retry semantics — the same orchestration layer that runs the rest of the pipeline.
  • Object storage for the source file and the built graph, addressed by version.
  • A clip geometry, produced as in clipping OSM extracts to retail market boundaries.
  • A validation suite — counts, connectivity and a fixed route set — that the promotion gate can run.

Configuration and execution parameters

Parameter Value for this task Notes
schedule monthly, off-peak Weekly rebuilds churn catchments for no gain
source_checksum required The single cheapest guard against a truncated download
build_workspace scratch volume Imports need several times the extract size in temp space
build_version source date + profile + config hash Written into the artifact path
promotion gated Automated only when every check passes
retain_builds 3 Roll back and compare
drain_before_switch true Let in-flight requests finish on the old graph
alert_on failure and skipped promotion A build that never promotes is a silent stall

drain_before_switch prevents the subtlest failure in the list. Switching the pointer while a batch is running means some contours in one scoring run were computed on the old graph and some on the new, with nothing in the output distinguishing them. Draining — or simply refusing to promote while a batch holds a lease — keeps a run internally consistent.

Build always, promote only on a green gate Download with a checksum, clip, build the graph into a versioned location, then run counts, connectivity and route regression. A green result promotes by updating the pointer; a red result leaves the previous graph serving and raises an alert, with the failed build kept for inspection. The build is routine; the promotion is a decision download + checksum clip markets + buffer build graph versioned path validation suite counts · topology · routes all green? every check no keep serving the old graph alert · retain the failed build promote the pointer after draining requests A failed build that leaves the previous graph serving is not an outage — it is the system working.

Annotated implementation

The tasks below are written as plain functions so they can run under any orchestrator. What matters is the shape: every step writes to a version-scoped location, and promotion is a separate, last step.

python
from __future__ import annotations

import hashlib
import subprocess
from datetime import date
from pathlib import Path

SOURCE_URL = "https://download.example.org/region-latest.osm.pbf"
GRAPH_ROOT = Path("/srv/graphs")
POINTER = GRAPH_ROOT / "current"


def build_version(source_date: str, profile: str, config_hash: str) -> str:
    return f"{source_date}_{profile}_{config_hash[:8]}"


def download(target: Path, expected_sha256: str) -> Path:
    """A truncated download imports cleanly and yields a smaller network,
    so the checksum is not optional — it is the only thing that catches it."""
    subprocess.run(["curl", "-fsSL", "-o", str(target), SOURCE_URL], check=True)
    digest = hashlib.sha256(target.read_bytes()).hexdigest()
    if digest != expected_sha256:
        target.unlink(missing_ok=True)
        raise ValueError(f"checksum mismatch: {digest[:12]} != {expected_sha256[:12]}")
    return target


def build_graph(clipped_pbf: Path, profile: Path, version: str) -> Path:
    """Build into a version-scoped directory. Nothing writes to the live path."""
    out_dir = GRAPH_ROOT / version
    out_dir.mkdir(parents=True, exist_ok=True)
    subprocess.run(["osrm-extract", "-p", str(profile),
                    "-o", str(out_dir / "graph.osrm"), str(clipped_pbf)], check=True)
    subprocess.run(["osrm-contract", str(out_dir / "graph.osrm")], check=True)
    (out_dir / "BUILD_OK").write_text(version)     # completion marker, written last
    return out_dir


def promote(version: str, checks_passed: bool, batch_lease_held: bool) -> bool:
    """Pointer swap. Refuses while a batch holds the graph, and refuses on red."""
    if not checks_passed:
        return False
    if batch_lease_held:
        return False                               # try again after the batch
    target = GRAPH_ROOT / version
    if not (target / "BUILD_OK").exists():
        raise RuntimeError("refusing to promote a build with no completion marker")
    tmp = POINTER.with_suffix(".tmp")
    tmp.symlink_to(target)
    tmp.replace(POINTER)                           # atomic on the same filesystem
    return True

The BUILD_OK marker written last is the cheap version of a transaction. An import killed partway leaves a directory full of plausible files and no marker, and the promotion step refuses it — which is exactly the case that otherwise produces a service that starts, answers queries, and returns nonsense for half the region.

Failure modes and debugging

A source file that moved or changed format. Extract providers reorganise, and a URL that has worked for two years starts returning an error page that a naive download saves as a .pbf. The checksum catches this immediately; without one, the importer’s error message is about malformed data and the cause is three steps upstream.

Disk exhaustion mid-import. Graph builds need several times the extract size in temporary space, and the failure arrives as a cryptic error from a tool that was halfway through writing. Checking free space against a multiple of the input size before starting turns an obscure failure into a clear one.

Promotion while a batch is running. The most damaging and least obvious. A contour batch that spans a promotion produces a mixed-version result set, and the artifacts carry a single build version that is now wrong for half of them. A lease held by the batch and checked by the promoter is a few lines and removes the possibility.

A build that never promotes. The gate fails, the alert goes to a channel nobody reads, and three months later the graph is a quarter old while the pipeline reports success every month. Alerting on a skipped promotion, not only on a failure, is what catches the slow version of this.

Twelve monthly refreshes and what actually happened Of twelve scheduled refreshes, nine built and promoted cleanly, one failed the checksum and retried successfully the same night, one failed the route regression and was investigated before promotion, and one was deferred because a scoring batch held the graph lease and promoted two hours later. Three of twelve needed the safety net — which is why it exists built and promoted cleanly 9 checksum mismatch, retried 1 — the provider's mirror was mid-sync route regression failed 1 — a motorway closure, real and worth knowing deferred, batch held the lease 1 — promoted two hours later, no mixed run The regression failure is the interesting one: the graph was correct and the change was large, so the right response was to promote deliberately and tell the analytics team why catchments moved. A gate that never fires is a gate nobody can trust when it does.

Verification

  • Confirm the pointer moved and the engine reloaded. Query the service for its build version after promotion. A pointer that changed while the engine still holds the old graph in memory is the classic half-promoted state.
  • Re-run the route regression against the promoted graph, not only against the built one. The two should be identical; if they are not, the service is serving something other than what was validated.
  • Check that old builds are still readable for the retention window, so a rollback is a pointer change rather than a rebuild.
  • Assert the completion marker exists before every promotion, and test the assertion by promoting a deliberately incomplete build in a staging environment.
Three retained builds and one pointer Three dated graph directories sit side by side and a single pointer names the one in service. Rolling back is repointing at the previous directory and reloading, which takes seconds; without retention the same rollback is a fourteen-minute rebuild under pressure. Retention is what turns a rollback into a decision instead of a project 2026-06-01_car_3f9a retained 2026-07-01_car_3f9a retained · rollback target 2026-08-01_car_4b1c in service current pointer rollback The dashed arrow is the entire rollback procedure: repoint, reload, and record that you did — the failed build stays on disk so the reason can be found afterwards rather than under pressure.

Frequently Asked Questions

Why monthly rather than weekly or nightly?

Because the analytical cost of churn exceeds the value of freshness at higher frequencies. Catchments computed on different graphs are not comparable, and a nightly rebuild makes every stored contour a day-specific artifact. Monthly keeps the network usefully current — most road changes that matter to a catchment take longer than a month to appear in the map anyway — while giving a stable reference period for anything computed against it. Markets undergoing major roadworks justify an out-of-cycle rebuild, which is a decision rather than a schedule.

Should the refresh rebuild every profile?

Yes, together, from the same source file. Building the car graph in one cycle and the walking graph in another produces two graphs describing different snapshots of the world, and any multi-modal comparison between them is then measuring the gap between builds as well as the gap between modes. Building all profiles from one download and promoting them as a set keeps them consistent.

How should the pipeline handle a source that publishes irregularly?

Trigger on availability rather than on the calendar, with a scheduled check that compares the published checksum against the one already built. That turns an unpredictable publication into an event the pipeline reacts to, and it avoids the two failure modes of a fixed schedule — downloading the same file twice, or missing a publication because it landed the day after the job ran.

What belongs in the alert when a build fails?

The version being built, the failing check with its measured and expected values, and a statement of what is currently serving. That last part is what turns an alert into information: knowing that the July graph is still in service and the August build failed its route regression tells whoever is on call that nothing is broken and something needs looking at, which is a very different night from an ambiguous failure message.

Should the refresh notify downstream consumers?

Yes, and the notification is more useful than most alerts. A promoted build changes every catchment computed afterwards, so the analytics and planning teams need to know a step change happened and roughly what it contained — usually a short summary generated from the validation output: how many routes moved, by how much, and which markets they were in. Teams that publish that note stop receiving the question “why did this number change?” and start receiving the far better question “should we re-score the shortlist?”

← Back to Preparing OSM Network Extracts for Routing