Resolving Sync Conflicts in Distributed Annotation

When two annotation sites edit the same versioned dataset and both push, a naive sync clobbers one side’s work. The durable fix is to key every feature on a stable feature id and perform a three-way merge — comparing a common-ancestor GeoJSON against each site’s version — rather than a two-way file diff. Geometry and attribute changes that touch different features merge automatically; edits to the same feature go to a review queue instead of being guessed at; and a last-writer-wins guard keyed on edit timestamps is the bounded fallback for conflicts that must resolve without a human. This turns “whoever pushed last wins the whole file” into “every non-conflicting edit survives, and real conflicts are surfaced, not lost.”

Why Silent Clobbering Corrupts Training Data

Distributed annotation teams rarely edit in lockstep. One site refines building footprints in a northern tile while another relabels vehicles in a southern tile of the same GeoJSON collection. Both branch from the same snapshot, both edit, both push. If your sync layer resolves this by keeping the last-uploaded object — the default for object stores and for a careless DVC workflow that tracks the file as one opaque blob — one site’s entire session vanishes with no error. The dataset still validates, still loads, still trains. It is simply missing labels, and the loss surfaces only as unexplained recall degradation weeks later.

A two-way diff does not save you either. Given only ours and theirs, a diff cannot distinguish “they added feature X” from “we deleted feature X” — both look like feature X being present on one side and absent on the other. Without the shared base version as a reference point, every merge decision is a guess. The three-way merge exists precisely to remove that ambiguity: by comparing each side against the common ancestor, an added feature, a removed feature, and a modified feature become three distinct, detectable events.

Two structural preconditions make this work. First, features must carry a stable feature id — a UUID minted at creation, stored in a property, and never regenerated on edit. Array index and geometry hash are both unstable: nudge one vertex and a hash-keyed feature reads as delete-plus-add, silently discarding the annotator’s refinement. Second, features should carry an edit timestamp so the fallback guard has something principled to compare. Getting these two fields onto every feature is upstream work, but everything below depends on them.

Three-way merge for distributed GeoJSON annotations A common base FeatureCollection diverges into two edited versions, ours and theirs. Non-overlapping changes auto-merge into a merged output, while edits to the same feature are routed to a conflict review queue. base common ancestor ours site A edits theirs site B edits classify by feature id added / removed / modified merged output non-overlapping edits auto-merged conflict queue same feature, both sides — review

Step-by-Step Implementation

The workflow below loads three FeatureCollections, classifies every feature against the base, auto-merges non-overlapping changes, isolates true conflicts, and writes both a merged file and a conflict report. It assumes each feature stores a feature_id string and an edited_at ISO timestamp in its properties. Coordinates are geographic; the merge is CRS-agnostic as long as all three files share one coordinate reference system — mixing EPSG:4326 on one side with a projected CRS on another would make identical geometries compare as modified, so normalise CRS before merging.

Install the pinned dependencies once:

bash
pip install geopandas==0.14.4 shapely==2.0.6 pandas==2.2.2

Step 1 — Load Base, Ours, and Theirs Keyed by Feature Id

Read all three versions into geopandas GeoDataFrames indexed by the stable id. Indexing by feature_id — not row order — is what lets the same feature line up across three files that may store features in different orders.

python
from __future__ import annotations
import geopandas as gpd

def load_keyed(path: str, id_field: str = "feature_id") -> gpd.GeoDataFrame:
    """Load a GeoJSON FeatureCollection indexed by its stable feature id."""
    gdf: gpd.GeoDataFrame = gpd.read_file(path)
    if id_field not in gdf.columns:
        raise ValueError(f"{path}: every feature needs a stable '{id_field}'")
    if gdf[id_field].duplicated().any():
        raise ValueError(f"{path}: duplicate {id_field} values break the merge")
    return gdf.set_index(id_field, drop=False)

base: gpd.GeoDataFrame = load_keyed("base.geojson")
ours: gpd.GeoDataFrame = load_keyed("ours.geojson")
theirs: gpd.GeoDataFrame = load_keyed("theirs.geojson")

Step 2 — Classify Each Feature Against the Base

For one side, compare every feature id to the base to label it added, removed, or modified. A feature is modified when its geometry or its non-metadata attributes differ from the base version. Comparing on WKB plus a sorted attribute tuple is stable and ignores property ordering.

python
from shapely.geometry.base import BaseGeometry

IGNORE_PROPS: set[str] = {"edited_at"}  # metadata that should not count as a change

def _fingerprint(row: "gpd.GeoSeries", geom: BaseGeometry) -> tuple[bytes, tuple]:
    props = tuple(
        (k, row[k]) for k in sorted(row.index)
        if k not in IGNORE_PROPS and k != "geometry"
    )
    return (geom.wkb, props)

def classify(side: gpd.GeoDataFrame, base: gpd.GeoDataFrame) -> dict[str, str]:
    """Return {feature_id: 'added' | 'removed' | 'modified'} for one side vs base."""
    changes: dict[str, str] = {}
    base_ids: set[str] = set(base.index)
    side_ids: set[str] = set(side.index)

    for fid in side_ids - base_ids:
        changes[fid] = "added"
    for fid in base_ids - side_ids:
        changes[fid] = "removed"
    for fid in side_ids & base_ids:
        if _fingerprint(side.loc[fid], side.geometry.loc[fid]) != \
           _fingerprint(base.loc[fid], base.geometry.loc[fid]):
            changes[fid] = "modified"
    return changes

Step 3 — Merge Non-Overlapping Changes and Flag Conflicts

Now walk the union of both changesets. If only one side touched a feature, apply that side’s version. If both sides touched the same feature, it is a conflict unless — the one safe exception — both made the identical edit, in which case either version serves.

python
from dataclasses import dataclass, field

@dataclass
class Conflict:
    feature_id: str
    ours_change: str | None
    theirs_change: str | None

@dataclass
class MergeResult:
    features: dict[str, "gpd.GeoSeries"] = field(default_factory=dict)
    conflicts: list[Conflict] = field(default_factory=list)

def three_way_merge(base: gpd.GeoDataFrame,
                    ours: gpd.GeoDataFrame,
                    theirs: gpd.GeoDataFrame) -> MergeResult:
    ours_ch = classify(ours, base)
    theirs_ch = classify(theirs, base)
    result = MergeResult()

    # Start from base; unchanged features carry through untouched.
    for fid in base.index:
        result.features[fid] = base.loc[fid]

    touched: set[str] = set(ours_ch) | set(theirs_ch)
    for fid in touched:
        o, t = ours_ch.get(fid), theirs_ch.get(fid)
        if o and not t:                       # only we changed it
            _apply(result, fid, "ours", ours, o)
        elif t and not o:                     # only they changed it
            _apply(result, fid, "theirs", theirs, t)
        elif _same_edit(fid, o, t, ours, theirs):
            _apply(result, fid, "ours", ours, o)   # identical edit, no conflict
        else:                                 # same feature, different edits
            result.conflicts.append(Conflict(fid, o, t))
    return result

def _apply(result: MergeResult, fid: str, side: str,
           gdf: gpd.GeoDataFrame, change: str) -> None:
    if change == "removed":
        result.features.pop(fid, None)
    else:                                     # added or modified
        result.features[fid] = gdf.loc[fid]

def _same_edit(fid: str, o: str | None, t: str | None,
               ours: gpd.GeoDataFrame, theirs: gpd.GeoDataFrame) -> bool:
    if o != t or o == "removed":
        return o == "removed" and t == "removed"
    return _fingerprint(ours.loc[fid], ours.geometry.loc[fid]) == \
           _fingerprint(theirs.loc[fid], theirs.geometry.loc[fid])

Step 4 — Apply the Last-Writer-Wins Guard

For pipelines that must produce a merged file with no human in the loop, resolve remaining conflicts by keeping the feature with the newer edited_at. Always log the discarded side so the choice stays auditable rather than silent.

python
from datetime import datetime

def resolve_last_writer_wins(result: MergeResult,
                             ours: gpd.GeoDataFrame,
                             theirs: gpd.GeoDataFrame) -> list[dict[str, str]]:
    """Auto-resolve conflicts by newest timestamp; return an audit log."""
    log: list[dict[str, str]] = []
    for c in list(result.conflicts):
        if c.feature_id not in ours.index or c.feature_id not in theirs.index:
            continue  # delete-vs-modify: leave for human review
        o_ts = datetime.fromisoformat(ours.loc[c.feature_id, "edited_at"])
        t_ts = datetime.fromisoformat(theirs.loc[c.feature_id, "edited_at"])
        winner = "ours" if o_ts >= t_ts else "theirs"
        src = ours if winner == "ours" else theirs
        result.features[c.feature_id] = src.loc[c.feature_id]
        result.conflicts.remove(c)
        log.append({"feature_id": c.feature_id, "kept": winner,
                    "reason": "last_writer_wins"})
    return log

Step 5 — Write Merged Output and a Conflict Report

Serialize the surviving features back to GeoJSON and emit a JSON report so review tooling and downstream stages know exactly what merged cleanly and what still needs a decision.

python
import json
import pandas as pd

def write_outputs(result: MergeResult, base: gpd.GeoDataFrame,
                  merged_path: str = "merged.geojson",
                  report_path: str = "conflict_report.json") -> None:
    rows = list(result.features.values())
    merged = gpd.GeoDataFrame(pd.DataFrame(rows), crs=base.crs)
    merged.to_file(merged_path, driver="GeoJSON")

    report = {
        "merged_feature_count": len(result.features),
        "unresolved_conflicts": [
            {"feature_id": c.feature_id, "ours": c.ours_change,
             "theirs": c.theirs_change} for c in result.conflicts
        ],
    }
    with open(report_path, "w", encoding="utf-8") as fh:
        json.dump(report, fh, indent=2)

# End-to-end
res = three_way_merge(base, ours, theirs)
audit = resolve_last_writer_wins(res, ours, theirs)  # optional automatic pass
write_outputs(res, base)

Change Type to Resolution Reference

The merge’s entire decision surface reduces to how a feature changed on each side. This table is the contract the code above implements.

Change on ours Change on theirs Resolution
Modified Unchanged Auto-merge — keep ours
Unchanged Modified Auto-merge — keep theirs
Added (new id) Unchanged Auto-merge — include the new feature
Removed Unchanged Auto-merge — drop the feature
Modified Modified (identical) Auto-merge — either version
Modified Modified (different) Conflict — review queue
Removed Modified Conflict — review queue (never auto-drop)
Added (same id, different geometry) Added (same id) Conflict — id collision, review queue

The two rows that most often get mishandled are delete-versus-modify and same-id collisions. Never let a deletion win automatically over a refinement: the annotator who deleted may have been working from stale context, and dropping a corrected boundary is unrecoverable without digging through history. Same-id collisions usually signal that two sites minted the same UUID or that ids were reused — treat them as conflicts and fix the id generator upstream.

Common Errors and Fixes

Every feature reports as modified even when nothing changed Root cause: the three files use different CRS or different coordinate precision, so WKB fingerprints differ byte-for-byte. Fix: reproject all sides to one CRS and round coordinates to a fixed precision (for example shapely.set_precision(geom, 1e-7)) before fingerprinting.

Edited features appear as a delete plus an add Root cause: features are keyed on array index or a geometry hash, so any vertex move changes the key. Fix: key strictly on a persistent feature_id UUID assigned at creation and preserved through every edit.

KeyError on edited_at during last-writer-wins Root cause: some features predate the timestamp convention. Fix: backfill edited_at from version history, or skip the guard for those ids and route them to the review queue instead of crashing the merge.

Merged file loses features that neither side changed Root cause: the merge started from the changesets alone and never seeded from base. Fix: initialise the result with every base feature first (Step 3 does this), then apply changes on top so untouched features always survive.

Two sites both push a feature with the same id but different geometry Root cause: non-unique id generation across sites. Fix: mint UUIDs with a site prefix or a UUID4 source, and treat any residual collision as a conflict rather than overwriting — the same discipline that tracking annotation changes with SHA hashing uses to keep identities stable.

This guide is part of the broader Syncing Annotations Across Cloud Remotes topic area within Dataset Versioning & Spatial Data Sync.