Vector Clocks and Eventual Consistency

The CAP Theorem

Vector Clocks

i
pub fn merge_clocks(
  a: dict.Dict(String, Int),
  b: dict.Dict(String, Int),
) -> dict.Dict(String, Int) {
  dict.combine(a, b, fn(va, vb) { int.max(va, vb) })
}

Dominance

i
pub fn dominates(
  a: dict.Dict(String, Int),
  b: dict.Dict(String, Int),
) -> Bool {
  let a_entries = dict.to_list(a)
  list.all(a_entries, fn(entry) {
    let #(node, count_a) = entry
    let count_b = dict.get(b, node) |> result.unwrap(0)
    count_a >= count_b
  })
}

Simulating Two Replicas

{"n1": 1}
{"n1": 2, "n2": 1}
{"n1": 2, "n2": 1}

Versioned Values

i
pub type Versioned(v) {
  Versioned(value: v, clock: dict.Dict(String, Int))
}
i
pub fn resolve(
  local: Versioned(String),
  remote: Versioned(String),
) -> Versioned(String) {
  let merged_clock = merge_clocks(local.clock, remote.clock)
  case dominates(local.clock, remote.clock) {
    True -> Versioned(local.value, merged_clock)
    False ->
      case dominates(remote.clock, local.clock) {
        True -> Versioned(remote.value, merged_clock)
        False ->
          case string.compare(local.value, remote.value) {
            order.Lt | order.Eq -> Versioned(local.value, merged_clock)
            order.Gt -> Versioned(remote.value, merged_clock)
          }
      }
  }
}

Testing

i
pub fn merge_takes_max_test() {
  let a = dict.from_list([#("n1", 3), #("n2", 1)])
  let b = dict.from_list([#("n1", 1), #("n2", 4)])
  let merged = merge_clocks(a, b)
  dict.get(merged, "n1")
  |> should.equal(Ok(3))
  dict.get(merged, "n2")
  |> should.equal(Ok(4))
}

pub fn dominates_true_test() {
  let a = dict.from_list([#("n1", 2)])
  let b = dict.from_list([#("n1", 1)])
  dominates(a, b)
  |> should.be_true()
}

pub fn dominates_false_test() {
  let a = dict.from_list([#("n1", 1)])
  let b = dict.from_list([#("n1", 2)])
  dominates(a, b)
  |> should.be_false()
}

pub fn concurrent_test() {
  let a = dict.from_list([#("n1", 2), #("n2", 1)])
  let b = dict.from_list([#("n1", 1), #("n2", 3)])
  dominates(a, b)
  |> should.be_false()
  dominates(b, a)
  |> should.be_false()
}

pub fn merged_dominates_both_test() {
  let a = dict.from_list([#("n1", 2)])
  let b = dict.from_list([#("n1", 1), #("n2", 3)])
  let merged = merge_clocks(a, b)
  dominates(merged, a)
  |> should.be_true()
  dominates(merged, b)
  |> should.be_true()
}

Check Understanding

What is a grow-only counter (G-counter)?

A G-counter is the simplest CRDT (conflict-free replicated data type). Each node maintains a count of how many times it has incremented. The global count is the sum of all nodes' counts. To merge two G-counters, take the element-wise maximum (the same as merge_clocks). The result is always >= either input, so merges can never decrease the count. This is exactly what vector clocks represent: a G-counter per node ID.

Node n1 has clocks {"n1": 2, "n2": 1} and {"n1": 1, "n2": 3}. Does either clock dominate the other, and what does the merged clock look like?

Neither dominates the other. For the first clock to dominate the second, every counter must be >=. The n1 counter satisfies that (2 >= 1), but the n2 counter does not (1 < 3). These are concurrent writes.

The merged clock is {"n1": 2, "n2": 3}: element-wise maximum. The merged clock dominates both originals.

Exercises

G-counter (15 minutes)

Implement a grow-only counter as a type alias for Dict(String, Int). Write increment(counter, node_id) that adds 1 to the given node's count, merge_counters(a, b) using merge_clocks, and total(counter) that returns the sum of all counts. Simulate two replicas each incrementing independently; merge and assert the total is the sum of both.

Versioned store (20 minutes)

Implement type Versioned(v) { Versioned(value: v, clock: Dict(String, Int)) }. Write resolve(local: Versioned(a), remote: Versioned(a)) -> Versioned(a) that returns the dominant version or, for concurrent writes, the version with the lexicographically smaller value. Write three tests: local dominates, remote dominates, concurrent.

Increment clock (10 minutes)

Write increment(clock: Dict(String, Int), node: String) -> Dict(String, Int) that bumps the counter for node by 1. Add tests confirming that incrementing a node not yet in the clock starts it at 1 and that incrementing an existing node adds 1 to its current value.

Concurrent write count (10 minutes)

Write count_concurrent(versions: List(Versioned(String))) -> Int that counts how many pairs of versions in the list are concurrent (neither dominates the other). Test with a list where all versions are from the same causal history (count = 0) and one where all are concurrent (count = n*(n-1)/2).