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/y MVT 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.

Consumer sequence: one event, a scoped edge purge, and a delta-only client swap A vertical sequence diagram across five lifelines — Redis Pub/Sub, the edge worker, the CDN edge, the client SDK, and the local SQLite graph. Step 1, Redis publishes the channel map:invalidate:{building}:{floor}:{hash} to the edge worker. Step 2, the worker issues a surrogate-key purge to the CDN, evicting the vector tiles, routing subgraph and POI metadata for only that floor. Step 3, Redis pushes the same delta to the client SDK over server-sent events. Step 4, the SDK sends an If-None-Match request with the old ETag and the CDN answers 200 rather than 304, proving the tile is stale. Step 5, the SDK delta-fetches only the changed nodes and edges, receiving a small payload rather than the whole graph. Step 6, the SDK atomically swaps its local SQLite routing graph so it matches the origin topology_hash. Consumer sequence: one event, a scoped edge purge, a delta-only client swap Redis Pub/Sub publisher Edge worker subscriber CDN edge surrogate-key store Client SDK over SSE Local SQLite routing graph 1 · publish event map:invalidate:{building}:{floor}:{hash} 2 · surrogate-key purge evicts: vector tiles · routing subgraph · POI metadata 3 · same delta pushed to the client over SSE 4 · If-None-Match: <old ETag> 200, not 304 → the tile is stale 5 · delta fetch: changed nodes + edges only small delta payload, not the whole graph 6 · atomic graph swap now matches origin topology_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.

This page is part of the Cache Invalidation Strategies cluster within the Production-Ready Indoor Map Deployment reference.