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

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:

Types

A dot that a join added or removed, with its key and entry.

pub type Change(value) {
  Added(key: String, dot: Dot, entry: Entry(value))
  Removed(key: String, dot: Dot, entry: Entry(value))
}

Constructors

  • Added(key: String, dot: Dot, entry: Entry(value))
  • Removed(key: String, dot: Dot, entry: Entry(value))

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

The unique identity of one written value.

pub type Dot {
  Dot(replica: Replica, counter: Int)
}

Constructors

  • Dot(replica: Replica, counter: Int)

The live dots of a key.

pub type Dots(value) =
  dict.Dict(Dot, Entry(value))

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

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 compare_dots(a: Dot, b: Dot) -> order.Order

Replica, then counter.

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 delta_size(delta: Delta(value)) -> Int

The number of dots a delta carries.

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 new() -> State(value)

The empty state. Its digest is 0, the XOR of no hashes.

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 size(state: State(value)) -> Int

The number of live dots over every key.

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).

pub fn values(state: State(value), key: String) -> List(value)

Every value of key, in (timestamp, dot) order.

✨ Search Document