Recomputing the same travel times every morning is the largest avoidable cost in most matrix pipelines. Delivery manifests overlap heavily day to day, traffic profiles change on a schedule rather than continuously, and the graph itself changes weekly at most — so the great majority of cells a dispatch run needs were computed at some point in the recent past. This page covers caching them safely, as part of async route matrix automation within Routing API Automation & Fleet Integration.

The word doing the work in that sentence is “safely”. A travel-time cache that serves a value computed against a graph that has since been rebuilt is worse than no cache, because the resulting plan is confidently wrong and nothing downstream can tell.

When to use this approach

Cache when stop sets overlap between runs — recurring delivery rounds, fixed service points, depot-to-customer legs that repeat. Do not bother when every run is a fresh set of one-off coordinates, which is the case for some on-demand workloads and for nothing in scheduled logistics.

The economics are unusual because reuse scales quadratically. A manifest sharing 90 % of yesterday’s stops shares 81 % of the pairs; one sharing 70 % shares only 49 %. That curve is steep enough that a modest improvement in manifest stability produces a large improvement in hit rate, which is worth knowing when someone proposes reshuffling territories.

Reuse scales with the square of shared stops Pair-level cache hit rate is plotted against the fraction of stops shared with the previous run. Because a pair needs both endpoints to be shared, the hit rate is the square of the shared fraction — 90 percent shared stops gives 81 percent reuse, while 50 percent gives only 25. Shared stops against reusable pairs 100 % 0 0 % 50 % shared 100 % linear, for comparison 90 % shared → 81 % reuse 50 % shared → 25 % reuse A pair is reusable only if both endpoints recur, so hit rate is the square of stop overlap — small stability gains pay disproportionately. This is also why stable territories are worth more operationally than they look on a map.

Implementation

The key is the whole design. Everything that can change the answer must be in it.

# requires: none beyond the standard library
import hashlib


def pair_key(origin_id: str, dest_id: str, *, graph_version: str,
             profile: str, time_bucket: str) -> str:
    """A cache key that becomes wrong exactly when the value does."""
    raw = f"{origin_id}|{dest_id}|{graph_version}|{profile}|{time_bucket}"
    return "tt:" + hashlib.blake2b(raw.encode(), digest_size=16).hexdigest()

graph_version is the content hash or build id of the routing artefact currently serving, taken from the same versioning used in incremental graph rebuilds without full reprocessing. Including it means a rebuild invalidates everything atomically and without an eviction sweep — the old entries simply become unreachable and age out.

time_bucket covers time-dependent costs. Where the engine serves a traffic layer or a time-of-day profile, a matrix computed for the 08:00 bucket is not valid for the 14:00 one, and omitting the bucket is the second most common way to serve a wrong value.

# requires: redis, numpy (pip install redis numpy)
import numpy as np
import redis


class MatrixCache:
    """Pair-granular travel-time cache over Redis."""

    def __init__(self, client: redis.Redis, ttl_s: int = 14 * 86400) -> None:
        self.r = client
        self.ttl = ttl_s

    def get_many(self, keys: list[str]) -> list[int | None]:
        vals = self.r.mget(keys)
        return [None if v is None else int(v) for v in vals]

    def put_many(self, pairs: dict[str, int]) -> None:
        # Pipeline so a few thousand writes are one round trip
        pipe = self.r.pipeline(transaction=False)
        for k, v in pairs.items():
            pipe.set(k, int(v), ex=self.ttl)
        pipe.execute()

The TTL is a safety net rather than the invalidation mechanism. With the graph version in the key, entries for a superseded graph are already unreachable; the TTL just stops them occupying memory forever.

Assembly then becomes: look up everything, compute the misses, merge.

# requires: numpy (pip install numpy)
import numpy as np


def matrix_with_cache(ids: list[str], cache, fetch_missing, **key_parts) -> np.ndarray:
    """Assemble a matrix from cached pairs plus a fetch for the remainder."""
    n = len(ids)
    keys = [[pair_key(a, b, **key_parts) for b in ids] for a in ids]
    flat = [k for row in keys for k in row]
    cached = cache.get_many(flat)

    out = np.full((n, n), -1, dtype="int32")
    misses: list[tuple[int, int]] = []
    for idx, val in enumerate(cached):
        i, j = divmod(idx, n)
        if val is None:
            misses.append((i, j))
        else:
            out[i, j] = val

    if misses:
        fetched = fetch_missing(ids, misses)          # engine call, chunked
        writes = {}
        for (i, j), v in zip(misses, fetched):
            out[i, j] = v
            writes[keys[i][j]] = v
        cache.put_many(writes)

    np.fill_diagonal(out, 0)
    if (out < 0).any():
        raise RuntimeError("matrix still has holes after fetch — a chunk failed")
    return out

The final assertion matters. A cache that silently leaves -1 in the array hands a sentinel to the solver, which is exactly the failure described in feeding a travel-time matrix into OR-Tools.

Key parameters and tuning

Parameter Recommended value Notes
Key components pair + graph version + profile + time bucket Omit any one and stale values become reachable
Granularity per pair Whole-matrix keys miss on any single stop change
Time bucket width match the traffic layer Hourly for predictive traffic; a single bucket where costs are static
TTL 7–14 days A backstop, not the invalidation mechanism
Backend Redis mget and pipelining make bulk lookup one round trip
Value encoding integer seconds Same units as the matrix, so no conversion at read time
Hit-rate target above 60 % on recurring rounds Below that, check whether the graph version is churning

Integration points

With the fetch layer. Misses are handed to the chunking and concurrency machinery in chunking large origin-destination matrices. Because misses are scattered rather than contiguous, it is usually cheaper to fetch the bounding sub-matrix of the missing rows and columns than to request individual pairs.

With the graph pipeline. The graph version must be readable at request time. Exposing it on the engine’s health endpoint, and reading it once per run rather than per pair, keeps the coupling loose without letting the two drift.

With dispatch. Warm the cache before the dispatch window rather than during it. A scheduled pre-run over the expected manifest turns dispatch-time matrix computation from a bottleneck into a lookup, which is often the difference between a thirty-second solve budget and a five-minute one.

Every key component prevents one class of stale hit The pair identity prevents cross-route contamination, the graph version prevents serving times from a superseded topology, the profile prevents a van reading truck times, and the time bucket prevents an evening matrix being served at breakfast. What each key component protects against origin + destination the value itself graph version omit it and a rebuild keeps serving the old network's travel times profile omit it and a van reads artic times time bucket omit it and peak times serve at 22:00 Cache behaviour across a week with a rebuild in it A cold cache fetches 38 400 pairs on Monday and only 5 900 by Wednesday as the manifest stabilises. A graph rebuild on Thursday invalidates everything and the count returns to near its cold value. Engine calls per dispatch run, over one week Monday — cold cache 38 400 pairs fetched Tuesday 8 100 Wednesday 5 900 Thursday — graph rebuilt 37 200 — full invalidation The Thursday spike is correct behaviour, not a regression — a new graph means every cached travel time is describing a network that no longer exists. Schedule rebuilds outside the dispatch window so the cold refetch happens when nobody is waiting on it.

Validation checklist

  • A graph rebuild produces a full miss. Bump the graph version and confirm the hit rate drops to zero on the next run. If it does not, the version is not reaching the key.
  • No sentinel survives assembly. Assert that the returned matrix contains no -1. A hole means a fetch chunk failed and was not retried.
  • Hit rate is reported per run. Log hits, misses and the resulting engine call count. A quiet drop in hit rate is the earliest signal that graph versions are churning unexpectedly.
  • Cached and fresh values agree. Periodically recompute a random sample of cached pairs and compare. Any disagreement within one graph version is a key collision.
  • Memory is bounded. Track key count against the theoretical ceiling of distinct locations squared. Growth beyond it means the key includes something that varies per request.