Implementing a Version Vector for Spatial Features
Give each writer a stable identifier and a per-feature counter, then compare vectors to classify an incoming edit as newer, older or concurrent — a timestamp orders every pair of edits including ones made independently, so last-write-wins discards work without anybody being told it happened.
This guide sits under Conflict Resolution Strategies, within Idempotency & Spatial Deduplication. It provides the detection half; the geometric resolution half is Merging Overlapping Zone Edits with Shapely.
When to use this pattern
- More than one service or region can edit the same feature, and they do not coordinate through a single lock.
- Edits can arrive out of order, either because of retries or because a client was offline — the case covered in Handling Out-of-Order Pings from Intermittent Devices for positions and here for edits.
- Silently discarding an edit is unacceptable, which for anything a human typed it usually is.
What a timestamp cannot see
Complete runnable implementation
from dataclasses import dataclass, field
from enum import Enum
class Relation(Enum):
NEWER = "newer" # incoming dominates stored — apply it
OLDER = "older" # stored dominates incoming — drop it
EQUAL = "equal" # the same edit, redelivered
CONCURRENT = "concurrent" # neither dominates — a real conflict
@dataclass(slots=True)
class VersionVector:
"""One counter per writer that has edited this feature."""
counters: dict[str, int] = field(default_factory=dict)
def advance(self, writer: str) -> "VersionVector":
"""Called when `writer` makes an edit having seen everything in self."""
merged = dict(self.counters)
merged[writer] = merged.get(writer, 0) + 1
return VersionVector(merged)
def merge(self, other: "VersionVector") -> "VersionVector":
"""Pointwise maximum — the knowledge of both, after a resolution."""
writers = set(self.counters) | set(other.counters)
return VersionVector({
w: max(self.counters.get(w, 0), other.counters.get(w, 0))
for w in writers
})
def compare(self, other: "VersionVector") -> Relation:
"""Classify self (incoming) against other (stored).
Domination is pointwise: self is newer only if it is >= other for
EVERY writer and > for at least one. If each has a counter the other
does not dominate, the edits are concurrent — which is the case a
timestamp comparison cannot represent at all.
"""
writers = set(self.counters) | set(other.counters)
self_ahead = any(self.counters.get(w, 0) > other.counters.get(w, 0)
for w in writers)
other_ahead = any(other.counters.get(w, 0) > self.counters.get(w, 0)
for w in writers)
if self_ahead and other_ahead:
return Relation.CONCURRENT
if self_ahead:
return Relation.NEWER
if other_ahead:
return Relation.OLDER
return Relation.EQUAL
def prune(self, active_writers: set[str]) -> "VersionVector":
"""Drop retired writers so the vector stays bounded.
Safe only once no in-flight edit can still carry the retired writer's
counter — in practice, after the topic's retention has passed.
"""
return VersionVector({w: n for w, n in self.counters.items()
if w in active_writers})
@dataclass(slots=True)
class FeatureEdit:
feature_id: str
writer: str
vector: VersionVector
geometry: dict
crs: str = "EPSG:4326"
def apply_edit(stored: FeatureEdit | None, incoming: FeatureEdit,
resolve) -> FeatureEdit:
"""Apply one edit, routing genuine conflicts to `resolve`."""
if stored is None:
return incoming
match incoming.vector.compare(stored.vector):
case Relation.NEWER:
return incoming
case Relation.OLDER | Relation.EQUAL:
return stored
case Relation.CONCURRENT:
# The transport layer must NOT decide this. `resolve` knows about
# geometry and about what the feature means; the comparison above
# only knows that a decision is needed.
merged_geometry = resolve(stored, incoming)
return FeatureEdit(
feature_id=stored.feature_id,
writer=incoming.writer,
vector=stored.vector.merge(incoming.vector).advance(incoming.writer),
geometry=merged_geometry,
crs=stored.crs,
)
The EQUAL case is what makes this idempotent: a redelivered edit carries exactly the vector already stored, so it is dropped without any comparison of geometry or timestamps.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
| Writer identity | str |
The service accepting the edit, not the device making it | — |
counters |
dict[str, int] |
One entry per writer that has ever touched this feature | {} |
advance |
call | Only after reading the current state — advancing blind fabricates causality | — |
merge |
call | Pointwise max; used after a resolution to record that both are known | — |
prune |
call | Safe only after the retention window has passed | — |
resolve |
callable | Must be geometry-aware; the comparison layer must never decide | — |
Gotchas and spatial edge cases
-
Advancing a vector without having read the current state fabricates causality. A writer that increments its counter from a cached copy claims to have seen edits it has not, so a genuine conflict is classified as
NEWERand the other edit is discarded — with the vector now asserting that this was correct. Read-then-advance must be atomic, in the same transaction as the write. -
Writer identity must be stable across restarts and deploys. A service that generates a fresh identity on boot adds a new entry to every feature it touches, so vectors grow without bound and every edit after a restart looks concurrent with everything before it. Use a configured name, not a hostname or a process identifier.
-
Per-device writer identity does not scale. Ten thousand mobile editors produce vectors with ten thousand entries on popular features, which is larger than the geometry. Assign identity at the service that accepts the edit; the device identity belongs in the audit trail, not in the vector.
-
Pruning too early resurrects conflicts. Removing a retired writer’s counter while an edit carrying it is still in the log means that edit, on replay, compares as concurrent with everything. Prune only after the retention window has passed, and treat the prune as a schema change rather than a cleanup.
-
Concurrency is not the same as overlap. Two concurrent edits to a feature may touch entirely different parts of it — one changing a name and one moving a vertex — and merging those is trivial. Classify by what changed before invoking a geometric merge, or a simple attribute edit ends up resolved by a union of polygons.
-
A conflict that reaches a merge should be counted. The rate of
CONCURRENToutcomes per feature class is the signal that two teams are editing the same data without knowing it, which is an organisational problem the pipeline can detect before anyone reports it.
Verification
import pytest
SQUARE = {"type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}
def vec(**counters) -> VersionVector:
return VersionVector(dict(counters))
def test_sequential_edit_is_newer():
"""B read A's edit, so B's vector dominates."""
assert vec(A=5, B=1).compare(vec(A=5, B=0)) is Relation.NEWER
def test_concurrent_edits_are_detected():
"""Both branched from A=4; neither saw the other."""
assert vec(A=5, B=0).compare(vec(A=4, B=1)) is Relation.CONCURRENT
def test_redelivery_is_equal_and_dropped():
"""The property that makes at-least-once delivery safe."""
stored = FeatureEdit("f-1", "A", vec(A=5), SQUARE)
incoming = FeatureEdit("f-1", "A", vec(A=5), SQUARE)
assert apply_edit(stored, incoming, resolve=_never) is stored
def test_resolution_records_both_lineages():
"""After a merge, the vector must dominate BOTH inputs."""
stored = FeatureEdit("f-1", "A", vec(A=5, B=0), SQUARE)
incoming = FeatureEdit("f-1", "B", vec(A=4, B=1), SQUARE)
result = apply_edit(stored, incoming, resolve=lambda a, b: a.geometry)
assert result.vector.compare(stored.vector) is Relation.NEWER
assert result.vector.compare(incoming.vector) is Relation.NEWER
def _never(a, b):
raise AssertionError("resolve must not be called for non-concurrent edits")
The last test is the one that catches an incomplete resolution. Merging the geometries but forgetting to merge the vectors leaves the result concurrent with one of its own inputs, so the same conflict is rediscovered on every subsequent edit and the merge runs forever.
Related
- Conflict Resolution Strategies — the topic this guide belongs to
- Merging Overlapping Zone Edits with Shapely — the geometric half, invoked only for the concurrent case
- Idempotent Consumers for Out-of-Order Spatial Events — applying these decisions safely under at-least-once delivery
- Event Key Generation for Spatial Data — identifying the edit itself, which is a different question from ordering two of them