CasualDB, an eventually consistent distributed system

CasualDB is a small key-value store I wrote to understand what eventual consistency really demands of a system once the marketing language is stripped away. It is not production software. It is a place to be wrong cheaply.

The premise is ordinary: several nodes, no coordinator, writes accepted anywhere, and a promise that replicas converge. The interesting part is what "converge" quietly obliges you to build.

Why causal, not just eventual

Plain eventual consistency lets a replica show you an effect before its cause. You reply to a message that, from another node's point of view, has not been sent yet. Technically correct, completely unusable.

Causal consistency is the weakest model that avoids this. If write B happened after the client observed write A, every replica that shows B must already show A. Concurrent writes with no such relationship can land in any order.

Tracking that relationship is the entire cost of the model. A single timestamp cannot express "these two writes are unrelated", so you need something that can hold partial order.

this is the whole reason vector clocks exist

Vector clocks, and their bill

Each node keeps a counter per node. A write carries the writer's whole vector. Comparing two vectors gives you one of three answers: A precedes B, B precedes A, or the two are concurrent.

type Clock = Record<NodeId, number>;
 
function compare(a: Clock, b: Clock): "before" | "after" | "concurrent" {
  let aSmaller = false;
  let bSmaller = false;
 
  for (const node of new Set([...Object.keys(a), ...Object.keys(b)])) {
    const left = a[node] ?? 0;
    const right = b[node] ?? 0;
    if (left < right) aSmaller = true;
    if (right < left) bSmaller = true;
  }
 
  if (aSmaller && bSmaller) return "concurrent";
  return aSmaller ? "before" : "after";
}

The bill arrives immediately. Every value carries metadata proportional to the number of nodes that have ever written it, and nothing garbage-collects that on its own.

Conflicts

Somebody has to decide

Concurrent writes are not a bug to be prevented, they are a case to be handled. CasualDB keeps both versions as siblings and hands them to the caller on read. That is the honest option, and it is also the one nobody wants.

The uncomfortable part

Last-write-wins is not conflict resolution. It is conflict deletion with a timestamp attached to make it look deliberate.

The alternative is to constrain the data type until conflicts cannot happen: counters that only increment, sets that only grow, registers with a defined merge. You give up generality and get determinism back.

Anti-entropy is most of the work

Replication over the happy path is easy. The system spends far more of its code on the boring repair loop that runs when the happy path did not happen: periodic digest exchange, Merkle trees over key ranges, and a repair pass for the ranges whose hashes disagree.

Naive gossip

Every node pushes every write to every peer. Works with three nodes. Collapses at ten.

Digests

Nodes exchange summaries first and only ship the payload for keys that differ.

Merkle ranges

Hash trees over key ranges cut a full-keyspace comparison down to the handful of ranges that actually diverged.

:::

What I would keep

The parts worth carrying into real systems are unglamorous:

eventual is a promise about the limit, not about right now