Tile Update Event Pipelines
A tile update event pipeline takes a spatial mutation — a feature edit, a sensor burst, a boundary change — and drives it through ingestion, validation, partition-aware routing, and CDN broadcast so that map clients see the updated tile within milliseconds of the source change.
This topic is part of Core Event Fundamentals & Architecture, which covers the event modeling, delivery guarantees, and security patterns that underpin all real-time geospatial systems.
Prerequisites
Pipeline Architecture
The pipeline has four sequential layers. Each layer has a single responsibility and a defined failure exit so that a bad payload cannot travel further than one stage.
<svg viewBox=“0 0 760 220” xmlns=“http://www.w3.org/2000/svg” role=“img” aria-label=“Four-stage tile update event pipeline diagram” style=“width:100%;max-width:760px;height:auto;display:block;margin:1.5rem auto;”
Stage 1 — Ingestion & Verification: The edge endpoint authenticates the request with HMAC-SHA256, applies TLS termination, and writes accepted payloads to a raw ingestion topic with a monotonic timestamp and an upstream-derived idempotency key. Rejected requests return 401 Unauthorized immediately.
Stage 2 — Validation & Spatial Enrichment: A consumer reads from the raw topic, parses the payload with Pydantic, and validates geometry topology with shapely.is_valid. Bounding boxes, tile matrix set identifiers, and version stamps are computed and attached. Invalid events are written to a dead-letter queue without blocking the main stream.
Stage 3 — Partition Router: Enriched events are keyed by a composite zoom:quadkey string and routed to the appropriate partition. This guarantees strict ordering per tile region while allowing full parallelism across disjoint regions. When your architecture also ingests sensor telemetry alongside feature edits, sharing consumer groups with Sensor Data Routing Patterns can co-locate spatial and temporal streams efficiently.
Stage 4 — Tile Worker & Broadcast: Workers pull from their assigned partition, run tile regeneration, write outputs to the tile store, and publish a lightweight tile_updated event containing z, x, y, and a content hash to a WebSocket or Server-Sent Events channel. Clients perform a targeted cache bust rather than a full map reload.
Step-by-Step Implementation
Step 1 — Define the Canonical Event Model
Start by locking down the payload contract. Applying best practices for spatial event payload schemas at ingestion time prevents projection ambiguity and bounding-box errors from propagating through all downstream stages.
from pydantic import BaseModel, Field, field_validator
from typing import Literal
class TileUpdateEvent(BaseModel):
event_id: str = Field(description="Upstream mutation ID; drives idempotency key")
zoom: int = Field(ge=0, le=22)
x: int = Field(ge=0)
y: int = Field(ge=0)
# [min_lon, min_lat, max_lon, max_lat] — WGS84 (EPSG:4326)
bbox: tuple[float, float, float, float]
crs: str = Field(default="EPSG:4326")
version: int = Field(description="Monotonic counter; used for conflict resolution")
source: str
@field_validator("bbox")
@classmethod
def bbox_must_be_valid(cls, v: tuple) -> tuple:
min_lon, min_lat, max_lon, max_lat = v
if min_lon >= max_lon or min_lat >= max_lat:
raise ValueError("bbox min values must be strictly less than max values")
if not (-180 <= min_lon <= 180 and -180 <= max_lon <= 180):
raise ValueError("longitude out of WGS84 range")
if not (-90 <= min_lat <= 90 and -90 <= max_lat <= 90):
raise ValueError("latitude out of WGS84 range")
return v
All coordinate references in the model default to WGS84 (EPSG:4326). If upstream sources emit mixed projections, normalize them before writing to the raw ingestion topic, as described in CRS Normalization Strategies.
Step 2 — Validate Geometry Topology
Schema validity is necessary but not sufficient. A polygon that is topologically invalid — self-intersecting rings, incorrect winding order — will silently corrupt spatial operations downstream.
from shapely.geometry import shape
from shapely.validation import explain_validity
def validate_geometry(geojson_geom: dict) -> tuple[bool, str | None]:
"""
Returns (True, None) when geometry is valid.
Returns (False, reason) when invalid so callers can route to DLQ.
"""
try:
geom = shape(geojson_geom)
except Exception as exc:
return False, f"unparseable geometry: {exc}"
if not geom.is_valid:
return False, explain_validity(geom)
return True, None
Pair this check with the approach in Geometry Validation Pipelines when your pipeline receives raw WKT alongside GeoJSON, as different serialization formats have distinct validity edge cases.
Step 3 — Compute the Partition Key
Tile events must be partitioned so that all mutations to the same tile region land on the same consumer in order. A composite zoom:quadkey string is deterministic, human-readable in logs, and maps directly to the cache path your tile server uses.
The routing guarantee is two-sided: events sharing a zoom:quadkey are pinned to one partition and processed strictly in arrival order, while events for disjoint regions fan out across partitions and run fully in parallel.
<svg viewBox=“0 0 720 300” xmlns=“http://www.w3.org/2000/svg” role=“img” aria-label=“Diagram showing quadkey-based partition routing: same-region events stay ordered on one partition while disjoint regions run in parallel” style=“width:100%;max-width:720px;height:auto;display:block;margin:1.5rem auto;”
def quadkey(zoom: int, x: int, y: int) -> str:
"""Convert z/x/y to a Bing-style quadkey string."""
key = []
for i in range(zoom, 0, -1):
digit = 0
mask = 1 << (i - 1)
if x & mask:
digit += 1
if y & mask:
digit += 2
key.append(str(digit))
return "".join(key)
def partition_key(event: TileUpdateEvent) -> str:
qk = quadkey(event.zoom, event.x, event.y)
return f"{event.zoom}:{qk}"
For Kafka, use this string as the message key. For SQS FIFO queues, use it as the MessageGroupId. Downstream consumers then process each zoom:quadkey group sequentially without any cross-region serialization overhead.
Step 4 — Idempotent Async Consumer
The core worker loop uses SHA-256 of the upstream event_id as its idempotency key, following the hashing approach described in Generating Deterministic Idempotency Keys for GeoJSON Events. The guard lives in Redis so that all worker replicas share a consistent view.
import asyncio
import hashlib
import logging
from pydantic import ValidationError
# Stubs — replace with your Redis, tile renderer, and broker bindings
async def idempotency_guard_check(key: str) -> bool: ...
async def idempotency_guard_set(key: str) -> None: ...
async def regenerate_tile(zoom: int, x: int, y: int) -> str: ... # returns content hash
async def broadcast_tile_updated(event: TileUpdateEvent, content_hash: str) -> None: ...
async def write_to_dlq(raw: dict, reason: str) -> None: ...
async def process_tile_event(raw_payload: dict) -> None:
try:
event = TileUpdateEvent(**raw_payload)
except ValidationError as exc:
logging.error("Schema validation failed: %s", exc)
await write_to_dlq(raw_payload, str(exc))
return
ok, reason = validate_geometry(raw_payload.get("geometry", {})) if "geometry" in raw_payload else (True, None)
if not ok:
await write_to_dlq(raw_payload, f"invalid geometry: {reason}")
return
idem_key = hashlib.sha256(event.event_id.encode()).hexdigest()
if await idempotency_guard_check(idem_key):
logging.info("Duplicate skipped: event_id=%s", event.event_id)
return
content_hash = await regenerate_tile(event.zoom, event.x, event.y)
await broadcast_tile_updated(event, content_hash)
await idempotency_guard_set(idem_key)
async def run_consumer(broker_topic: str, concurrency: int = 10) -> None:
dlq: asyncio.Queue[dict] = asyncio.Queue()
semaphore = asyncio.Semaphore(concurrency)
async def bounded_process(payload: dict) -> None:
async with semaphore:
await process_tile_event(payload)
# Replace with your actual broker polling loop
async for raw in poll_broker(broker_topic): # type: ignore[name-defined]
asyncio.create_task(bounded_process(raw))
Use httpx.AsyncClient for CDN cache-purge calls and aiobotocore for S3/R2 writes so that the worker loop never blocks on I/O.
Spatial Validation & Error Handling
Four error conditions require explicit routing rather than silent discard:
- Signature mismatch — Return
401at the edge. Do not write to the broker at all. - Schema validation failure — Pydantic
ValidationErrorcaught inprocess_tile_event; written to the dead-letter queue with the raw payload and error detail for operator triage. - Geometry topology error —
shapely.is_validreturnsFalse; same DLQ path as schema failure, but tagged withreason=invalid_geometryso alerts can distinguish schema drift from bad source data. - Stale version — When
event.versionis lower than the last committed version for that tile, the event is acknowledged and discarded without triggering regeneration. This prevents older events from overwriting newer tiles during backpressure recovery.
Retry, Backoff & Delivery Guarantees
At-least-once delivery is the correct default for tile update pipelines. Exactly-once semantics require distributed transactions that impose latency incompatible with real-time tile invalidation, and the idempotency guard already makes at-least-once safe.
For transient regeneration failures — network errors, tile renderer timeouts — apply exponential backoff with full jitter:
import random
import asyncio
async def regenerate_with_backoff(
zoom: int,
x: int,
y: int,
max_attempts: int = 5,
base_delay: float = 0.5,
) -> str:
for attempt in range(max_attempts):
try:
return await regenerate_tile(zoom, x, y)
except Exception as exc:
if attempt == max_attempts - 1:
raise
delay = base_delay * (2 ** attempt) * random.uniform(0.5, 1.5)
logging.warning(
"Tile regeneration failed (attempt %d/%d), retrying in %.2fs: %s",
attempt + 1, max_attempts, delay, exc,
)
await asyncio.sleep(delay)
raise RuntimeError("unreachable") # satisfies type checker
After max_attempts, write the event to the dead-letter queue. Use the Cache-Backed Idempotency Checks pattern to ensure that a manual DLQ replay does not re-trigger completed regenerations.
Verification
Run this smoke test against a locally running worker to confirm the full path from raw payload to broadcast:
import asyncio
import pytest
@pytest.mark.asyncio
async def test_valid_event_triggers_broadcast(monkeypatch):
processed: list[TileUpdateEvent] = []
broadcast_calls: list[str] = []
async def mock_regenerate(zoom, x, y):
return "abc123"
async def mock_broadcast(event, content_hash):
processed.append(event)
broadcast_calls.append(content_hash)
async def mock_idem_check(key):
return False # not yet processed
async def mock_idem_set(key):
pass
monkeypatch.setattr("your_module.regenerate_tile", mock_regenerate)
monkeypatch.setattr("your_module.broadcast_tile_updated", mock_broadcast)
monkeypatch.setattr("your_module.idempotency_guard_check", mock_idem_check)
monkeypatch.setattr("your_module.idempotency_guard_set", mock_idem_set)
payload = {
"event_id": "evt-001",
"zoom": 12,
"x": 2048,
"y": 1024,
"bbox": (-74.01, 40.70, -73.97, 40.73),
"crs": "EPSG:4326",
"version": 1,
"source": "parcel-edit-service",
}
await process_tile_event(payload)
assert len(processed) == 1
assert broadcast_calls == ["abc123"]
@pytest.mark.asyncio
async def test_duplicate_event_is_skipped(monkeypatch):
regenerate_calls: list = []
async def mock_regenerate(zoom, x, y):
regenerate_calls.append((zoom, x, y))
return "abc123"
async def mock_idem_check(key):
return True # already processed
monkeypatch.setattr("your_module.regenerate_tile", mock_regenerate)
monkeypatch.setattr("your_module.idempotency_guard_check", mock_idem_check)
payload = {
"event_id": "evt-002",
"zoom": 10,
"x": 512,
"y": 256,
"bbox": (-74.01, 40.70, -73.97, 40.73),
"crs": "EPSG:4326",
"version": 3,
"source": "parcel-edit-service",
}
await process_tile_event(payload)
assert regenerate_calls == []
Troubleshooting
| Symptom | Likely Spatial Cause | Fix |
|---|---|---|
| Tiles flicker between old and new state | Out-of-order delivery overwriting newer versions | Add monotonic version field; skip events where incoming.version < committed.version |
| DLQ spike after upstream schema change | New field added without default in Pydantic model | Add Field(default=None) for optional fields; alert on DLQ rate > 1% of throughput |
| Hot partition bottlenecking one consumer | High-edit-frequency tile region landing on a single partition | Sub-partition by appending a fractional quadkey depth or route hot tiles to a dedicated topic |
| CDN cache not clearing after broadcast | Broadcast emitted before tile write completes | Write tile to store before publishing tile_updated; await storage confirmation |
| Duplicate tile regeneration on broker retry | Idempotency key TTL expired in Redis before retry window | Increase Redis TTL to at least 2× the broker’s retention window |
shapely.is_valid rejects polygon from trusted source |
Coordinate ring winding order inverted | Call shapely.geometry.polygon.orient(geom, sign=1.0) to normalize orientation |
FAQ
Should I partition by H3 cell or by z/x/y quadkey?
Use z/x/y quadkey when your downstream is an XYZ tile server — the partition key maps directly to the cache path, so routing and invalidation share the same identifier. H3 is better when you also run spatial analytics alongside tile rendering, because H3 cells support hierarchical aggregation at multiple resolutions without re-indexing.
How many partitions do I need?
Start with one partition per planned consumer thread, then monitor consumer lag per partition. Under-partitioning creates hot shards; over-partitioning inflates metadata overhead. A working rule: re-evaluate when any single partition sustains lag above 70% of broker retention window.
Can I skip the dead-letter queue for a small deployment?
Only if you are comfortable losing visibility into why events fail. Even in small deployments, a DLQ is the fastest way to detect upstream schema drift before it silently corrupts tile state.
How do I handle the same tile being invalidated by both a feature edit and a sensor burst simultaneously?
Process both events normally. The idempotency key is derived from each event’s own event_id, not from the tile coordinates, so two legitimate mutations from different sources produce two regeneration calls. The version guard ensures the second call overwrites the first only if it carries a higher version number.
Related
- Core Event Fundamentals & Architecture — parent section covering event modeling, delivery guarantees, and security contracts for spatial systems
- Best Practices for Spatial Event Payload Schemas — field definitions, CRS declarations, and bounding-box rules for tile update payloads
- Feature Change Triggers — how upstream GIS services detect and emit spatial mutations that feed this pipeline
- Sensor Data Routing Patterns — partition and consumer-group strategies for high-frequency telemetry streams alongside tile events
- Event Key Generation for Spatial Data — deterministic idempotency key derivation from GeoJSON feature hashes