Cache Invalidation Strategies for Indoor Maps
Caching is what lets an indoor routing graph answer a turn-by-turn query in single-digit milliseconds; invalidation is what stops that same cache from sending a visitor down a corridor that was sealed an hour ago. This technique sits inside the Production-Ready Indoor Map Deployment lifecycle, immediately downstream of the artifact registry: once a new floor-level revision is promoted, every CDN node, edge worker, and client SDK holding the previous routing graph must be told — deterministically and per building — to drop it. Get this wrong and the rest of the deployment pipeline is undermined, because a perfectly validated artifact is worthless if devices keep serving the stale one.
The Problem: Stale Routing Graphs in a Distributed Cache
Indoor wayfinding has a tighter consistency requirement than almost any outdoor GIS workload. An outdoor basemap can tolerate a tile being minutes out of date — the world has not moved. Indoors, a single edit changes navigational truth instantly: a facilities team closes a corridor for a spill, a GIS developer re-weights a wheelchair-accessible ramp, an automation job recalibrates a beacon zone. The moment that change is promoted, three classes of cached state become actively wrong:
- Routing graphs — precomputed adjacency and shortest-path matrices that now contain edges which no longer exist or weights that no longer apply.
- Vector tiles — the rendered
z/x/ytiles a client paints, which still show the old room boundary or POI marker. - POI and occupancy metadata — the “this room is bookable / this exit is open” records that drive UI state.
The visible symptoms are specific and reportable. A user is routed into a loop because the client’s cached graph still believes a removed edge is traversable. A “Routing Unavailable” fallback fires on a floor that is actually fully mapped, because a partial purge left the metadata layer and the graph layer disagreeing — a split-brain cache state. Markers drift because the tile overlay updated but the POI metadata did not. Every one of these is a cache-coherence failure, not a mapping error, and they are solved by an invalidation strategy that operates at the level of the routing graph rather than the file.
The core tension is this: you want aggressive, long-lived caching for fast graph traversal, and you want a change to reach every device in under a second. Reconciling those two goals is the entire job of this page.
Prerequisites & Dependencies
Before implementing the dispatcher below, you need the following in place:
- A content-addressed version tag. Every promoted artifact must already carry a
topology_hash— the deterministic SHA-256 of its serialized routing graph — produced during validation. This is the same hash described in Rollback Triggers & Versioning; invalidation reuses it as the cache key rather than minting a separate version number. - A validated artifact contract. Purges should only ever be fired for artifacts that passed the JSON Schema Design for Indoor Maps contract and the gates in CI Gating for Map Updates. Invalidating toward a malformed graph is worse than serving a stale-but-valid one.
- The canonical GeoJSON envelope. Cache keys are derived from the
metadatablock of the standardFeatureCollection(itsbuilding_id,floor_level,map_version, andtopology_hash), so the producing pipeline must already emit that envelope. - Libraries.
redis-py(5.x) for pub/sub event fan-out,requests(2.x) for CDN purge APIs, and the standard-libraryhashlib,sqlite3, andjson. The client-side checker assumes an SQLite-capable runtime, which covers iOS, Android, and kiosk builds. - A CDN that supports key-scoped purge. Fastly surrogate keys, Cloudflare cache tags, or CloudFront invalidation paths — anything that lets you evict
building/floor/hashwithout flushing the whole zone. Coordinate-system assumptions are inherited from the artifact’s Indoor Coordinate Reference System; invalidation never touches geometry, only the keys that wrap it.
How It Works: Event-Driven Propagation Across Three Tiers
Indoor mapping pipelines cache at three tiers, and an invalidation must address each one independently while keeping them mutually consistent:
- Origin / storage layer — PostGIS or S3-backed GeoJSON holding the authoritative, versioned artifacts. This is the single source of truth and is never “invalidated”; it is appended to.
- Edge / CDN layer — Fastly, Cloudflare, or CloudFront caching compiled routing graphs and vector tiles under per-floor keys with long TTLs.
- Client / SDK layer — local SQLite or in-memory caches on phones and kiosks holding pre-fetched floor plans for offline use, consumed through the patterns in SDK Integration Patterns.
Polling cannot bridge these tiers fast enough. Waiting for a 60-second TTL to lapse after a corridor closes creates a routing dead zone and forces clients into GPS fallback indoors, where GPS does not work. The strategy is therefore event-driven: when an artifact is promoted, the invalidation service publishes a targeted message — map:invalidate:{building_id}:{floor_id}:{topology_hash} — onto a Redis Pub/Sub (or Kafka / SNS) channel. Edge nodes and clients subscribe and evict only the affected routing subgraph, leaving every other floor’s cache warm. A blanket flush would be correct but catastrophic for hit ratio during peak facility hours; selective eviction keyed on the hash is both correct and cheap. The end-to-end mechanics of consuming those events are covered in Implementing Cache Invalidation for Real-Time Updates.
Step-by-Step Implementation
The pipeline has four ordered steps: hash and validate, dispatch the event, purge the edge, then reconcile the client. The Python below implements a deterministic dispatcher (IndoorMapInvalidator) and a client-side version checker (ClientCacheManager).
Step 1: Deterministic version hashing
No purge is fired until the updated artifact has a stable identity. Reuse the topology_hash minted during validation as the immutable tag for every downstream cache key — never a wall-clock timestamp, which is non-deterministic across rebuilds and breaks idempotency.
import hashlib
import json
import logging
import sqlite3
import time
from typing import Optional
import redis
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def compute_topology_hash(topology_json: str) -> str:
"""Deterministic SHA-256 tag for a serialized routing graph."""
digest = hashlib.sha256(topology_json.encode("utf-8")).hexdigest()
logger.info("computed topology_hash=%s", digest[:12])
return digest
Step 2: Pub/Sub event dispatch
Publish a structured event carrying the building ID, affected floor level, new topology_hash, and a change type (TOPOLOGY_UPDATE, POI_METADATA, or BEACON_RECALIBRATION). Subscribers at the edge and client tiers consume it asynchronously, so the dispatcher returns immediately rather than blocking on every downstream cache.
class IndoorMapInvalidator:
def __init__(
self,
redis_url: str,
cdn_purge_url: str,
cdn_api_token: str,
origin_base_url: str,
) -> None:
self.rds = redis.from_url(redis_url, decode_responses=True)
self.cdn_purge_url = cdn_purge_url
self.cdn_headers = {"Authorization": f"Bearer {cdn_api_token}"}
self.origin_base_url = origin_base_url
def publish_invalidation_event(
self, building_id: str, floor_id: str, version_hash: str, change_type: str
) -> None:
channel = f"map:invalidate:{building_id}:{floor_id}:{version_hash}"
payload = json.dumps({
"building_id": building_id,
"floor_id": floor_id,
"version_hash": version_hash,
"change_type": change_type,
"timestamp": time.time(),
})
try:
self.rds.publish(channel, payload)
logger.info("published invalidation event to %s", channel)
except redis.RedisError as exc:
logger.error("pub/sub dispatch failed for %s: %s", channel, exc)
raise
Step 3: Edge / CDN selective purge
The edge tier responds by evicting specific surrogate keys, not by flushing the zone. Tag-based purge lets you drop indoor_map_{building}_{floor}_{hash} and the floor’s routing_graph key while leaving adjacent floors untouched, which keeps a single edit from triggering a cache-miss storm across the whole campus.
def purge_cdn_surrogate_keys(
self, building_id: str, floor_id: str, version_hash: str
) -> bool:
"""Targeted CDN purge by surrogate key. Returns success."""
purge_payload = {
"surrogate_keys": [
f"indoor_map_{building_id}_{floor_id}_{version_hash}",
f"routing_graph_{building_id}_{floor_id}",
]
}
try:
resp = requests.post(
self.cdn_purge_url,
headers=self.cdn_headers,
json=purge_payload,
timeout=10,
)
resp.raise_for_status()
logger.info("CDN purge ok for %s/%s", building_id, floor_id)
return True
except requests.Timeout:
logger.error("CDN purge timed out for %s/%s", building_id, floor_id)
return False
except requests.RequestException as exc:
logger.error("CDN purge failed for %s/%s: %s", building_id, floor_id, exc)
return False
Step 4: Client SDK local cache synchronization
Clients keep a local routing graph for offline use, so they must reconcile against the origin when an event arrives. The checker compares its stored topology_hash to the expected one; on a mismatch it fetches a replacement (ideally a delta patch of only the changed nodes and edges) and upserts the new version. Keeping payloads delta-encoded is what makes sync resilient when a phone hands off between Wi-Fi and cellular mid-route.
class ClientCacheManager:
"""SQLite-backed client cache version checker."""
def __init__(self, db_path: str) -> None:
self.conn = sqlite3.connect(db_path)
self._init_schema()
def _init_schema(self) -> None:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS map_versions (
building_id TEXT PRIMARY KEY,
floor_id TEXT,
version_hash TEXT,
last_synced REAL
)
""")
self.conn.commit()
def check_and_sync(
self, building_id: str, floor_id: str, expected_hash: str, fetch_graph_url: str
) -> Optional[dict]:
cursor = self.conn.execute(
"SELECT version_hash FROM map_versions WHERE building_id=? AND floor_id=?",
(building_id, floor_id),
)
row = cursor.fetchone()
if row and row[0] == expected_hash:
logger.debug("cache hit for %s/%s", building_id, floor_id)
return None # already current, no transfer
try:
resp = requests.get(fetch_graph_url, timeout=15)
resp.raise_for_status()
graph_data = resp.json()
except requests.RequestException as exc:
logger.error("graph fetch failed for %s/%s: %s", building_id, floor_id, exc)
return None # caller falls back to last-known-good cache
self.conn.execute("""
INSERT INTO map_versions (building_id, floor_id, version_hash, last_synced)
VALUES (?, ?, ?, ?)
ON CONFLICT(building_id) DO UPDATE SET
floor_id=excluded.floor_id,
version_hash=excluded.version_hash,
last_synced=excluded.last_synced
""", (building_id, floor_id, expected_hash, time.time()))
self.conn.commit()
logger.info("synced new graph for %s/%s", building_id, floor_id)
return graph_data
Time-bound invalidations need one extra guard: scheduled rules such as after-hours security routing or HVAC-driven comfort zones must be expressed in UTC and only converted to the building’s local offset at evaluation time, so a daylight-saving shift never fires a purge an hour early or late across globally distributed sites.
Edge Cases & Gotchas
Even with deterministic hashing, production throws failure modes that a naive implementation will miss:
| Symptom | Root cause | Diagnostic | Remediation |
|---|---|---|---|
| Routing loops after a floor edit | Stale edge weights still in the client graph | Compare ClientCacheManager.last_synced to the origin’s generated_at |
Force a hard refresh via the map:invalidate:* wildcard and add a version-mismatch guard in the SDK |
| POI markers drift off geometry | CDN served an updated tile but the POI metadata key was not purged | Inspect Surrogate-Key headers on the tile response |
Purge the tile layer key separately from the routing-graph key |
| Split-brain cache state | A pub/sub message was lost during a network partition | redis-cli MONITOR over the invalidation window; check Kafka consumer lag |
Switch to Redis Streams with XREADGROUP for at-least-once delivery and make purges idempotent |
| Emergency egress edge missing | Topology validation was bypassed before promotion | Re-run the schema gate against the promoted artifact | Block promotion when accessibility_paths is empty or malformed at the gate |
| Timezone purge fires at the wrong hour | A DST shift was not applied to the scheduler | Diff the cron timezone against the UTC offset in the event payload | Standardize all triggers to UTC and convert only at evaluation |
The unifying rule: tiles and graphs are different keys with different lifetimes — never assume purging one evicts the other.
Validation Output
Confirm coherence with explicit assertions rather than eyeballing a map. A correct sync leaves the client hash equal to the origin hash and the dispatcher idempotent under a repeated event:
inv = IndoorMapInvalidator(redis_url, cdn_purge_url, token, origin_url)
client = ClientCacheManager(":memory:")
new_hash = compute_topology_hash(serialized_graph)
inv.publish_invalidation_event("BLDG-04", "3", new_hash, "TOPOLOGY_UPDATE")
# First sync pulls the new graph; second is a no-op cache hit.
assert client.check_and_sync("BLDG-04", "3", new_hash, fetch_url) is not None
assert client.check_and_sync("BLDG-04", "3", new_hash, fetch_url) is None
# Re-publishing the same hash must not change client state (idempotent).
inv.publish_invalidation_event("BLDG-04", "3", new_hash, "TOPOLOGY_UPDATE")
assert client.check_and_sync("BLDG-04", "3", new_hash, fetch_url) is None
The incorrect pattern to watch for: check_and_sync returning a graph object on the second call. That means the upsert did not persist the hash, the client will re-fetch on every event, and you will see the network saturation that delta-encoding was meant to prevent.
Performance & Scale Notes
- Selective purge is the budget. A per-floor surrogate-key purge touches O(1) keys; a zone flush touches every floor in the campus and re-warms them all on the next request. On a 40-floor airport the difference is the entire morning peak’s hit ratio.
- Delta payloads, not full dumps. For a large campus, transmit only modified nodes and edges. A full topology dump per edit multiplies client SQLite write contention and mobile transfer cost; a delta is typically two to three orders of magnitude smaller.
- Idempotency key. Use
topology_hash(not a timestamp) as the idempotency key so duplicate events from at-least-once delivery are free no-ops. - Bounded version history. Keep a rolling buffer of the last five hashes per floor for fast reverts; pair it with Rollback Triggers & Versioning so a spike in routing-anomaly telemetry auto-reverts to the previous stable hash.
- Observability. Instrument the pipeline with OpenTelemetry spans and track
invalidation_latency,cdn_purge_success_rate, andclient_sync_failures. If purge acknowledgment exceeds two seconds or a client fails three consecutive syncs, degrade to the last-known-good graph and emit aCACHE_DEGRADATIONmetric for facilities ops — the same graceful-degradation ladder used by Fallback Routing Architectures.
Frequently Asked Questions
Why key the cache on topology_hash instead of a version number or timestamp?
Because the hash is content-addressed: identical geometry always produces the same key, and any change produces a new one. That makes purges idempotent (re-firing the same event is a no-op), makes reverts a pointer swap to a hash that already passed every gate, and lets the edge re-verify byte integrity before serving. A timestamp or incrementing version is non-deterministic across rebuilds and cannot be used to detect that two artifacts are actually the same graph.
When should I purge a whole floor versus just the affected tiles?
Purge tile keys and the routing-graph key independently, and only the floor that changed. A geometry edit that moves a wall affects both the rendered tiles and the graph, so both keys for that floor are evicted; a POI status change (room now bookable) touches only the metadata key. Flushing the whole building — or worse, the CDN zone — is only justified when the schema_revision itself changes, because then every artifact’s key shape is different.
How do I stop a network partition from leaving caches inconsistent?
Plain Redis Pub/Sub is fire-and-forget, so a partitioned subscriber silently misses the event and keeps serving the old graph. Move to Redis Streams with consumer groups (XREADGROUP) for at-least-once delivery, make every purge idempotent on topology_hash, and have clients revalidate on reconnect by comparing their stored hash to the origin. The combination guarantees that a missed message is reconciled the next time the device is online rather than persisting as a split-brain state.
What happens to a user mid-route when a floor is invalidated under them?
The wayfinding engine degrades in order: the latest validated graph, then the last-known-good client cache, then a pre-baked immutable emergency-egress graph, then a 2D “Routing Unavailable” state. An in-progress route is never torn down abruptly; the client finishes guidance on its cached graph and reconciles to the new hash at the next decision point, which is why delta sync and a bounded version history matter for live sessions.
Related Pages
- Implementing Cache Invalidation for Real-Time Updates — the event-consumer side: tile ETag diagnostics, pub/sub purge automation, and SDK fallback handling.
- Rollback Triggers & Versioning — content-addressed reverts that reuse the same
topology_hashas the cache key. - CI Gating for Map Updates — the gates that guarantee you only ever invalidate toward a valid artifact.
- SDK Integration Patterns — how client SDKs consume and cache the routing graph this strategy keeps fresh.
This page is part of the Production-Ready Indoor Map Deployment reference; for the validation contract these caches depend on, see JSON Schema Design for Indoor Maps.