Versioning Cache Keys Across Normalisation Changes

TL;DR: Put the normalisation version in the key prefix, and when it changes, write the new version while still reading the old one. A miss on the new key that hits the old key is rewritten in place, so no address is ever re-resolved just because the rules changed. Remove the fallback only when backfill coverage reaches 100%. The wider policy is geocoding cache invalidation and freshness.

Why a Normalisation Change Is a Cache Event

Cache keys derived from normalised addresses inherit every property of the normaliser, including its bugs. The moment a rule changes — a new abbreviation, a different punctuation policy, a fix to the whitespace squeeze — a subset of addresses produce different keys, and every one of those becomes a miss.

Without a version segment there is no way to tell a legitimately new address from an address whose key merely moved. The pipeline pays for both identically, and the spend spike looks like a traffic spike, which sends everyone looking in the wrong place.

The same address, two keys, one avoidable bill One address is normalised under version six and version seven, producing two different canonical strings and therefore two different hash keys. Without a fallback, the version seven lookup misses and the address is re-resolved at full price even though the answer is already stored under the version six key. 100 N Main St #4 one address, unchanged norm v6 → 100 n main st 4 geo:n6:… 9f2c4ab1 norm v7 → 100 n main st unit 4 geo:n7:… c81d7e40 stored — the answer is here miss — billable, unnecessarily The fix is not to avoid changing the rules; it is to make the new key able to find the old answer.

Two Versions, Not One

A single version conflates two independent things. The shape of the stored value and the derivation of the key change for different reasons and should invalidate different amounts.

Change Value version Key version What must happen
add a field to the cached payload bump unchanged old entries readable or lazily upgraded
fix a whitespace bug in normalisation unchanged bump dual-read window, backfill
switch providers unchanged unchanged provider is already a key segment
change hash algorithm unchanged bump dual-read window, backfill
change the precision tiering bump unchanged re-derive tiers from stored payloads

Only two of these five need the migration window, and knowing which is which prevents the common overreaction of treating every change as a full invalidation.

The Dual-Read Window

from __future__ import annotations

import json

import redis

CURRENT_NORM = 7
PREVIOUS_NORM = 6          # set to None once backfill coverage reaches 100%


def key_for(canonical: str, provider: str, norm: int) -> str:
    import hashlib
    digest = hashlib.blake2b(canonical.encode("utf-8"), digest_size=12).hexdigest()
    return f"geo:v3:n{norm}:{provider}:{digest}"


def get_with_fallback(
    r: redis.Redis,
    canonical_current: str,
    canonical_previous: str,
    provider: str,
    ttl_s: int,
) -> dict | None:
    """Read the current key; on a miss, try the previous version and rewrite forward."""
    new_key = key_for(canonical_current, provider, CURRENT_NORM)
    hit = r.get(new_key)
    if hit is not None:
        return json.loads(hit)

    if PREVIOUS_NORM is None:
        return None

    old_key = key_for(canonical_previous, provider, PREVIOUS_NORM)
    legacy = r.get(old_key)
    if legacy is None:
        return None

    r.set(new_key, legacy, ex=ttl_s)         # rewrite forward, self-draining
    return json.loads(legacy)

The function needs both canonical strings, which means the old normaliser has to remain callable during the window. That is the real cost of this pattern — two versions of the normalisation code live side by side for a while — and it is a small price against re-resolving a cache that took months to build.

Tracking Backfill Coverage

The fallback path must not become permanent. A single counter pair tells you when it is safe to remove: reads answered by the new key, and reads answered by the fallback.

Coverage rising, fallback hits draining A chart over a four-week migration window. The share of reads answered by the new key rises steeply in the first week and asymptotes toward one hundred percent. The share answered by the fallback declines correspondingly. A marker at the point where the fallback share reaches zero indicates when the old code path can be removed. answered by the new key answered by the fallback remove the fallback here week 0 week 4

The curve flattens well before it reaches zero, because rarely-seen addresses take a long time to be read again. Rather than waiting months for the last fraction of a percent, run a bulk backfill over the durable table for whatever remains — it is a database operation, not a provider one, and it finishes the migration in an afternoon.

Preventing Silent Divergence

The failure this whole mechanism cannot protect against is two services normalising differently while both claim the same version. That is a code problem, and it needs a code solution.

import hashlib
import inspect

from address_norm import canonicalise, NORM_VERSION

# Golden vectors: input -> expected canonical form under the CURRENT version.
GOLDEN = {
    "100 N. Main St., Apt 4": "100 n main st unit 4",
    "1600  Pennsylvania  Ave NW": "1600 pennsylvania ave nw",
    "Bahnhofstraße 4": "bahnhofstrasse 4",
}


def test_golden_vectors() -> None:
    for raw, expected in GOLDEN.items():
        assert canonicalise(raw) == expected, raw


def test_version_bumped_when_logic_changes() -> None:
    """A change to the normaliser body must be accompanied by a version bump."""
    body = inspect.getsource(canonicalise).encode("utf-8")
    fingerprint = hashlib.sha256(body).hexdigest()[:16]
    # Update BOTH constants together, deliberately, in the same commit.
    assert (NORM_VERSION, fingerprint) == (7, "4f2b8c1d9a03e517")

The second test is blunt and effective. It fails whenever anyone edits the normaliser without touching the version, which converts the most dangerous silent change in this whole subsystem into a red build and a two-line diff.

The Bulk Backfill

Waiting for organic traffic to complete the migration works and takes longer than anyone wants, because the tail of rarely-read addresses is long. A bulk backfill finishes it deterministically, and because it operates on the durable table rather than on providers, it costs nothing but database and cache time.

The shape is a scan of the results table, re-deriving the current-version key for each canonical address and writing the stored payload under it. Batched into chunks of a few thousand with a pipelined write, a few million rows complete in minutes. The only care needed is to skip rows whose new key already exists, so the backfill does not reset TTLs that organic traffic has already refreshed.

Run it after the organic curve flattens rather than immediately. The first week of dual-read migrates the hot keys for free, and starting the backfill after that means it handles only the cold tail — less work, less cache churn, and a smaller window in which both key versions occupy memory simultaneously.

Once coverage reaches one hundred percent, removing the fallback is a two-line change: PREVIOUS_NORM = None and the deletion of the old normaliser. Do both in the same commit, and keep the golden vectors from the previous version in the test suite as historical documentation of what the rules used to be.

Edge Cases and Failure Modes

Bumping the version without a fallback. The cache misses everywhere at once and the next batch runs at full price. This is the exact incident the pattern prevents, and it usually happens because the version was bumped as a “safe” cleanup with no migration plan.

Fallback left in permanently. Every miss then costs two lookups instead of one, and the old normaliser can never be deleted. The coverage metric plus a dated removal ticket is the antidote; without one, the temporary path becomes structural.

Two services with different PREVIOUS_NORM. One rewrites forward and the other does not, so coverage plateaus and nobody can explain why. Keep the version constants in a shared library rather than duplicated per service.

Durable table keyed by cache key alone. If the results table is keyed on the versioned cache key rather than on the canonical address, a version bump orphans the history too. Store the canonical address as well, so keys can always be re-derived from it.

The Three Phases, Side by Side

Read and write behaviour in each phase A three-column table. Before the migration, both reads and writes use version six. During the window, writes use version seven while reads try version seven then fall back to version six and rewrite forward. After the migration, both use version seven and the old normaliser is deleted. Phase Writes Reads before v6 v6 window v7 v7, else v6 → rewrite forward after v7 v7 — old normaliser deleted Only the middle row costs anything, and what it costs is one extra read on a miss — never a provider call.

Integration Note

This pattern is what makes normalisation improvable. Without it, teams avoid touching the normaliser because the cache cost of any change is prohibitive, and the parsing rules ossify around whatever was written first. With it, a rule fix is an ordinary deployment plus a four-week window — which is why the version segment belongs in the key from day one, even when there is only ever one version. The rules themselves are covered in generating canonical address keys in Python, and the durable storage that makes bulk backfill cheap in Postgres materialized view geocode cache.