Implementing Cache Invalidation for Real-Time Indoor Map Updates
This page covers the consumer half of Cache Invalidation Strategies for Indoor Maps: how an edge worker and a client SDK react to a map:invalidate event so a corridor closed thirty seconds ago stops appearing as a traversable edge. The linked strategies guide builds the dispatcher that publishes the event; here we build the workers that receive it, diagnose what went stale, and evict exactly the affected vector tiles, routing subgraph, and POI metadata — nothing more.
What “real-time invalidation” means here
Real-time invalidation is the bounded propagation of a single content-addressed change to every cache tier within one navigation interval (sub-second to a few seconds), driven by an explicit event rather than by TTL decay. The unit of change is a topology_hash — the deterministic SHA-256 of a floor level’s serialized routing graph, the same hash minted during validation and reused as the cache key in Rollback Triggers & Versioning. “Real-time” does not mean polling on a tight loop; it means a subscriber wakes on a published message, compares its stored hash to the expected one, and fetches only the delta when they differ. Three distinct caches must converge on the new hash:
- CDN / edge vector tiles — the rendered
z/x/yMVT a client paints. - The routing subgraph — precomputed adjacency and shortest-path data for one floor level.
- POI / occupancy metadata — the “room bookable / exit open” records that drive UI state.
The hard part is that these are three different keys with three different lifetimes. Purging the tile but not the metadata leaves markers drifting off geometry; purging the graph but not the tile routes a user through a wall that is still painted as open. The consumer’s job is to evict all three coherently, keyed on one hash.
Minimal working example: an event consumer
The smallest useful consumer subscribes to the invalidation channel, validates the payload against the established envelope, and reacts. This snippet is self-contained: run it against a local Redis and publish a test message to watch it evict.
import asyncio
import logging
from typing import Optional
import redis.asyncio as redis
from pydantic import BaseModel, ValidationError
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("invalidation_consumer")
class InvalidationEvent(BaseModel):
building_id: str
floor_level: str
map_version: str
topology_hash: str
change_type: str # TOPOLOGY_UPDATE | POI_METADATA | BEACON_RECALIBRATION
async def consume(redis_url: str, channel: str = "map:invalidate:*") -> None:
client = redis.from_url(redis_url, decode_responses=True)
pubsub = client.pubsub()
await pubsub.psubscribe(channel)
logger.info("subscribed to %s", channel)
async for message in pubsub.listen():
if message["type"] != "pmessage":
continue
try:
event = InvalidationEvent.model_validate_json(message["data"])
except ValidationError as exc:
logger.error("dropping malformed event: %s", exc)
continue
logger.info("evicting %s/%s -> %s", event.building_id,
event.floor_level, event.topology_hash[:12])
# purge_edge(event) and notify_sdk(event) fan out from here
if __name__ == "__main__":
asyncio.run(consume("redis://localhost:6379/0"))
The pattern subscription (psubscribe on map:invalidate:*) means one worker handles every building and floor without re-subscribing per zone. Validation happens before any eviction, so a corrupt message is logged and skipped rather than triggering a blind flush.
Event payload reference
The published payload mirrors the metadata block of the canonical GeoJSON FeatureCollection, so the consumer never has to learn a second schema. Keys, types, and consumer behaviour:
| Field | Type | Default | Notes |
|---|---|---|---|
building_id |
str |
— | Stable building identifier; the high-order cache-key segment. |
floor_level |
str |
— | Floor identifier (use the level token, not a render layer index). |
map_version |
str |
— | SemVer MAJOR.MINOR.PATCH; MAJOR bump signals a schema change that forces a full re-fetch. |
topology_hash |
str (64 hex) |
— | Content-addressed key; the idempotency key for every eviction. |
change_type |
enum |
TOPOLOGY_UPDATE |
Selects which tiers to purge: graph+tiles, metadata-only, or beacon zone. |
timestamp |
float |
time.time() |
Wall-clock for telemetry only — never used as a cache key. |
ttl_fallback |
int (s) |
60 |
Safety net if the event is missed; the longest a stale tier can persist. |
Two diagnostics confirm an eviction actually landed before you trust it. Use a conditional curl to prove the edge dropped the tile — a 200 (not 304) means the surrogate-key purge worked:
curl -s -o /dev/null -w "%{http_code} %{header_json}" \
-H 'If-None-Match: "<old_etag>"' \
https://tiles.indoor-map.internal/v1/BLDG-04/floor/3/12/405/230.mvt
And confirm the routing subgraph version flipped in Redis, scoped to one floor rather than dumping the keyspace:
redis-cli HGET "routing:graph:BLDG-04:floor_3" topology_hash
Common errors and fixes
1. pydantic.ValidationError: floor_level field required. A producer is still emitting the legacy floor_id key. Root cause: the dispatcher predates the GeoJSON envelope alignment. Fix at the consumer boundary with a tolerant alias rather than letting the event drop silently:
from pydantic import BaseModel, Field
class InvalidationEvent(BaseModel):
floor_level: str = Field(validation_alias="floor_id") # accept legacy key
2. Stale tiles persist after a successful graph purge (split-tier eviction). The change_type was TOPOLOGY_UPDATE but the worker only evicted the routing_graph surrogate key. Root cause: tiles and graphs are separate keys; geometry edits invalidate both. Purge them together:
def surrogate_keys(event: InvalidationEvent) -> list[str]:
b, f, h = event.building_id, event.floor_level, event.topology_hash
keys = [f"routing_graph_{b}_{f}"]
if event.change_type in ("TOPOLOGY_UPDATE",):
keys.append(f"vector_tiles_{b}_{f}_{h}") # tiles share the floor edit
if event.change_type in ("TOPOLOGY_UPDATE", "POI_METADATA"):
keys.append(f"poi_meta_{b}_{f}")
logger.info("purging %d surrogate keys for %s/%s", len(keys), b, f)
return keys
3. redis.exceptions.ConnectionError mid-stream leaves a client serving the old graph. A network partition dropped the subscriber; plain Pub/Sub is fire-and-forget, so the missed message is gone. Root cause: at-most-once delivery. Fix by reconciling on reconnect — compare the locally stored hash to the origin and re-evict if they disagree — and, for stronger guarantees, move to Redis Streams with XREADGROUP so the missed entry is redelivered:
async def reconcile_on_reconnect(client, building: str, floor: str, origin_hash: str) -> Optional[str]:
try:
local = await client.hget(f"routing:graph:{building}:{floor}", "topology_hash")
except redis.ConnectionError as exc:
logger.error("reconcile failed, holding last-known-good: %s", exc)
return None
if local != origin_hash:
logger.warning("hash drift on %s/%s, forcing re-evict", building, floor)
return origin_hash # caller re-runs the purge + delta fetch
return None
How this feeds the rest of the deployment
This consumer sits directly downstream of the dispatcher described in Cache Invalidation Strategies for Indoor Maps and only ever fires for artifacts that already passed CI Gating for Map Updates and the JSON Schema Design for Indoor Maps contract — invalidating toward a malformed graph is worse than serving a stale-but-valid one. The delta the SDK fetches is consumed through the client patterns in SDK Integration Patterns; when three consecutive syncs fail, the worker emits a CACHE_DEGRADATION metric and the engine drops onto the graceful-degradation ladder defined by Fallback Routing Architectures, finishing the user’s in-progress route on the last-known-good graph and reconciling at the next decision point.
Frequently Asked Questions
Why purge by surrogate key rather than by URL?
Because one map edit touches many URLs and you will miss some. A single room boundary change affects the tiles covering it at every zoom level from 14 to 18, the level’s GeoJSON envelope, and any route response cached for that building — a set that varies with the geometry of the edit and is tedious and fragile to enumerate. Tagging every object with a building:level surrogate key at publish time turns that into one purge call whose scope is defined by the data rather than by whoever wrote the invalidation code. It also fails safe in the right direction: a key that is slightly too broad purges a few extra tiles, whereas a URL list that is slightly too narrow leaves a stale tile in place until its TTL expires, which is the failure users actually report.
What TTL should indoor map tiles have?
Long, with revalidation — not short. Set a generous max-age (hours to days) together with stale-while-revalidate, and rely on purges for correctness rather than on expiry. Indoor maps change rarely and unpredictably: a floor may be static for months and then change three times in a day, so a short TTL pays constant revalidation traffic for freshness it only occasionally needs, and still leaves a window of staleness after every real edit. Long TTLs plus targeted purges give immediate freshness where it matters and near-zero origin traffic the rest of the time. The exception is the level and venue metadata endpoint, which is small, cheap to revalidate, and the thing a client checks first — a minute or two there costs nothing.
How do I invalidate a cache I cannot reach — the device?
You cannot purge it, so it has to be designed to ask. Devices hold tiles in SDK memory and on disk and no API reaches into either, which means device freshness depends entirely on revalidation: serve tiles with an ETag derived from the level’s topology hash, and have the SDK send If-None-Match on a cadence appropriate to the surface — on app foreground, on building entry, and before a route request. The response is a 304 in the overwhelming majority of cases, so the cost is one small request, and a genuine change is picked up within one cadence interval rather than whenever the on-disk cache happens to expire. An offline pack needs a version check on the same schedule, because it has no expiry at all.
Related pages
- Cache Invalidation Strategies for Indoor Maps — the dispatcher side that publishes the events this page consumes.
- Designing JSON Schemas for Indoor Map APIs — the payload contract that defines the metadata envelope used as the cache key.
- Integrating Indoor Maps with React Native SDKs — how the client applies the delta fetched after an invalidation.
This page is part of the Cache Invalidation Strategies cluster within the Production-Ready Indoor Map Deployment reference.