Reconciling Divergent Spatial Domain Replicas

Two replicas of a spatial domain disagree eventually — a dropped tombstone, an out-of-order arrival applied by wall-clock, a rebuild from a compacted feed that resurrected deleted features. The disagreement is silent, because both replicas answer every query successfully and neither has any reason to suspect the other. This guide detects divergence cheaply, localises it to the features involved, and reconciles it deterministically so both replicas converge on the same answer rather than on whichever one was checked. It applies the ordering and compaction rules from Domain Sync Protocols for Spatial Data within Federated Ownership & Routing Architecture.

Prerequisites

Requirement Value / Assumption Notes
Tools PostGIS ≥ 3.3 on both replicas, python3 ≥ 3.11 Comparison runs against both
Feed Event-time and a stable feature identifier on every record Reconciliation needs a total order
CRS convention Both replicas store EPSG:4326 at the same declared precision A precision mismatch shows as universal divergence
Access roles Read on both replicas; write only through the feed Never repair a replica by hand
Environment REPLICA_A_DSN, REPLICA_B_DSN, PARTITION Exported before running

Step-by-Step Implementation

1. Detect divergence with a partition digest

Comparing feature by feature across two replicas is expensive. A per-partition digest reduces routine checking to one number each.

sql
-- partition_digest.sql — one comparable value per partition, per replica.
-- Parameters: :partition (text), :precision (int)
SELECT
    md5(string_agg(row_digest, '' ORDER BY feature_id)) AS partition_digest,
    count(*)                                            AS feature_count
FROM (
    SELECT
        feature_id,
        md5(
            feature_id
            -- Snap before hashing: two replicas may hold coordinates differing in
            -- noise digits below the declared precision, which is not divergence.
            || ST_AsBinary(ST_SnapToGrid(geom, 1.0 / power(10, :precision)))::text
            || coalesce(attrs::text, '')
            || coalesce(deleted_at::text, '')
        ) AS row_digest
    FROM domain_features
    WHERE partition_key = :'partition'
) rows;

Detect cheaply, localise narrowly, repair through the feedReconciliation runs in three stages of widening cost. A per-partition digest compares two replicas with one scan each and one value returned, cheap enough to run hourly across hundreds of partitions. Only partitions whose digests differ are localised to the specific features involved. Those features are resolved by event time and emitted back onto the feed, so every replica converges. The rail records the failure the feed path prevents: a replica repaired by direct write diverges again on its next rebuild, because the feed still holds whatever caused it.Digest per partitionone value each sideLocaliseonly where they differResolveby event timeEmit to the feedevery replica convergesdiffersfeaturesresolveddirect write insteadDiverges again on rebuildthe feed still holds the cause

Verify the digest is stable across repeated runs on one replica before comparing two:

bash
for i in 1 2; do
  psql "$REPLICA_A_DSN" --csv -f partition_digest.sql \
    -v partition="$PARTITION" -v precision=6 | tail -1
done
# Identical output. A digest that varies within one replica cannot detect anything.

2. Compare and localise only where digests differ

bash
#!/usr/bin/env bash
# diverged.sh — compare every partition, then localise only the ones that differ.
set -euo pipefail

for p in $(psql "$REPLICA_A_DSN" -At -c "SELECT DISTINCT partition_key FROM domain_features"); do
  a=$(psql "$REPLICA_A_DSN" -At -f partition_digest.sql -v partition="$p" -v precision=6 | cut -d'|' -f1)
  b=$(psql "$REPLICA_B_DSN" -At -f partition_digest.sql -v partition="$p" -v precision=6 | cut -d'|' -f1)
  [ "$a" = "$b" ] && continue
  echo "DIVERGED: $p"
done
sql
-- localise.sql — the specific features that differ, for one diverged partition.
-- Run with replica B attached as a foreign schema.
SELECT
    coalesce(a.feature_id, b.feature_id) AS feature_id,
    CASE
        WHEN b.feature_id IS NULL THEN 'missing_on_b'
        WHEN a.feature_id IS NULL THEN 'missing_on_a'
        WHEN a.deleted_at IS DISTINCT FROM b.deleted_at THEN 'tombstone_mismatch'
        WHEN NOT ST_Equals(a.geom, b.geom) THEN 'geometry_differs'
        ELSE 'attributes_differ'
    END AS divergence,
    a.event_time AS a_event_time,
    b.event_time AS b_event_time
FROM domain_features a
FULL OUTER JOIN replica_b.domain_features b USING (feature_id)
WHERE a.partition_key = :'partition' OR b.partition_key = :'partition';

The four divergence classes, and what each one implies about the feedFour classes of divergence against the likely cause and whether the reconciliation rule resolves them automatically. A feature missing on one replica usually means a dropped or expired record and resolves by event time. A tombstone mismatch usually means retention shorter than the rebuild lag and resolves, with the tombstone winning at equal event time. Differing geometry usually means an out-of-order arrival applied by wall clock and resolves. Differing attributes with identical event time indicates genuine merge semantics, which no generic rule can resolve.Likely causeAuto-resolvesmissing on one sidedropped or expired recordyestombstone mismatchretention < rebuild lagyesgeometry differsout-of-order by wall clockyesattributes differ, same event timegenuine merge semanticsno

3. Reconcile by event time, never by arrival time

The reconciliation rule has to be a total order both replicas compute identically, or they converge on different answers.

python
# reconcile.py — deterministic convergence.
def winner(a: dict, b: dict) -> dict:
    """Event time decides; the record identifier breaks exact ties. Never wall-clock
    arrival, which differs between replicas and makes the outcome depend on network
    timing rather than on the data."""
    if a["event_time"] != b["event_time"]:
        return a if a["event_time"] > b["event_time"] else b
    if a["record_id"] != b["record_id"]:
        return a if a["record_id"] > b["record_id"] else b
    return a                                    # genuinely identical


def resolve(divergence: str, a: dict | None, b: dict | None) -> dict:
    """A tombstone always wins over a live record at the same event time: a feature
    deleted and then re-observed produces a later event, so the ordering handles it.
    Treating absence as deletion is what resurrects features on a rebuild."""
    if a is None:
        return b
    if b is None:
        return a
    if divergence == "tombstone_mismatch":
        deleted = a if a.get("deleted_at") else b
        live = b if a.get("deleted_at") else a
        return deleted if deleted["event_time"] >= live["event_time"] else live
    return winner(a, b)

4. Repair through the feed, never by direct write

A replica repaired by hand diverges again on the next rebuild, because the feed still holds whatever caused the divergence.

bash
# Emit the resolved records back onto the feed, so every replica — including ones
# not yet built — converges on the same answer.
python3 reconcile.py --partition "$PARTITION" --emit /tmp/resolved.ndjson
while read -r rec; do
  kcat -P -b "$BROKER" -t "domain.parcels" \
       -k "$(jq -r .feature_id <<<"$rec")" <<<"$rec"
done < /tmp/resolved.ndjson

# Re-digest both replicas after the feed has drained.

Verify convergence rather than assuming it:

bash
a=$(psql "$REPLICA_A_DSN" -At -f partition_digest.sql -v partition="$PARTITION" -v precision=6)
b=$(psql "$REPLICA_B_DSN" -At -f partition_digest.sql -v partition="$PARTITION" -v precision=6)
[ "$a" = "$b" ] && echo "converged" || echo "STILL DIVERGED"

Configuration Reference

Element Value Rationale
Digest scope Per partition Whole-domain digests localise nothing
Coordinate snapping To the declared precision Noise digits are not divergence
Tombstone in digest Included Otherwise a deletion mismatch is invisible
Reconciliation key Event time, then record id Arrival time differs between replicas
Repair path Through the feed A direct write diverges again on rebuild
Digest cadence Hourly per partition Cheap enough to run continuously

Common Failure Modes & Fixes

Every partition reports divergence. Root cause: the two replicas hold different coordinate precision, so every geometry hashes differently. Fix: snap to the declared precision before hashing, as above; if the underlying precision genuinely differs, that is a contract violation on one replica.

How a short tombstone retention resurrects a deleted featureA producer emits a tombstone for a deleted feature. A live replica receives it and correctly omits the feature. Compaction later removes the tombstone once its retention has elapsed. A replica rebuilding after that point reads the compacted feed, finds no record at all for the feature, and cannot distinguish that from not-yet-delivered — so it materialises the feature as though it had never been deleted. Tombstone retention therefore has to exceed the longest permitted consumer lag, which belongs in the feed published contract.ProducerFeedLive replicaRebuilt replicatombstone emittedfeature omitted, correctlyretention elapses, compacted awayno record at allfeature resurrectedabsence ≠ deletion

Digests match and query results differ. Root cause: the digest omits something the query reads — commonly an attribute column added later, or the tombstone field. Fix: include every column the contract publishes.

Reconciliation converges to different answers on each replica. Root cause: the tiebreak uses arrival time or a local sequence. Fix: event time plus a stable record identifier; both replicas must compute the same winner from the same inputs.

A repaired divergence returns after a rebuild. Root cause: repaired by direct write, leaving the feed unchanged. Fix: emit resolved records to the feed so the repair is part of the log rather than a local edit.

Deleted features reappear on a replica rebuilt from a compacted feed. Root cause: tombstone retention shorter than the rebuild lag, so absence was read as never-existed. Fix: retention must exceed the maximum permitted consumer lag, and that lag belongs in the feed’s published contract.

FAQ

Why hash rather than compare rows directly?

Because routine checking has to be cheap enough to run continuously, and a full comparison across two replicas transfers both datasets. A per-partition digest costs one scan on each side and returns one value, so an hourly check over hundreds of partitions is affordable. The expensive per-feature comparison then runs only against the partitions whose digests actually differ, which is normally none of them. It is the same reasoning that makes a checksum useful for file synchronisation.

Is last-write-wins the right reconciliation rule?

For observational spatial data it usually is, provided “last” means event time rather than arrival. A later observation of a feature genuinely supersedes an earlier one, and the ordering is a property of the data rather than of the network. Where it is wrong is for data with real merge semantics — a feature whose attributes are contributed by several sources — and there the reconciliation rule has to be domain-specific and declared in the feed’s contract, because no generic rule can be correct.

Should divergence block serving?

No, and blocking would be worse than the divergence. Both replicas are internally consistent and answer every query; taking one out of service to protect against a disagreement affecting a handful of features converts a data-quality issue into an availability incident. What divergence should do is raise a producer-facing alert with the affected partitions named, and — where a consumer needs certainty — let them pin to a specific replica while reconciliation runs.

How does this interact with the exactly-once guarantees the feed provides?

It backstops them. Exactly-once delivery is a property of the feed under normal operation and does not survive every failure mode: an operator resetting offsets, a rebuild from a compacted feed past tombstone retention, a schema migration applied to one replica first. Digest comparison detects the results of all of those without needing to know which occurred, which is why it is worth running even on a feed whose delivery guarantees are sound.

Should reconciliation run automatically or under supervision?

Detection automatically, reconciliation under supervision at first. The digest comparison is cheap, safe and read-only, so it should run continuously without anyone deciding to. Emitting resolved records back onto the feed is a write to the authoritative log, and until the reconciliation rule has been observed to produce sensible outcomes on real divergences, a human should look at what it proposes. Once the rule has been exercised against a dozen real cases and the outcomes were right each time, promoting it to automatic is reasonable — with the resolved records still emitted through the feed, so the repair is auditable rather than a local edit.