brazier/store/crdt
Add-wins last-writer-wins map: the delta-state CRDT behind replicated stores. A port of the AWLWWMap of delta_crdt_ex (DeltaCrdt.AWLWWMap), itself the “add-wins observed-remove map” of Almeida, Shoker and Baquero, “Delta State Replicated Data Types” (2018), with a timestamp on every value.
Every value written gets a fresh dot (replica, counter). The state
is the live dots of every key plus a causal context: the set of every
dot this replica has ever observed, live or removed. The context is a
version vector (clock: replica -> highest counter, meaning counters
1..n were all seen) plus a cloud of dots seen out of order.
A write never sends the whole state, only a delta: a state fragment with the new dots and, in its context, the dots the write replaced or removed. Joining two states keeps a dot when
- both sides have it, or
- one side has it and the other side’s context does not contain it (the other side never saw it, so it cannot have removed it).
So a removal only removes the dots it observed, and a concurrent write
on another node survives it (add wins). Two concurrent writes of one key
both survive as dots; reads pick the value with the greatest
(timestamp, dot) (last writer wins), the same on every node, and the
next write of the key replaces both.
Join is commutative, associative and idempotent, so replicas converge whatever order, duplication or batching deltas and full states arrive in.
Every state carries a digest of itself for anti-entropy, kept up to
date by every join instead of recomputed (see digest).
Floors: a context bounded across restarts
A restarted store server is a new replica (a new incarnation of its
node), so without more every restart would add a clock entry that no
replica could ever drop. The context therefore also holds floors:
floors[node] = i covers every dot of every incarnation of node
below i, so the clock entries and cloud dots of those incarnations
are dropped. Only an incarnation raises its own node’s floor, to
itself (raise_floor), and only once every replica it can reach holds
the same state, so every dot of an earlier incarnation any of them
holds is in its own state: a live one stays live (a floor covers a dot,
it does not remove it), a removed one stays removed. The context then
holds at most one incarnation per node name that is still compacting,
plus the incarnations that started since a node’s last raise.
The cost is a narrow loss: a dot of an earlier incarnation that only a replica out of reach held (partitioned, or a node whose replica had not yet joined), or that was still in flight, is covered without having been seen, so it is dropped wherever it arrives. The earlier incarnation itself is gone, and its writes are fire and forget, so such a write was already at risk; docs/CLUSTERING.md says when it happens.
Against delta_crdt_ex
The join of dot sets is delta_crdt_ex’s AWLWWMap.join_dot_sets/4
(the formula on join_keys); the differences are:
- The context is exact: delta_crdt_ex’s
Dots.compressfolds a dot set into one counter per replica, which also covers counters below it that it never saw. Here a dot beyond a gap stays in the cloud until the gap fills, and only a contiguous run moves into the clock. - Values are ordered by a hybrid logical clock (
brazier/store/hlc) and ties break on the dot, a total order, so every node reads the same value. delta_crdt_ex stamps a value with the writer’sSystem.monotonic_time, which is not comparable across nodes, and reads the greatest withEnum.max_by, with no tie-break. - Floors (above): delta_crdt_ex never shrinks its context, and its node
id is a fresh
:rand.uniform(1_000_000_000)on every start, so its context grows with every restart (and two starts can draw one id). - Anti-entropy compares one incremental digest (see
digest) and a mismatch pulls the whole state; delta_crdt_ex walks a Merkle tree down to the differing keys and sends only those, one sync in flight per neighbour until it is acknowledged.
Types
Every dot observed: clock covers counters 1..n of each replica,
cloud holds the dots seen beyond a gap, and floors covers every dot
of each node’s incarnations below its floor (node name -> incarnation,
always at least 1; see the module doc). No clock entry or cloud dot of
a state is under a floor: joins drop them.
pub type Context {
Context(
clock: dict.Dict(Replica, Int),
cloud: set.Set(Dot),
floors: dict.Dict(String, Int),
)
}
Constructors
A state fragment produced by a write. entries names every key the
write touched (a removed key with no dots), topics the extra topics
to notify on the nodes that merge it.
pub type Delta(value) {
Delta(
entries: dict.Dict(String, dict.Dict(Dot, Entry(value))),
context: Context,
topics: List(String),
)
}
Constructors
A written value with the timestamp that orders it and the extra PubSub topics that are notified when it is added or removed on another node.
pub type Entry(value) {
Entry(value: value, ts: hlc.Timestamp, topics: List(String))
}
Constructors
-
Entry(value: value, ts: hlc.Timestamp, topics: List(String))
One incarnation of a replica: a node and the time its store server started (microseconds). A restarted server is a new replica, so the dots it creates never collide with the ones its previous incarnation created.
pub type Replica {
Replica(node: String, incarnation: Int)
}
Constructors
-
Replica(node: String, incarnation: Int)
A replica’s state. size is the number of live dots over every key and
digest the XOR of the hashes of its elements (see digest); both are
kept up to date by every join.
pub type State(value) {
State(
entries: dict.Dict(String, dict.Dict(Dot, Entry(value))),
context: Context,
size: Int,
digest: Int,
)
}
Constructors
Values
pub fn append(
state: State(value),
replica: Replica,
key: String,
items: List(#(value, hlc.Timestamp)),
topics: List(String),
) -> #(State(value), Delta(value), List(Change(value)))
Add every value to key, keeping the values already there. Each item
gets its own dot; items pairs each value with its timestamp.
pub fn context_size(state: State(value)) -> Int
The number of elements the state’s context holds: clock entries, cloud dots and floors. Floors keep it bounded across restarts.
pub fn covers(context: Context, dot: Dot) -> Bool
Whether the context has observed dot. A clock entry n covers
counters 1..n only: next_dot never makes a counter below 1, and a
clock that covered one would cover dots fold_context_dots cannot list,
so the two ways join_keys finds removed dots would disagree on a
malformed delta from a peer. A floor covers every dot of the node’s
incarnations below it.
pub fn digest(state: State(value)) -> Int
The state’s digest for anti-entropy: the XOR of the 256-bit hashes of
its elements (every live (key, dot), clock entry, cloud dot and
floor). Two
replicas hold the same digest exactly when they hold the same elements,
up to a hash collision. Reading it costs nothing: every join folds in
the elements it adds and removes (XOR is its own inverse, and the
result does not depend on order), so a write costs a few hashes and a
digest exchange never walks the state.
This is the incremental tree hash of Riak’s TicTac AAE
(leveled_tictac, whose segment hashes are XORs of per-key hashes,
updated by XOR-ing in the hash of each change), with one segment, since
a mismatch pulls the whole state. delta_crdt_ex keeps its MerkleMap up
to date on every change for the same reason. XOR resists accidental
collisions, not crafted ones, which is enough between members: they
trust each other completely.
pub fn floor(state: State(value), node: String) -> Int
The floor of node in the state’s context: 0 when it has none (an
incarnation is never below 0).
pub fn has_older_incarnation(
state: State(value),
replica: Replica,
) -> Bool
Whether the state’s context holds a clock entry or cloud dot of an
incarnation of replica’s node older than replica: what
raise_floor would drop. O(clock entries + cloud dots); a replica asks
once it is in sync with every peer, not on every join.
pub fn join(
state: State(value),
delta: Delta(value),
) -> #(State(value), List(Change(value)))
Merge a delta: only the keys the delta names can change. The cost
follows the delta, not the key: appending to a key that holds 10,000
values visits none of them (see join_keys). The changes come in no
particular order.
pub fn join_full(
state: State(value),
full: Delta(value),
) -> #(State(value), List(Change(value)))
Merge another replica’s full state (see to_delta): every key of
either side can change, since a key absent from full may have been
removed there.
pub fn key_dots(
state: State(value),
key: String,
) -> dict.Dict(Dot, Entry(value))
The live dots of key.
pub fn max_timestamp(
delta: Delta(value),
) -> Result(hlc.Timestamp, Nil)
The latest timestamp a delta carries, to advance the receiver’s clock.
pub fn put(
state: State(value),
replica: Replica,
key: String,
value: value,
ts: hlc.Timestamp,
topics: List(String),
) -> #(State(value), Delta(value), List(Change(value)))
Set key to value, replacing every value of key this replica has
observed.
pub fn raise_floor(
state: State(value),
replica: Replica,
) -> Result(#(State(value), Delta(value)), Nil)
Raise replica’s node’s floor to replica’s incarnation, covering
every dot of the node’s older incarnations and dropping their clock
entries and cloud dots. Their live dots stay live. Returns the new state
and the delta to broadcast (a context with only the floor, so a join of
it removes no live dot), or Error(Nil) when the floor is already
there or higher (nothing to raise). Only the incarnation itself may
raise its node’s floor, and only once it holds every reachable
replica’s state (see the module doc).
pub fn recompute_digest(state: State(value)) -> Int
The digest computed from the whole state, in O(state) and without
sorting: what digest must always equal (the tests check it after
every join).
pub fn remove(
state: State(value),
key: String,
topics: List(String),
) -> #(State(value), Delta(value), List(Change(value)))
Remove every value of key this replica has observed. Values written
concurrently on other nodes survive (add wins).
pub fn replica_to_string(replica: Replica) -> String
node/incarnation, for logs.
pub fn to_delta(state: State(value)) -> Delta(value)
The whole state as a delta, for a full sync (merge it with
join_full).
pub fn value(
state: State(value),
key: String,
) -> Result(value, Nil)
The value of key: the entry with the greatest (timestamp, dot).