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.
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.
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
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.
Related
- Geocoding Cache Invalidation and Freshness — the trigger this page addresses in full.
- Warming a Geocoding Cache From Historical Orders — smoothing the first day of a migration.
- Generating Canonical Address Keys in Python — the derivation the version describes.