Using a Transactional Outbox for Spatial Events
Write the feature row and the outbox row in the same PostGIS transaction, keep the geometry out of the outbox and reference it by id and version, then relay with FOR UPDATE SKIP LOCKED — publishing before marking sent gives at-least-once delivery, which is the guarantee the consumer’s deduplication already assumes.
This guide sits under Delivery Guarantees & Ordering, within Queue Management, Retries & Delivery Guarantees. It is the explicit alternative to Capturing PostGIS Changes with Logical Replication, and the two catch different sets of changes.
When to use this pattern
- The application is the only writer that matters, so an application-level record catches every change worth publishing.
- Events need intent — an event type, a routing key, a schema version — that a decoded row does not carry.
- Operating a replication slot is undesirable, which for a managed database with restricted permissions it often is.
Two failure modes, in opposite directions
Complete runnable implementation
CREATE TABLE feature_outbox (
id bigserial PRIMARY KEY,
feature_id bigint NOT NULL,
feature_version int NOT NULL, -- what the relay must publish
event_type text NOT NULL, -- intent: created | moved | retired
routing_key text NOT NULL, -- H3 cell, computed at write time
schema_version int NOT NULL DEFAULT 2,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
-- Deliberately NO geometry column: an outbox row carrying a full
-- multipolygon doubles the write volume of every edit and makes this
-- table larger than the one it serves.
);
-- The relay only ever scans unpublished rows, so index for exactly that.
CREATE INDEX feature_outbox_unpublished
ON feature_outbox (id) WHERE published_at IS NULL;
import json
from datetime import datetime, UTC
import h3
from prometheus_client import Counter, Gauge
RELAYED = Counter("outbox_rows_relayed_total", "Rows published", ("event_type",))
BACKLOG = Gauge("outbox_unpublished_rows", "Unpublished outbox rows")
BATCH_SIZE = 200
async def edit_feature(conn, feature_id: int, geometry: dict,
event_type: str) -> None:
"""Write the feature and its outbox row in ONE transaction.
Either both are durable or neither is. That is the whole pattern; every
other detail here is about cost.
"""
async with conn.transaction():
version = await conn.fetchval(
"""
UPDATE features
SET geom = ST_GeomFromGeoJSON($2),
version = version + 1,
updated_at = now()
WHERE id = $1
RETURNING version
""",
feature_id, json.dumps(geometry),
)
centroid = await conn.fetchrow(
"SELECT ST_Y(ST_Centroid(geom)) AS lat, ST_X(ST_Centroid(geom)) AS lon "
"FROM features WHERE id = $1",
feature_id,
)
routing_key = h3.latlng_to_cell(centroid["lat"], centroid["lon"], 8)
await conn.execute(
"""
INSERT INTO feature_outbox
(feature_id, feature_version, event_type, routing_key)
VALUES ($1, $2, $3, $4)
""",
feature_id, version, event_type, routing_key,
)
async def relay(conn, publish) -> int:
"""Publish unsent rows. Safe to run in several processes at once."""
async with conn.transaction():
rows = await conn.fetch(
"""
SELECT o.id, o.feature_id, o.feature_version, o.event_type,
o.routing_key, o.schema_version,
ST_AsGeoJSON(f.geom) AS geometry, f.version AS current_version
FROM feature_outbox o
JOIN features f ON f.id = o.feature_id
WHERE o.published_at IS NULL
ORDER BY o.id
LIMIT $1
FOR UPDATE OF o SKIP LOCKED
""",
BATCH_SIZE,
)
for row in rows:
# The relay reads whatever version is CURRENT, which may be newer
# than the row's. Carrying both lets a consumer tell that it is
# seeing a later state than the event described.
await publish({
"schema_version": row["schema_version"],
"action": row["event_type"],
"feature_id": str(row["feature_id"]),
"event_version": row["feature_version"],
"geometry_version": row["current_version"],
"routing_key": row["routing_key"],
"crs": "EPSG:4326",
"geometry": json.loads(row["geometry"]),
"occurred_at": datetime.now(UTC).isoformat(),
})
RELAYED.labels(event_type=row["event_type"]).inc()
# Marked sent AFTER publishing. A crash between the two republishes
# on the next pass — at-least-once, which the consumer already expects.
if rows:
await conn.execute(
"UPDATE feature_outbox SET published_at = now() WHERE id = ANY($1)",
[r["id"] for r in rows],
)
return len(rows)
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
feature_version |
int |
The version the event describes; compared against current at relay | — |
routing_key |
text |
H3 cell computed at write time, from the geometry as it then was | — |
| Geometry column | — | Absent by design; relay joins the feature table | — |
BATCH_SIZE |
int |
Ordering holds within a batch only | 200 |
SKIP LOCKED |
— | Required for more than one relay; without it they serialise or duplicate | — |
| Partial index | — | On id WHERE published_at IS NULL — the only query the relay runs |
— |
Gotchas and spatial edge cases
-
The relay publishes the current geometry, not the one at the time of the edit. Two rapid edits mean the first event carries the second edit’s shape. Carrying both
event_versionandgeometry_versionlets a consumer detect it; storing the geometry in the outbox would avoid it entirely, at a write cost most pipelines will not pay. -
Computing the routing key at write time is deliberate. A feature that moves between cells would otherwise be routed by wherever it ended up rather than where the edit happened, so a consumer subscribed to the origin area never learns the feature left. The same reasoning drives the previous-geometry field in Tile Update Event Pipelines.
-
Published rows must be deleted, not merely marked. An outbox retaining every event forever becomes the largest table in the database and slows the partial index scan through sheer bloat. Delete on a schedule once the topic has the events, and keep the deletion window longer than any replay you might run.
-
A stalled relay is invisible without a backlog metric. The application keeps working — writes succeed, transactions commit — while nothing reaches consumers. The
outbox_unpublished_rowsgauge and an age-of-oldest-unpublished-row metric are the only signals, and they belong in the freshness objective described in SLOs & Alerting for Spatial Webhook Pipelines. -
The outbox misses every writer that is not the application. A bulk import, a migration or a desktop GIS client editing the table directly produces no outbox row, and the pipeline silently has no idea those features changed. If that is a real risk, logical replication is the pattern that catches them.
-
ST_Centroidof a multipolygon can fall outside the feature. For a routing key that is usually acceptable, but for a crescent-shaped or multi-part feature the centroid can land in a cell the feature does not touch. UseST_PointOnSurfacewhere the key must be inside the geometry.
Verification
import pytest
SQUARE = {"type": "Polygon", "coordinates": [[[13.40, 52.52], [13.40, 52.53],
[13.41, 52.53], [13.41, 52.52],
[13.40, 52.52]]]}
@pytest.mark.asyncio
async def test_rollback_leaves_no_outbox_row(conn):
"""The invented-event failure, asserted."""
with pytest.raises(RuntimeError):
async with conn.transaction():
await edit_feature(conn, 4471, SQUARE, "moved")
raise RuntimeError("simulated failure after the edit")
assert await conn.fetchval(
"SELECT count(*) FROM feature_outbox WHERE feature_id = 4471") == 0
@pytest.mark.asyncio
async def test_relay_crash_republishes_rather_than_losing(conn):
"""Publish-then-mark gives at-least-once, deliberately."""
await edit_feature(conn, 4471, SQUARE, "moved")
async def failing_publish(event):
raise ConnectionError("broker unavailable")
with pytest.raises(ConnectionError):
await relay(conn, failing_publish)
published = []
assert await relay(conn, published.append) == 1
assert published[0]["feature_id"] == "4471"
@pytest.mark.asyncio
async def test_two_relays_take_disjoint_batches(conn, conn2):
"""Without SKIP LOCKED this either blocks or double-publishes."""
for i in range(400):
await edit_feature(conn, 4471, SQUARE, "moved")
a, b = [], []
await asyncio.gather(relay(conn, a.append), relay(conn2, b.append))
ids_a = {e["event_version"] for e in a}
ids_b = {e["event_version"] for e in b}
assert not (ids_a & ids_b)
@pytest.mark.asyncio
async def test_outbox_row_carries_no_geometry(conn):
"""The cost decision, guarded so it does not drift back."""
columns = await conn.fetch(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'feature_outbox'")
assert not any("geom" in c["column_name"] for c in columns)
The second test is the one that documents the guarantee rather than the implementation: it asserts that a failed publish leads to a republish, which is what makes the consumer’s deduplication load-bearing rather than decorative.
Related
- Delivery Guarantees & Ordering — the topic this guide belongs to
- Capturing PostGIS Changes with Logical Replication — the implicit alternative, and the writers it catches that this does not
- Idempotent Consumers for Out-of-Order Spatial Events — absorbing the duplicates and the reordering this relay produces
- Partitioning Kafka Topics by H3 Cell — what the routing key computed at write time is for