LWW Property Resolution During Concurrent Updates
The Problem
Two users concurrently edit the same entity. User A sets title = "Alpha" on
one replica; User B sets title = "Beta" on another. Both writes happen
independently – neither knows about the other. When the replicas sync, the
system must pick one winner for title and guarantee that every replica picks
the same one, no matter which write it saw first.
LWW (Last-Writer-Wins) resolution answers this question with two rules applied in order:
- Causal dominance. If one write causally follows another (the writer had already seen the earlier value), the later write wins.
- Deterministic tiebreak. If neither write has seen the other (they are
truly concurrent), the write with the lexicographically greater
EventIdwins. Since EventIds are SHA-256 hashes, this is an arbitrary but consistent total order that every replica can compute independently.
The Three-Stage Pipeline
When the event DAG detects that branches have diverged, the merge proceeds in three conceptual stages:
1. Layer computation
All events between the meet point (the last common ancestor of the two branches) and the branch tips are partitioned into concurrency layers – groups of events at the same causal depth. Within a layer, events are either already applied (the replica has already incorporated them) or to-apply (new to this replica). Layers are produced in topological order so that earlier causal history is resolved before later history. See Event Layers for the precise definition of a layer and the guarantees it provides.
2. Per-layer resolution
For each layer, every LWW property is resolved independently. The algorithm starts with the property’s current stored value as the incumbent, then considers every event in the layer – both already-applied and to-apply – as challengers. The resolution rules below determine whether a challenger displaces the incumbent.
3. State mutation
Only winners that originate from to-apply events actually mutate the backend. If the winning write was already in the replica’s state (from an already-applied event or from the stored seed), nothing changes. After all mutations, subscribers are notified for each changed property.
Resolution Rules
When a challenger event competes against the current incumbent for a property, three rules are applied in priority order:
-
Older-than-meet rule. If the incumbent value was written by an event that predates the meet point, the challenger wins unconditionally. Rationale: every event in the layer descends from the meet, which itself descends from (or equals) the old event. The challenger is strictly newer.
-
Causal dominance. If one event is an ancestor of the other in the accumulated DAG, the descendant wins. This respects user intent: a write made after seeing a prior value should supersede it.
-
Lexicographic tiebreak. If the two events are truly concurrent (neither descends from the other), the one with the greater
EventIdwins. This is an arbitrary but deterministic rule that ensures every replica reaches the same conclusion.
If no incumbent exists for a property, the first event to write it wins by default.
Per-Property Independence
Each property is resolved independently. In a diamond DAG:
A (genesis, title="Init", artist="Init")
/ \
B C
Event B writes title = "B-title". Event C writes artist = "C-artist".
- title: Only B touched it. B’s value wins.
- artist: Only C touched it. C’s value wins.
Final state: title = "B-title", artist = "C-artist". When both branches
write the same property, the resolution rules above determine the winner.
Different properties may have different winning events from the same layer.
Determinism
The system guarantees that the same set of concurrent events always resolves to the same property values, regardless of event delivery order.
Worked example
Replicas R1 and R2 both hold an entity at head [G] (genesis). Three
concurrent events A, B, C – all parented on [G] – arrive in different
orders. Assume EventId(A) < EventId(B) < EventId(C).
R1 receives A, then B, then C:
| Step | Arrives | Layer (already / to-apply) | Winner for each property |
|---|---|---|---|
| 1 | A | – (direct apply) | A’s values |
| 2 | B | already=[A], to-apply=[B] | max(A, B) = B by tiebreak |
| 3 | C | already=[A, B], to-apply=[C] | max(B, C) = C by tiebreak |
R2 receives C, then A, then B:
| Step | Arrives | Layer (already / to-apply) | Winner for each property |
|---|---|---|---|
| 1 | C | – (direct apply) | C’s values |
| 2 | A | already=[C], to-apply=[A] | max(C, A) = C by tiebreak |
| 3 | B | already=[A, C], to-apply=[B] | max(C, B) = C by tiebreak |
Both replicas converge to the same head [A, B, C] and the same winner (C)
for every property that all three events wrote. The key insight: pairwise
max() over EventIds is commutative and associative, so the evaluation
order does not matter.
The ingredients of determinism
-
Lexicographic tiebreak is a total order. EventIds are SHA-256 hashes of
(entity_id, operations, parent_clock). The byte-level ordering is consistent across all replicas. -
max()is commutative and associative. Pairwise competition produces the same result regardless of evaluation order. -
Causal dominance is objective. The DAG structure is the same on every replica, so ancestor/descendant queries return the same answers everywhere.
-
The older-than-meet rule is deterministic. Whether a stored event falls inside or outside the accumulated DAG depends only on the DAG contents, which converge across replicas.
-
Stored state tracks winners. Each layer updates the stored
event_idfor mutated properties, so subsequent layers seed from the correct incumbent.
Prerequisite: causal application order
Determinism relies on events being applied parents-first: if event D has
parent [C], a replica must integrate C before D.
This is an application-order invariant, not a transport guarantee – the
receiver enforces it itself by staging each multi-event batch and
topologically sorting it by in-batch parent edges (event_dag/ordering.rs)
before applying. Sender order is never trusted.
Integration With Entity::apply_event
The DivergedSince handler in
Entity::apply_event ties the
pipeline together:
- Decompose the comparison result into the causal relation and the accumulated DAG.
- Build the layer iterator from the meet point and current head.
- Collect all layers (async; may hit storage).
- Acquire the entity write lock; re-check the head for TOCTOU safety.
- For each layer in topological order, call the resolution algorithm on every property backend. If a to-apply event introduces a new backend type, create it and replay earlier layers first.
- Update the head, release the lock, and broadcast change notifications.
Test Coverage
See Testing Strategy for the full test matrix. Key test files:
tests/tests/lww_resolution.rs– deeper-branch-wins, sequential last write, lexicographic tiebreak, per-property independence, order independence.tests/tests/determinism.rs– two-event same-property determinism (the most critical test: two nodes apply events in opposite order and assert identical final state), deep diamond, multi-property convergence, three-way fork.