Caching OSRM Route Matrices for Large Batches

Recomputing an entire origin-destination duration matrix on every pipeline run wastes OSRM engine time and invites timeouts at agency scale, a fault-tolerance problem within batch routing and error handling. This guide builds a content-addressed cache in Python, keyed on a stable hash of the sorted coordinate pair plus routing profile and engine version, so a run recomputes only the pairs that actually changed.

Problem Context & Constraints

The naive pipeline treats the OSRM table service as free: on every run it rebuilds the full N×N matrix from scratch. For a few hundred facilities and a few thousand population centroids that is tolerable. At the scale a state health department actually operates — tens of thousands of demand points against every facility, re-run nightly as case counts refresh — it is ruinous. The table service cost grows with the product of the coordinate counts, so a matrix that was cheap in a pilot becomes an hours-long job that times out, and the pipeline either stalls or drops chunks and biases the accessibility surface.

The insight that makes caching correct rather than merely fast is that most of the matrix does not change between runs. A facility does not move; a census centroid does not move; the road graph is updated on a monthly cadence, not nightly. What changes run-to-run is the demand weight attached to each point, not the geometry the router consumes. So the duration between a fixed origin and a fixed destination, under a fixed routing profile and a fixed engine build, is a pure function of its inputs — the definition of a cacheable value.

Three constraints shape the design and distinguish it from a generic memoization decorator.

  • The key must be stable and order-independent. Two runs that submit the same pair in a different iteration order, or with a coordinate printed to a different number of digits, must hit the same cache entry. That means rounding coordinates to a fixed precision and sorting the pair before hashing, so (A, B) and (B, A) — symmetric for a duration matrix under a symmetric profile — resolve identically, and floating-point noise never manufactures a miss.
  • The key must never leak precise patient geometry. A cache key derived from raw address coordinates is itself disclosure-sensitive. Rounding to a coarse grid and salting the hash breaks the link between the stored key and any recoverable location, satisfying data-minimization while preserving determinism.
  • Staleness must be explicit, not implicit. When the OSRM graph is rebuilt — a new OSM extract, a changed profile — every cached duration is potentially wrong. The engine version must be part of the key so a version bump invalidates the whole cache deterministically, rather than silently serving durations computed against a retired graph.

A single request flows through a cache lookup that resolves to a hit or a miss; only misses reach the engine, and every engine result is written back before the matrix is assembled:

Content-Addressed Cache Flow for OSRM Route Matrices A left-to-right flow. A coordinate pair is rounded, sorted, and hashed with the profile and engine version into a stable key. The key hits a cache lookup, which branches: a hit returns the stored duration directly to the matrix; a miss calls the OSRM table service, and the returned duration is written back to the cache store before it is appended to the matrix. Only misses consume engine time. Coord pair round · sort + profile · ver SHA-256 stable key cache lookup hit miss OSRM table compute store duration OD matrix assembled Only cache misses reach the engine; hits return stored durations, so a re-run recomputes just the changed pairs

Prerequisites

Pin the stack so keys and cache behavior are reproducible across runs and reviewers:

  • python 3.11
  • requests 2.32.x — the OSRM HTTP client
  • sqlite3 (standard library) — the durable cache store
  • A reachable OSRM table endpoint whose build version you can query or configure

Input state assumed by the code below:

  • Origins and destinations as (lon, lat) pairs in EPSG:4326, the order and CRS OSRM expects. The upstream projection discipline follows coordinate reference systems for public health; only WGS84 coordinates reach the engine.
  • A routing profile string (driving, walking) that is fixed for the run and recorded.
  • An engine version tag — the OSRM build identifier plus the OSM extract date — treated as an opaque cache-namespace token.
  • A per-run salt stored in the pipeline’s secret manager, so keys are non-reversible outside the trusted environment.

Step-by-Step Solution

The cache layer wraps the OSRM table call. It builds a stable key from rounded, sorted coordinates plus profile and version, checks a SQLite store, and calls the engine only on a miss — writing the result back before returning.

# python 3.11
# requests==2.32.3   (sqlite3, hashlib, json from the standard library)
import hashlib
import json
import logging
import sqlite3
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("osrm_cache")

COORD_PRECISION = 5   # ~1.1 m grid; coarse enough to break the link to a raw address

def cache_key(origin, dest, profile: str, engine_version: str, salt: str) -> str:
    """Stable, order-independent, non-reversible key for one OD duration.

    Rounding + sorting make (A,B) and (B,A) collapse to one entry and absorb
    float noise; the salt keeps rounded coordinates from being brute-forced.
    """
    a = (round(origin[0], COORD_PRECISION), round(origin[1], COORD_PRECISION))
    b = (round(dest[0], COORD_PRECISION), round(dest[1], COORD_PRECISION))
    pair = tuple(sorted([a, b]))                       # symmetric duration -> order-free
    blob = json.dumps({"pair": pair, "profile": profile,
                       "ver": engine_version, "salt": salt}, sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()

def init_cache(path: str = "osrm_cache.sqlite") -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.execute("CREATE TABLE IF NOT EXISTS durations ("
                 "key TEXT PRIMARY KEY, seconds REAL NOT NULL, engine_version TEXT NOT NULL)")
    return conn

def get_duration(conn, origin, dest, profile, engine_version, salt, engine_url) -> float:
    """Return the OD duration in seconds, serving from cache and filling misses."""
    key = cache_key(origin, dest, profile, engine_version, salt)
    row = conn.execute("SELECT seconds FROM durations WHERE key = ?", (key,)).fetchone()
    if row is not None:
        log.info(json.dumps({"event": "cache_hit", "key": key[:12]}))
        return float(row[0])

    # ---- miss: compute on the engine and write back ----
    coord_str = f"{origin[0]},{origin[1]};{dest[0]},{dest[1]}"
    url = f"{engine_url}/table/v1/{profile}/{coord_str}"
    resp = requests.get(url, params={"annotations": "duration"}, timeout=15)
    resp.raise_for_status()
    seconds = resp.json()["durations"][0][1]
    if seconds is None:
        raise ValueError(f"unroutable pair (null duration) key={key[:12]}")

    conn.execute("INSERT OR REPLACE INTO durations VALUES (?, ?, ?)",
                 (key, float(seconds), engine_version))
    conn.commit()
    log.info(json.dumps({"event": "cache_miss_store", "key": key[:12], "secs": round(seconds, 1)}))
    return float(seconds)

Incremental fill falls out of this design for free: a full-matrix build simply iterates every pair through get_duration, and on the second run only the pairs whose coordinates, profile, or engine version changed miss the cache. To rebuild a matrix after a demand refresh that moved nothing geometrically, the engine is never called at all.

def build_matrix(conn, origins, dests, profile, engine_version, salt, engine_url):
    """Assemble an OD duration matrix, hitting the engine only for uncached pairs."""
    hits = misses = 0
    matrix = []
    for o in origins:
        row = []
        for d in dests:
            key = cache_key(o, d, profile, engine_version, salt)
            cached = conn.execute("SELECT 1 FROM durations WHERE key = ?", (key,)).fetchone()
            hits += cached is not None
            misses += cached is None
            row.append(get_duration(conn, o, d, profile, engine_version, salt, engine_url))
        matrix.append(row)
    log.info(json.dumps({"event": "matrix_built", "hits": hits, "misses": misses,
                         "hit_rate": round(hits / max(hits + misses, 1), 4)}))
    return matrix

Validation & Edge Cases

A cache that returns wrong answers quickly is worse than no cache. Validate these three behaviors before trusting it in production.

1. Cache-hit accounting. Instrument every run with hit/miss counts and assert the hit rate is where the data-change pattern predicts. A run after a demand-only refresh should approach a 100% hit rate; a near-zero hit rate on such a run signals a key-instability bug — usually a coordinate printed at full float precision on one path and rounded on another:

INFO {"event":"matrix_built","hits":998001,"misses":0,"hit_rate":1.0}

2. Key stability across serialization. The most common defect is a key that depends on input formatting rather than input value. Test it directly: the same pair, submitted reversed and at differing textual precision, must produce one key.

k1 = cache_key((-122.41942, 37.77493), (-122.27652, 37.80440), "driving", "v5.27-2026-07", "s")
k2 = cache_key((-122.276520, 37.804400), (-122.419420, 37.774930), "driving", "v5.27-2026-07", "s")
assert k1 == k2, "key must be order- and precision-independent"

3. Staleness on engine-version bump. When the OSRM graph is rebuilt, bump engine_version. Because the version is inside the key, every prior entry becomes unreachable and the next run recomputes cleanly against the new graph — no manual flush, no risk of serving durations from a retired build. Retain the old rows only for audit reconstruction, and purge them on a documented retention schedule:

def purge_stale(conn, current_version: str):
    n = conn.execute("DELETE FROM durations WHERE engine_version != ?",
                     (current_version,)).rowcount
    conn.commit()
    log.info(json.dumps({"event": "purge_stale", "removed": n, "kept_version": current_version}))

The engine-specific timeout and retry handling that protects each miss’s HTTP call is covered in handling API timeouts in batch OSM routing; the cache sits in front of that retry layer, so a cached hit never risks a timeout at all.

Compliance Notes

Three properties make a cached matrix defensible for regulated public health reporting:

  • No raw geometry in the key. Coordinates are rounded to a coarse grid and the hash is salted, so a stored key cannot be reversed to a patient address. This satisfies HIPAA minimum-necessary and GDPR data-minimization while keeping the key deterministic — the same guarantee the parent routing layer enforces through payload hashing rather than coordinate storage.
  • Version-bound provenance. Every cached duration carries the engine_version that produced it. A reviewer can therefore prove which OSRM build and OSM extract underlies any accessibility figure, and a version bump is a clean, auditable invalidation boundary rather than a silent correctness hazard.
  • Deterministic, reproducible fill. Because keys are order-independent and coordinates are rounded, an identical input set yields an identical cache state regardless of iteration order or machine. Recording the salt, the coordinate precision, and the engine version alongside the output lets a second analyst reconstruct the exact matrix for peer review.