The Claim-Check Pattern for Oversized Spatial Payloads
Write the body to object storage under a key derived from its own SHA-256, publish the envelope only after that write returns, verify the digest after every read, and give the bucket a longer lifecycle than the topic’s retention — the ordering is what stops a fast consumer resolving a key that does not exist yet.
This guide sits under Streaming & Chunking Large Geometry Payloads, within Spatial Payload Routing & Parsing. That topic compares the three strategies; this one is the implementation detail of the recommended default.
When to use this pattern
- Geometry size has a long upper tail, so a per-message limit is met occasionally rather than constantly.
- Consumers are geographically sharded and discard most events, which is where lazy resolution pays.
- The event must stay atomic — one message, one event — which chunking cannot provide.
The three ordering rules
Complete runnable implementation
import hashlib
import json
from dataclasses import asdict, dataclass
from shapely.geometry import shape
INLINE_LIMIT = 256 * 1024
class BodyMismatch(Exception):
"""The stored body is not the one the envelope described."""
@dataclass(slots=True)
class Envelope:
feature_id: str
occurred_at: str
crs: str # always explicit
bbox: tuple[float, float, float, float] # stays here — see gotcha 1
vertex_count: int
geometry: dict | None = None
body_key: str | None = None
body_sha256: str | None = None
body_bytes: int | None = None
async def publish(geometry: dict, feature_id: str, occurred_at: str,
store, broker) -> Envelope:
geom = shape(geometry)
body = json.dumps(geometry, separators=(",", ":")).encode()
env = Envelope(
feature_id=feature_id, occurred_at=occurred_at, crs="EPSG:4326",
bbox=geom.bounds, vertex_count=_count_vertices(geom),
)
if len(body) < INLINE_LIMIT:
env.geometry = geometry
else:
digest = hashlib.sha256(body).hexdigest()
# Content-addressed: a retry writes identical bytes to the same key,
# so the write is idempotent and needs no coordination.
env.body_key = f"geom/{digest[:2]}/{digest}.json"
env.body_sha256, env.body_bytes = digest, len(body)
await store.put(env.body_key, body) # FIRST
await broker.send(json.dumps(asdict(env)).encode()) # SECOND
return env
async def resolve(env: Envelope, store, area_of_interest=None) -> dict | None:
"""Fetch the body only if this event is relevant, then verify it."""
# Lazy resolution: most of the saving is the events never fetched at all.
if area_of_interest is not None and not _intersects(env.bbox, area_of_interest):
return None
if env.geometry is not None:
return env.geometry
body = await store.get(env.body_key)
# A truncated read of a FeatureCollection frequently parses: it ends
# after a complete feature and yields a valid, smaller collection. The
# length and digest checks are the only things that catch it.
if len(body) != env.body_bytes:
raise BodyMismatch(f"{env.body_key}: {len(body)} != {env.body_bytes}")
if hashlib.sha256(body).hexdigest() != env.body_sha256:
raise BodyMismatch(f"{env.body_key}: digest mismatch")
return json.loads(body)
async def sweep_orphans(store, broker_retention_seconds: int, referenced) -> int:
"""Delete bodies no event references, once replay can no longer need them.
Sweeping earlier than the topic's retention deletes objects that a replay
would still resolve, which converts a harmless orphan into a broken event.
"""
removed = 0
async for key, age_seconds in store.list_with_age("geom/"):
if age_seconds > broker_retention_seconds and key not in referenced:
await store.delete(key)
removed += 1
return removed
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
INLINE_LIMIT |
int |
Chosen so the 99th percentile of measured sizes stays inline | 262144 |
body_key |
str |
sha256(body) — content-addressed, so retries are idempotent |
— |
body_sha256 |
str |
Verified after every read; a truncated collection still parses | — |
bbox |
tuple | Must stay in the envelope, or lazy resolution is impossible | — |
| Bucket lifecycle | days | > topic retention and any replayable archive | 14 |
| Orphan sweep age | seconds | Same boundary, from the other direction | retention |
Gotchas and spatial edge cases
-
Moving the bounding box into the body destroys the pattern’s main benefit. Lazy resolution is why a geographically sharded consumer costs almost nothing: it discards most events without a storage round trip. With the bbox in the body, every consumer fetches every object to learn the event was irrelevant, and the claim check becomes strictly worse than inlining.
-
A truncated body can parse successfully. A FeatureCollection cut after a complete feature is a valid collection with fewer features, so
json.loadssucceeds and a coverage calculation quietly reports a smaller area. The length check catches it; the digest check catches the case where the length is right and the content is not. -
The key must not encode anything mutable. Putting a date or a tenant name in the key path means two producers with different clocks or different configuration write the same body twice, and the idempotence the content address provides is lost. The digest prefix used for path sharding is fine because it derives from the body.
-
Presigned URLs expire, and replay is exactly when they have. If envelopes carry presigned fetch URLs rather than keys, a replay a week later resolves URLs that are no longer valid. Carry the key and let the consumer sign at fetch time.
-
The store’s consistency model matters. Most object stores are now strongly consistent for new writes, but a read-after-write against a stale replica returns not-found even with correct ordering. Treat a not-found on a freshly published envelope as retryable and a not-found on an old one as terminal, and use the envelope’s timestamp to tell them apart.
-
The digest cannot double as the idempotency key. Two genuinely different events can carry an identical geometry — the same boundary re-published by two sources — and they hash the same. Event identity comes from Event Key Generation for Spatial Data; the digest identifies bytes.
Verification
import json
import pytest
from shapely.geometry import MultiPolygon, Polygon, mapping
def big() -> dict:
return mapping(MultiPolygon([
Polygon([(i, j), (i, j + 0.9), (i + 0.9, j + 0.9), (i + 0.9, j)])
for i in range(120) for j in range(120)
]))
@pytest.mark.asyncio
async def test_store_happens_before_publish(store, broker):
"""The ordering rule, asserted against the recorded call sequence."""
await publish(big(), "f-1", "2026-08-08T10:00:00Z", store, broker)
assert store.calls[0][0] == "put"
assert store.calls[0][1] < broker.calls[0][1] # timestamps
@pytest.mark.asyncio
async def test_envelope_stays_small_and_routable(store, broker):
env = await publish(big(), "f-2", "2026-08-08T10:00:00Z", store, broker)
assert len(broker.sent[-1]) < 2048
assert env.bbox is not None and env.vertex_count > 0
@pytest.mark.asyncio
async def test_irrelevant_event_is_never_fetched(store, broker):
"""Where the saving actually comes from."""
env = await publish(big(), "f-3", "2026-08-08T10:00:00Z", store, broker)
before = store.reads
assert await resolve(env, store, area_of_interest=(200, 200, 210, 210)) is None
assert store.reads == before
@pytest.mark.asyncio
async def test_truncated_body_is_rejected(store, broker):
env = await publish(big(), "f-4", "2026-08-08T10:00:00Z", store, broker)
store.truncate(env.body_key, keep=env.body_bytes // 2)
with pytest.raises(BodyMismatch):
await resolve(env, store)
@pytest.mark.asyncio
async def test_retry_writes_the_same_key(store, broker):
"""Content addressing makes the producer retry harmless."""
a = await publish(big(), "f-5", "2026-08-08T10:00:00Z", store, broker)
b = await publish(big(), "f-5", "2026-08-08T10:00:00Z", store, broker)
assert a.body_key == b.body_key
The third test is the one that keeps the pattern honest. It fails the moment someone moves the bounding box into the body for tidiness, and that change would otherwise show up only as a slow rise in storage read costs that nobody attributes to a schema edit.
Related
- Streaming & Chunking Large Geometry Payloads — the topic this guide belongs to, and how the three strategies compare
- Chunking a Multipolygon Across Message Size Limits — the alternative, and the three failure modes it adds
- Streaming GeoJSON with ijson in Async Consumers — reading the referenced body without materialising it
- Replaying Dead-Letter Spatial Events Safely — the operation that finds a retention mismatch