Idempotent Consumers for Out-of-Order Spatial Events
To make a spatial consumer safe under at-least-once delivery and out-of-order arrival, drop exact redeliveries with an idempotency-key check, then apply every feature update as a conditional upsert that overwrites the stored row only when the incoming observed_at is strictly newer than what is already persisted. Ordering by observation time rather than arrival time guarantees that a delayed, stale feature update can never clobber a newer geometry, no matter what order the broker hands the events to you.
This page sits within Delivery Guarantees & Event Ordering, part of the broader Queue Management, Retries & Delivery Guarantees reference for building spatial webhook pipelines that survive redelivery and reordering.
When to use this pattern
Reach for a version-aware conditional upsert when:
- Your broker gives at-least-once delivery (Kafka, Redis Streams, SQS, RabbitMQ), so any event may arrive more than once, and reprocessing must be safe.
- Events for the same feature can arrive out of order — because of retries, partition rebalances, multiple producers, or a slow consumer catching up after lag — and the newest state must always win.
- You maintain a current-state table (the latest known geometry and attributes per feature), not just an append-only log, so a stale write would visibly corrupt what users see.
It is not the right tool when you genuinely need a full event history rather than a latest-value projection — there, append every event to an immutable log keyed by (feature_id, version) and compute the current state at read time. It is also unnecessary when your broker provides strict total ordering and exactly-once semantics end to end, which almost no real webhook pipeline does.
Why out-of-order delivery corrupts spatial state
At-least-once delivery and ordering are two separate guarantees, and most pipelines have neither for free. A feature — a parcel boundary, a vehicle position, a flood-zone polygon — emits an update at 12:00:00 and a correction at 12:00:03. The 12:00:00 event hits a transient failure, gets retried, and lands after the 12:00:03 event. If your consumer blindly writes whatever it receives, the last message processed wins, and the stored geometry silently reverts to the older, wrong shape. Nothing errors; the map is just quietly stale.
The fix is to stop trusting arrival order and start trusting an ordering token carried inside each event — a per-feature version counter or, more commonly for sensor data, the source observed_at timestamp. The consumer compares that token against what is already stored and applies the write only if it is strictly newer. This is a form of last-writer-wins conflict resolution where “last” means newest observation, not newest arrival; the deeper trade-offs of that choice are covered in Conflict Resolution Strategies.
Complete runnable implementation
The consumer below is self-contained and async. It reads events from an in-memory queue (swap in your Kafka or Redis Streams client), performs a Redis idempotency check to drop exact duplicates, then issues a single-statement PostGIS conditional upsert that persists the feature only when the incoming observation is strictly newer. Geometry arrives as GeoJSON in EPSG:4326 (WGS84) per RFC 7946 and is handed to PostGIS via ST_GeomFromGeoJSON. The idempotency key is assumed to be a content digest computed upstream (see the related links); here we only check and record it.
import asyncio
import json
from dataclasses import dataclass
from datetime import datetime, timezone
import asyncpg
import redis.asyncio as redis
# --- Event model -----------------------------------------------------------
@dataclass(frozen=True)
class FeatureEvent:
feature_id: str # stable identity of the real-world feature
idempotency_key: str # content digest, unique per exact payload
observed_at: datetime # SOURCE observation time (NOT arrival time)
geometry: dict # GeoJSON geometry, EPSG:4326 (WGS84), RFC 7946
properties: dict # non-spatial attributes
# --- Schema (run once) -----------------------------------------------------
# CREATE TABLE features (
# feature_id text PRIMARY KEY,
# observed_at timestamptz NOT NULL,
# geom geometry(Geometry, 4326) NOT NULL,
# properties jsonb NOT NULL DEFAULT '{}'::jsonb,
# updated_at timestamptz NOT NULL DEFAULT now()
# );
UPSERT_SQL = """
INSERT INTO features (feature_id, observed_at, geom, properties)
VALUES ($1, $2, ST_SetSRID(ST_GeomFromGeoJSON($3), 4326), $4::jsonb)
ON CONFLICT (feature_id) DO UPDATE
SET observed_at = EXCLUDED.observed_at,
geom = EXCLUDED.geom,
properties = EXCLUDED.properties,
updated_at = now()
-- The guard: only overwrite when the incoming observation is STRICTLY
-- newer. A stale (older) or tied event leaves the stored row untouched.
WHERE features.observed_at < EXCLUDED.observed_at
RETURNING feature_id;
"""
async def is_duplicate(r: redis.Redis, key: str, ttl_seconds: int = 259200) -> bool:
"""Return True if this exact payload was already seen (at-least-once guard).
SET NX is atomic: the first caller sets the key and gets True back from
Redis; any concurrent or later redelivery finds the key present and is
told it is a duplicate. TTL should cover the broker's redelivery window
(72h default here).
"""
# `set(..., nx=True)` returns True only if the key did NOT exist.
was_set = await r.set(f"idem:{key}", "1", nx=True, ex=ttl_seconds)
return not was_set
async def handle_event(pool: asyncpg.Pool, r: redis.Redis, ev: FeatureEvent) -> str:
"""Process one event idempotently and order-safely. Returns the outcome."""
# 1. Drop exact redeliveries before touching the database.
if await is_duplicate(r, ev.idempotency_key):
return "duplicate"
# 2. Conditional upsert: newest observation wins, regardless of arrival order.
async with pool.acquire() as conn:
row = await conn.fetchrow(
UPSERT_SQL,
ev.feature_id,
ev.observed_at,
json.dumps(ev.geometry),
json.dumps(ev.properties),
)
# RETURNING yields a row only when the WHERE guard passed (insert or newer update).
return "applied" if row is not None else "stale"
async def consume(pool: asyncpg.Pool, r: redis.Redis, queue: asyncio.Queue) -> None:
"""Main consumer loop. Acknowledge on every outcome so the broker
does not redeliver a message we have already reasoned about."""
while True:
ev, ack = await queue.get()
try:
outcome = await handle_event(pool, r, ev)
print(f"{ev.feature_id} @ {ev.observed_at.isoformat()} -> {outcome}")
ack() # duplicate, stale, and applied are all terminal — ack all three
except Exception:
# Do NOT ack: let the broker redeliver. The idempotency + version
# guards make redelivery safe, so transient errors self-heal.
raise
finally:
queue.task_done()
async def main() -> None:
pool = await asyncpg.create_pool(dsn="postgresql://localhost/geo")
r = redis.from_url("redis://localhost/0")
queue: asyncio.Queue = asyncio.Queue()
# Enqueue an OUT-OF-ORDER pair: the newer correction arrives FIRST.
t_new = datetime(2026, 7, 13, 12, 0, 3, tzinfo=timezone.utc)
t_old = datetime(2026, 7, 13, 12, 0, 0, tzinfo=timezone.utc)
events = [
FeatureEvent("parcel-42", "key-new", t_new,
{"type": "Point", "coordinates": [-73.9857, 40.7484]}, {"v": 2}),
FeatureEvent("parcel-42", "key-old", t_old,
{"type": "Point", "coordinates": [-73.9800, 40.7400]}, {"v": 1}),
]
for ev in events:
await queue.put((ev, lambda: None))
worker = asyncio.create_task(consume(pool, r, queue))
await queue.join()
worker.cancel()
await pool.close()
await r.aclose()
if __name__ == "__main__":
asyncio.run(main())
The entire ordering decision lives in one line of SQL — WHERE features.observed_at < EXCLUDED.observed_at — evaluated atomically by PostgreSQL under row locking. There is no read-then-write race: concurrent consumers cannot both read a stale observed_at and both decide to write, because the conditional update is a single statement.
Parameter reference
| Parameter | Type | Spatial constraint | Default |
|---|---|---|---|
feature_id |
str |
Stable identity of the real-world feature; the per-feature ordering scope. Not the geometry hash | — |
idempotency_key |
str |
Content digest of the exact payload; two distinct events at one feature must differ | — |
observed_at |
timestamptz |
Source observation time in UTC; the comparison token. Must be monotonic per feature_id |
— |
geometry |
dict (GeoJSON) |
Must be EPSG:4326 (WGS84) per RFC 7946; reproject upstream before ingest | — |
ttl_seconds |
int |
Redis idempotency TTL; set ≥ broker’s max redelivery window | 259200 (72h) |
SRID in ST_SetSRID |
int |
Must match the column’s declared SRID (4326) or PostGIS raises a mixed-SRID error |
4326 |
Gotchas and spatial edge cases
-
Wall-clock arrival time is not observation time. Never order by
received_atornow(). A stale event delayed by a retry arrives later but describes an earlier state; comparing arrival times would let it win. Compare only the source-stampedobserved_at(or a sourceversion), which travels with the payload and reflects when the feature actually held that shape. -
Ties are silently dropped by
<. With a strict less-than guard, two events sharing an identicalobserved_atnever overwrite each other — first write wins, stably. That is usually correct. If you must break ties deterministically (e.g. two producers stamping the same second), compare a composite(observed_at, sequence)and guard onfeatures.observed_at < EXCLUDED.observed_at OR (equal AND features.seq < EXCLUDED.seq). -
A missing version field defeats the whole scheme. If some producers omit
observed_at, you cannot order their events. Reject such events to a dead-letter queue rather than defaulting tonow()— defaulting silently reintroduces arrival-order semantics and the stale-overwrite bug you are trying to kill. -
Monotonicity is per feature, not global. Each
feature_idneeds its own increasing timeline; there is no requirement that feature A’s clock relate to feature B’s. Do not force a global sequence — it collapses all traffic onto one partition and destroys throughput. Kafka only orders within a partition, so key your topic byfeature_id(or its H3 cell) to keep each feature’s events on one partition. -
Late create after update (the phantom row). If a
createfor a feature is delayed and its laterupdatearrives first, theINSERT ... ON CONFLICTstill does the right thing: the update inserts the row, and the delayed create — carrying an olderobserved_at— hits theWHEREguard and is skipped. The newest state survives even when “create” loses the race, provided the create carries the correct earlierobserved_at. -
SRID must match the column. Feeding a geometry parsed without an SRID, or in a different CRS such as EPSG:3857 (Web Mercator), into a
geometry(Geometry, 4326)column raisesOperation on mixed SRID geometries. Always wrap withST_SetSRID(..., 4326)and reproject non-WGS84 sources before they reach the consumer. -
Idempotency TTL shorter than the retry window reopens the gap. If Redis expires the key before the broker stops retrying, a very late redelivery is treated as new. It is still order-safe (the version guard catches it), but you lose the cheap duplicate-drop and the duplicate-rate metric. Size the TTL to exceed your provider’s maximum retry horizon.
Verification
This pytest exercises the core invariant: an out-of-order pair — newer event processed first, older event second — must converge to the newer geometry. It uses stub stores so it runs without a live database or Redis, isolating the ordering logic.
import pytest
from datetime import datetime, timezone
class FakeFeatureStore:
"""Emulates the PostGIS conditional upsert: newest observed_at wins."""
def __init__(self):
self.rows = {}
def upsert_if_newer(self, feature_id, observed_at, geom):
existing = self.rows.get(feature_id)
if existing is None or existing["observed_at"] < observed_at:
self.rows[feature_id] = {"observed_at": observed_at, "geom": geom}
return "applied"
return "stale" # strict '<' => older OR tied is skipped
class FakeIdem:
def __init__(self):
self.seen = set()
def is_duplicate(self, key):
if key in self.seen:
return True
self.seen.add(key)
return False
def process(store, idem, feature_id, key, observed_at, geom):
if idem.is_duplicate(key):
return "duplicate"
return store.upsert_if_newer(feature_id, observed_at, geom)
T_OLD = datetime(2026, 7, 13, 12, 0, 0, tzinfo=timezone.utc)
T_NEW = datetime(2026, 7, 13, 12, 0, 3, tzinfo=timezone.utc)
GEOM_OLD = {"type": "Point", "coordinates": [-73.9800, 40.7400]}
GEOM_NEW = {"type": "Point", "coordinates": [-73.9857, 40.7484]}
def test_out_of_order_converges_to_newest():
"""Newer event arrives first, stale event second: newest geometry must win."""
store, idem = FakeFeatureStore(), FakeIdem()
assert process(store, idem, "p-42", "k2", T_NEW, GEOM_NEW) == "applied"
assert process(store, idem, "p-42", "k1", T_OLD, GEOM_OLD) == "stale"
assert store.rows["p-42"]["geom"] == GEOM_NEW # stale did NOT overwrite
def test_in_order_also_converges():
"""Same two events in natural order converge to the same final state."""
store, idem = FakeFeatureStore(), FakeIdem()
assert process(store, idem, "p-42", "k1", T_OLD, GEOM_OLD) == "applied"
assert process(store, idem, "p-42", "k2", T_NEW, GEOM_NEW) == "applied"
assert store.rows["p-42"]["geom"] == GEOM_NEW
def test_exact_redelivery_is_dropped():
"""At-least-once: the same key twice is a no-op the second time."""
store, idem = FakeFeatureStore(), FakeIdem()
assert process(store, idem, "p-42", "k2", T_NEW, GEOM_NEW) == "applied"
assert process(store, idem, "p-42", "k2", T_NEW, GEOM_NEW) == "duplicate"
def test_tie_does_not_overwrite():
"""Equal observation times: first write wins, tie is skipped."""
store, idem = FakeFeatureStore(), FakeIdem()
process(store, idem, "p-42", "a", T_NEW, GEOM_NEW)
assert process(store, idem, "p-42", "b", T_NEW, GEOM_OLD) == "stale"
assert store.rows["p-42"]["geom"] == GEOM_NEW
Both orderings land on GEOM_NEW, which is the definition of convergence: the final state is independent of delivery order and delivery count.
FAQ
Should I order events by wall-clock arrival time or by observation time?
Always by observation time — the moment the sensor or source system recorded the feature state. Wall-clock arrival time reflects network and queue delays, so a stale event that was delayed can arrive after a fresh one. Ordering by received_at would then let the stale event win. Use a source-supplied version or observed_at as the comparison token.
What happens when two events for the same feature have identical observation times?
A strict greater-than comparison treats ties as no-ops: neither overwrites the other, so whichever landed first wins and the result is stable and deterministic. If ties must be broken deterministically, add a secondary tiebreaker such as a monotonic sequence number or the idempotency key, and compare the pair (observed_at, seq) lexicographically.
Do I still need an idempotency key if I already compare versions?
Yes. Version comparison makes a redelivered event a harmless no-op at the database, but it still costs a round trip and can mask logic bugs. A Redis idempotency check drops exact duplicates before they reach the database, which is cheaper and gives you a clean duplicate-rate metric. The two mechanisms cover different failure modes and are complementary.
Why must monotonicity be per feature rather than global?
Independent features change independently, and a partitioned broker gives you ordering guarantees only within a partition, not across the whole topic. Requiring a single global sequence would force all events through one partition and destroy throughput. Track the latest version per feature_id so each feature has its own monotonic timeline while features remain independent and parallelisable.
Related
- Delivery Guarantees & Event Ordering — parent: the full picture of at-least-once, exactly-once, and ordering for spatial streams
- Conflict Resolution Strategies — deciding which write wins when versions collide, beyond simple last-writer-wins
- Cache-Backed Idempotency Checks — the Redis store and atomic
SET NXpattern behind the duplicate-drop step