Redis Cache Tags for Bounding Box Queries

Implement Redis cache tags for spatial bounding box queries using Redis Sets. Invalidate whole grid cells atomically when PostGIS geometry data changes.

← Back to Redis Caching for Spatial Queries

Bounding box queries carry high-cardinality parameters: a viewport shift of 0.0001 degrees or a minor zoom change generates a completely new cache key, making conventional per-key invalidation unmanageable at scale. This page shows how to group those keys under spatial tag Sets in Redis so you can invalidate an entire geographic grid cell in a single atomic pipeline when PostGIS geometry changes.

Context & When to Use

Standard cache strategies break down for spatial bounding box endpoints because the parameter space is effectively infinite. A delivery fleet map can produce thousands of distinct bbox strings in a single hour of normal use. If a driver’s geometry is updated in PostGIS, you need to expire every cached response that could contain that driver — but you cannot enumerate those keys ahead of time.

The tag-based approach solves this by introducing a level of indirection. Every bbox cache key is registered into a Redis Set that represents the coarse grid cell it falls within. When geometry in a cell changes, you fetch the Set membership and purge the whole batch. The cost is two extra Redis commands on write (SADD) and one pipeline on invalidation (SMEMBERS + UNLINK + DEL), both of which are negligible compared to the PostGIS query they replace.

Use this pattern when: your bbox parameters are user-driven and unpredictable (map panning, viewport resizing); your underlying spatial data changes frequently enough that stale responses are a correctness concern; and you need sub-50 ms read latency under high concurrency. If your spatial data is nearly static, a simpler approach — a global TTL plus periodic cache warming — is sufficient. For the foundational key structure and connection setup, read the Redis Caching for Spatial Queries guide first.

This technique also complements Query Plan Analysis & Index Tuning work: once you know which ST_Intersects or ST_Within calls dominate your EXPLAIN ANALYZE output, tag-driven caching is the next layer to add so those expensive plans run as rarely as possible.

Tag Architecture Diagram

The diagram below shows the data flow from an incoming bbox request through the Redis tag layer to PostGIS, and the separate invalidation path triggered by a geometry mutation.

Redis cache tag architecture for bounding box queriesDiagram showing two flows: a read path where bbox requests hit Redis and fall through to PostGIS on a miss, and an invalidation path where PostGIS mutations purge the Redis tag Set.FastAPIbbox requestNormalizeround + grid tagRedisGET cache_keyHIT →returnMISSPostGISST_Intersects queryPipeline writeSETEX + SADDInvalidation path (on PostGIS mutation)Geometry updatePOST /featuresDerive tag keygrid cell(s)SMEMBERSfetch all keysUNLINK+ DEL tagSolid arrows — read/write pathDashed — cache miss fallthrough

Runnable Implementation

The implementation below uses redis-py 5.0+ async client and Python 3.10+. It covers coordinate normalization, deterministic key generation, atomic cache writes with tag registration, and the invalidation routine. Wire invalidate_layer_bbox into any FastAPI mutation route that persists geometry to PostGIS.

For the PostGIS query inside fetch_features_from_db, use ST_Intersects or ST_Within with a properly maintained GIST index — see Bounding Box & Spatial Index Queries for the full index setup. If you are returning large feature sets, consider the GeoJSON vs GeoParquet Serialization decision matrix before choosing a serialization format for the cached payload.

import math
import json
from typing import Optional
from fastapi import FastAPI, Query, Depends
import redis.asyncio as aioredis

app = FastAPI()

# ------------------------------------------------------------------
# Connection — reuse a single pool across all requests
# ------------------------------------------------------------------
redis_pool = aioredis.ConnectionPool(
    host="localhost", port=6379, db=0,
    decode_responses=True, max_connections=50
)

async def get_redis() -> aioredis.Redis:
    return aioredis.Redis(connection_pool=redis_pool)

# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
CACHE_TTL = 3600      # seconds — fallback TTL if invalidation is missed
TAG_TTL   = 5400      # tag Set TTL — slightly longer than CACHE_TTL
PRECISION = 4         # ~11 m accuracy at mid-latitudes
GRID_SIZE = 1.0       # 1°×1° grid cell for tag derivation

# ------------------------------------------------------------------
# Key helpers
# ------------------------------------------------------------------
def normalize_bbox(minx: float, miny: float,
                   maxx: float, maxy: float) -> tuple[float, float, float, float]:
    """Round all bbox edges to PRECISION decimal places."""
    return (
        round(minx, PRECISION), round(miny, PRECISION),
        round(maxx, PRECISION), round(maxy, PRECISION),
    )

def cache_key(layer: str, bbox: tuple) -> str:
    """Deterministic cache key from layer + normalized bbox."""
    return f"cache:bbox:{layer}:{bbox[0]}:{bbox[1]}:{bbox[2]}:{bbox[3]}"

def tag_key(layer: str, bbox: tuple) -> str:
    """Redis Set key representing the grid cell containing this bbox center."""
    cx = (bbox[0] + bbox[2]) / 2
    cy = (bbox[1] + bbox[3]) / 2
    gx = math.floor(cx / GRID_SIZE) * GRID_SIZE
    gy = math.floor(cy / GRID_SIZE) * GRID_SIZE
    return f"tag:bbox:{layer}:{gx}:{gy}"

# ------------------------------------------------------------------
# Cache-aside read with tag registration
# ------------------------------------------------------------------
async def fetch_features_from_db(
    layer: str, bbox: tuple, redis: aioredis.Redis
) -> dict:
    """
    Replace this stub with your asyncpg + PostGIS call.
    Example SQL:
        SELECT ST_AsGeoJSON(geom) FROM features
        WHERE layer = $1
          AND ST_Intersects(geom, ST_MakeEnvelope($2,$3,$4,$5, 4326))
    """
    return {"type": "FeatureCollection", "features": []}

@app.get("/api/features")
async def get_features(
    layer: str = Query(...),
    minx: float = Query(...), miny: float = Query(...),
    maxx: float = Query(...), maxy: float = Query(...),
    redis: aioredis.Redis = Depends(get_redis),
):
    bbox  = normalize_bbox(minx, miny, maxx, maxy)
    ckey  = cache_key(layer, bbox)

    # 1. Cache lookup
    cached = await redis.get(ckey)
    if cached:
        return {"source": "cache", "data": json.loads(cached)}

    # 2. Cache miss — query PostGIS
    data = await fetch_features_from_db(layer, bbox, redis)
    payload = json.dumps(data)

    # 3. Write cache entry + register in tag Set (one pipeline, non-transactional)
    tkey = tag_key(layer, bbox)
    async with redis.pipeline(transaction=False) as pipe:
        pipe.setex(ckey, CACHE_TTL, payload)   # cache the response
        pipe.sadd(tkey, ckey)                   # register key in grid-cell tag
        pipe.expire(tkey, TAG_TTL)              # safety TTL on the tag Set itself
        await pipe.execute()

    return {"source": "db", "data": data}

# ------------------------------------------------------------------
# Invalidation — call this inside mutation routes
# ------------------------------------------------------------------
async def invalidate_layer_bbox(
    layer: str,
    affected_bbox: tuple,
    redis: aioredis.Redis,
) -> int:
    """
    Purge all cache keys whose tag Set covers affected_bbox.
    Returns the number of keys removed.

    For updates that span multiple grid cells, compute all affected
    tag keys and call this function once per cell (or fan out with
    asyncio.gather for large updates).
    """
    tkey = tag_key(layer, affected_bbox)
    keys = await redis.smembers(tkey)
    if not keys:
        return 0

    # UNLINK is non-blocking (background thread); DEL would block the event loop
    async with redis.pipeline(transaction=False) as pipe:
        pipe.unlink(*keys)    # async key deletion — does not block Redis
        pipe.delete(tkey)     # remove the tag Set itself
        await pipe.execute()

    return len(keys)

@app.post("/api/features/{feature_id}")
async def update_feature(
    feature_id: int,
    layer: str = Query(...),
    minx: float = Query(...), miny: float = Query(...),
    maxx: float = Query(...), maxy: float = Query(...),
    redis: aioredis.Redis = Depends(get_redis),
):
    bbox = normalize_bbox(minx, miny, maxx, maxy)
    # ... persist geometry to PostGIS here ...
    removed = await invalidate_layer_bbox(layer, bbox, redis)
    return {"invalidated_keys": removed}

Key Parameters & Options

ParameterDefaultEffect
PRECISION4Decimal places for coordinate rounding. 4 ≈ 11 m; 5 ≈ 1 m. Lower values increase hit rate but risk boundary mismatches.
GRID_SIZE1.0 (degrees)Width/height of each tag cell. Smaller grids reduce over-invalidation but create more tag Sets. Use 0.1 for city-scale datasets, 1.0 for regional, 5.0 for continental.
CACHE_TTL3600 sFallback TTL applied to every cache entry. Caps stale exposure if a mutation event is missed.
TAG_TTL5400 sTTL applied to the tag Set itself. Should exceed CACHE_TTL so entries always expire before their tag Set disappears.
transaction=FalsePipelines without MULTI/EXEC. Correct here because we do not need rollback semantics; removing it avoids the round-trip cost of MULTI.
UNLINK vs DELUNLINK preferredUNLINK defers memory reclamation to a background thread. Use DEL only if you need guaranteed synchronous deletion (rarely needed in production).

Gotchas & Failure Modes

  • Orphaned tag Sets after missed mutations. If a geometry update bypasses invalidate_layer_bbox (e.g. a direct SQL UPDATE outside the API), the tag Set persists and stale cache keys remain live until CACHE_TTL expires. Always apply TAG_TTL on the tag Set and CACHE_TTL on every cache entry as independent safety nets. Never rely on invalidation alone.

  • Grid boundary splits. A bbox that straddles a grid cell boundary registers only in the cell containing its center. A geometry update in the adjacent cell will not purge it. For datasets with frequent edits near grid lines, reduce GRID_SIZE or use a multi-cell registration strategy: compute all grid cells that intersect the bbox and SADD the cache key into every relevant tag Set.

  • Tag Set memory growth under high write volume. Each SADD call adds one string entry to the Set. Under sustained load, a popular grid cell can accumulate tens of thousands of entries. Monitor with MEMORY USAGE tag:bbox:* and track SCARD on hot tag Sets. If a Set exceeds ~10k members, the invalidation pipeline stalls noticeably — consider sharding by zoom level or adding a secondary expiry sweep.

  • Race between cache write and invalidation. In a concurrent environment, a mutation event can arrive between the PostGIS query and the pipeline.execute() call that writes the cache entry. The new cache entry contains stale data but lacks its tag registration, so it will never be invalidated by tag. Mitigate by adding a short CACHE_TTL (60–300 s) for data that mutates frequently, so any stale window is bounded.

  • pipeline(transaction=False) does not guarantee atomicity. Commands in a non-transactional pipeline are sent in bulk but can be interrupted if the connection drops mid-flight. If SETEX succeeds but SADD does not, the cache key is live but untagged. Detect this via a periodic reconciliation job that scans cache:bbox:* keys and checks each one against its expected tag Set.

Verification Snippet

After deploying, confirm the tag mechanism works end-to-end:

# 1. Make a cacheable request
curl -s "http://localhost:8000/api/features?layer=roads&minx=-0.1278&miny=51.5074&maxx=-0.0978&maxy=51.5274"
# Expect: {"source": "db", ...}

# 2. Confirm cache entry exists
redis-cli GET "cache:bbox:roads:-0.1278:51.5074:-0.0978:51.5274"

# 3. Confirm tag Set membership
redis-cli SMEMBERS "tag:bbox:roads:-1.0:51.0"
# Expect: 1) "cache:bbox:roads:-0.1278:51.5074:-0.0978:51.5274"

# 4. Trigger invalidation via mutation route
curl -s -X POST "http://localhost:8000/api/features/42?layer=roads&minx=-0.1278&miny=51.5074&maxx=-0.0978&maxy=51.5274"
# Expect: {"invalidated_keys": 1}

# 5. Confirm the cache key is gone
redis-cli GET "cache:bbox:roads:-0.1278:51.5074:-0.0978:51.5274"
# Expect: (nil)

# 6. Next request re-populates from PostGIS
curl -s "http://localhost:8000/api/features?layer=roads&minx=-0.1278&miny=51.5074&maxx=-0.0978&maxy=51.5274"
# Expect: {"source": "db", ...}

To verify tag Set TTL is set correctly:

redis-cli TTL "tag:bbox:roads:-1.0:51.0"
# Expect: a positive integer close to TAG_TTL (5400)

← Back to Redis Caching for Spatial Queries