Algebraic Replicated Data Types
This manual introduces Algebraic Replicated Data Types (ARDTs): a systematic approach to building conflict-free replicated data types by composition of product, and sum types.
The merge semantics of a compound data type are automatically derived from the merge semantics of its components.
This document contains compilable code, you can download the full file.
Lattices
Every data type in this module is a join-semilattice.
A lattice for a type A is a trait with a single operation:
trait Lattice[A]:
def merge(left: A, right: A): A
We usually will just import it
import rdts.base.Lattice
The merge operation must be associative, commutative, and idempotent.
Merging two states yields a consistent result containing all information from both sides.
If left `merge` right == right then left is subsumed by right: merging left into right is a no-op.
Lattices for existing types
To use a type as a lattice, we need to provide a given instance for that type. A safe way to do this is to use an existing ordering:
given Lattice[Int] = Lattice.fromOrdering
Merging based on an ordering will discard the smaller of the two values.
Safe in this case means that we must be sure that the lattice properties (associative, commutative, idempotent) are guaranteed.
The library comes with existing instances for built-in Scala types that you can just import.
import rdts.base.Lattice.given
import rdts.datatypes.*
Now you can merge any two values of a type that has a given Lattice instance:
test("set lattice via union"):
val setA: Set[Int] = Set(1, 2, 3)
val setB: Set[Int] = Set(3, 4, 5)
val mergedSet = setA `merge` setB
assertEquals(mergedSet, Set(1, 2, 3, 4, 5))
Maps merge key-wise using the lattice of their values:
test("map lattice key-wise"):
val mapA: Map[String, Int] = Map("x" -> 1, "y" -> 2)
val mapB: Map[String, Int] = Map("y" -> 3, "z" -> 4)
val mergedMap = mapA `merge` mapB
assertEquals(mergedMap("x"), 1)
assertEquals(mergedMap("y"), 3)
assertEquals(mergedMap("z"), 4)
Options form a lattice where None is smaller than Some:
test("option lattice"):
val optA: Option[Int] = None
val optB: Option[Int] = Some(5)
assertEquals(optA `merge` optB, Some(5))
assertEquals(optB `merge` optA, Some(5))
Bottom
Bottom[A] provides the empty value of a lattice.
It is the identity element of merge: merging an empty value into any other value leaves it unchanged.
import rdts.base.Bottom
import rdts.base.Bottom.given
test("bottom is the identity of merge"):
val set: Set[Int] = Set(1, 2, 3)
val emptySet = Bottom.empty[Set[Int]]
assert(emptySet.isEmpty, "the empty set is a bottom value")
// merging nothing into a state is a no-op
assertEquals(emptySet `merge` set, set)
The empty set, the empty map, and None are all examples of bottom values.
Some Operations Have No Effect
Because merge only ever adds information, removing information is impossible through merges alone: operations that try to forget previously merged state are no-ops.
A plain grow-only set, for example, cannot forget an element. It can compute a smaller value, but merging that value back into the original set immediately re-adds what was removed:
test("removal has no effect in a set lattice"):
val original: Set[Int] = Set(1, 2)
// removing 1 gives a smaller set ...
val removed: Set[Int] = original.excl(1)
assertEquals(removed, Set(2))
// ... but merging it back into the original set re-adds 1
assertEquals(original `merge` removed, original)
The removed value is Set(2), yet merging it back yields Set(1, 2) merge Set(2), which is Set(1, 2) again: the removal is lost because set union is monotone.
To support removals, replicated data types need more elaborate representations (such as tombstones).
Automatic Lattice Derivation
The core idea of ARDTs is that lattices can be automatically derived from the structure of data types. No manual merge logic is needed.
Product Lattices
For a product type like a case class, merge is component-wise. Each field is merged independently using its own lattice. You get the derivation with a single line:
test("product lattice derives component-wise merge"):
case class Data(counter: Int, items: Set[Int], mapping: Map[String, Int], flag: Option[Int])
// automatically derive the lattice instance
given Lattice[Data] = Lattice.derived
val left = Data(1, Set(1, 2), Map("a" -> 4), Some(1))
val right = Data(2, Set(2, 3), Map("a" -> 1, "b" -> 2), None)
val merged = left `merge` right
assertEquals(merged.counter, 2) // max(1, 2)
assertEquals(merged.items, Set(1, 2, 3)) // union
assertEquals(merged.mapping, Map("a" -> 4, "b" -> 2)) // key-wise merge
assertEquals(merged.flag, Some(1)) // Some > None
Sum Lattices
For sum types (enums, sealed classes), we rely on their inherent total ordering to derive the lattice instance. Constructors that are defined later are considered larger. Sum lattices are not as common as product lattices, but do allow modeling state machines. Consider the following example, where we have a Workflow that first prepares some documents, then drafts a contract, and then finalizes the process.
enum Workflow:
case Init()
case Documents(hasStaffSheet: Boolean = false, hasHourConfirmation: Boolean = false)
case Contract(signed: Boolean = false)
case Complete()
given Lattice[Workflow] = {
// each case of the sum is a Product type, so we need to derive lattices for each
given Lattice[Boolean] = Lattice.fromOrdering
given Lattice[Workflow.Init] = Lattice.derived
given Lattice[Workflow.Documents] = Lattice.derived
given Lattice[Workflow.Contract] = Lattice.derived
given Lattice[Workflow.Complete] = Lattice.derived
Lattice.sumLattice
}
test("sum lattice for state machine"):
val wf0: Workflow = Workflow.Init()
val wf1 = wf0 `merge` Workflow.Documents()
assertEquals(wf1, Workflow.Documents())
The ordering is Init < Documents < Contract < Complete.
Once a workflow reaches Complete, merging any earlier state leaves it at Complete.
By itself, the Workflow state machine does not really guarantee that steps are taken in the correct order; we could just skip directly from Init to Complete, without ever verifying the documents or the contract. We can fix this by using update methods that check preconditions.
import Workflow.*
extension (wf: Workflow) {
def newProcess: Workflow = Documents()
def addStaffSheet: Workflow = Documents(hasStaffSheet = true)
def addHourConfirmation: Workflow = Documents(hasHourConfirmation = true)
def createContract: Workflow = wf match
case Documents(hasStaffSheet = true, hasHourConfirmation = true) => Contract()
case other => Init()
def signContract: Workflow = wf match
case Contract(signed = false) => Contract(true)
case other => Init()
def complete: Workflow = wf match
case Contract(signed = true) => Complete()
case other => Init()
}
test("workflow"):
import rdts.syntax.DeltaBuffer
val db = DeltaBuffer(Workflow.Init())
val result = db
.mod(_.newProcess)
.mod(_.addStaffSheet)
.mod(_.addHourConfirmation)
.mod(_.createContract)
.mod(_.signContract)
.mod(_.complete)
assertEquals(result.state, Workflow.Complete())
If we now leave out any of the steps above, then we will not complete the workflow. Note that it’s not important which replica calls any of the above methods, only that it is in the corresponding state. So anyone could progress the workflow. We could further restrict this to specific replicas/roles by adding those roles to the precondition. If we don’t trust all of the replicas, we need some form of access control, which is currently an open research topic.
Designing Replicated Data Types
Now that we can derive lattices from products and sums, we can design replicated data types.
The key motivation for ARDTs is that they provide automatic convergence: no matter in which order replicas merge states, they always reach the same final result. This solves the hard problem of distributed convergence, but the resulting value may not be semantically useful.
Consider counting votes in an election. If we use a plain Int,
two replicas each counting one vote would produce values 1 and 1.
Merging with max gives 1, losing a vote! The replicas agree on
1, but the result is wrong.
ARDTs shift the problem from ensuring convergence (which is hard) to designing good data types (which is much easier). We need to design data types where concurrent operations do not overwrite each other.
A common trick is to use replica identities. Each replica writes to its own part of the state, so concurrent writes never conflict. The library provides a built in class to track unique IDs and the specific Uid of the local replica (which is a different type, to prevent accidential misuse).
import rdts.base.Uid
import rdts.base.ReplicaId
A replicated counter
Let us design a counter that counts votes correctly. Each replica tracks its own contribution in a map from replica id to count. The total is the sum of all entries. Since each replica only writes to its own key, no information is lost on merge.
// A Counter is just a map: each replica tracks its own count
case class Counter(counters: Map[Uid, Int] = Map.empty):
def value: Int = counters.values.sum
def add(amount: Int)(using replicaId: ReplicaId): Counter =
Counter(Map(replicaId.uid -> (counters.getOrElse(replicaId.uid, 0) + amount)))
// We can derive the lattice automatically
given Lattice[Counter] = Lattice.derived
Note how the methods only return delta states: small values that need to be merged into the state to take effect.
test("Using a counter"):
given ReplicaId = ReplicaId.predefined("replica-alice")
var counter = Counter()
// inc() returns a delta that we merge into the state
counter = counter `merge` counter.add(1)
assertEquals(counter.value, 1)
// merging deltas from multiple replicas adds their contributions
val deltaAlice = counter.add(1)
val deltaBob =
given ReplicaId = ReplicaId.predefined("replica-bob")
counter.add(1)
val total = counter `merge` deltaAlice `merge` deltaBob
assertEquals(total.value, 3)
Our Counter only grows. If we try to add a negative value, the map merge with
max would ignore it because the existing positive value is already larger:
test("Counter cannot decrease"):
given ReplicaId = ReplicaId.predefined("replica-alice")
var counter = Counter()
counter = counter `merge` counter.add(1)
assertEquals(counter.value, 1)
// trying to "un-count" by adding -1 has no effect
counter = counter `merge` counter.add(-1)
assertEquals(counter.value, 1) // still 1, because max(1, -1) == 1
We need a counter that can go both up and down. The solution is a product of two Counters: one for positive increments, one for negative increments. The total is the difference. The product lattice derives component-wise merge automatically.
// A PosNegCounter is a product of two Counters: one for positive, one for negative
case class PosNeg(pos: Counter, neg: Counter):
def value: Int = pos.value - neg.value
def add(amount: Int)(using ReplicaId): PosNeg =
if amount >= 0 then PosNeg(pos.add(amount), Counter())
else PosNeg(Counter(), neg.add(-amount))
given Lattice[PosNeg] = Lattice.derived
test("PosNegCounter from scratch"):
given ReplicaId = ReplicaId.predefined("replica-alice")
var pn = PosNeg(Counter(), Counter())
pn = pn `merge` pn.add(5)
pn = pn `merge` pn.add(3)
assertEquals(pn.value, 8)
pn = pn `merge` pn.add(-4)
assertEquals(pn.value, 4)
test("PosNegCounter concurrent from scratch"):
given ReplicaId = ReplicaId.predefined("replica-alice")
val deltaA = PosNeg(Counter(), Counter()).add(5)
val deltaB =
given ReplicaId = ReplicaId.predefined("replica-bob")
PosNeg(Counter(), Counter()).add(3)
val merged = PosNeg(Counter(), Counter()) `merge` deltaA `merge` deltaB
assertEquals(merged.value, 8)
Causal Time and Ordering-Based Data Types
A common way to allow any data to be replicated is to order values by when they were created. The last write (largest timestamp) wins.
CausalTime is a causally consistent wall-time clock that provides a total
order across replicas. It has three components:
time: wall-clock milliseconds
causal: a monotonic counter for concurrent updates in the same ms
random: a tie-breaker when all else is equal
LastWriterWins[A] pairs a CausalTime with a payload A. Its lattice
picks the value with the later timestamp:
test("LastWriterWins"):
val lww1 = LastWriterWins.now("hello")
val lww2 = LastWriterWins.now("world")
// the later timestamp wins
val mergedLWW = lww1 `merge` lww2
assertEquals(mergedLWW.read, "world")
Common Replicated Data Types
GrowOnlyCounter
An increment-only counter. Internally it is a map from replica id to count.
Each inc() or add(n) produces a delta. Needs an identity so each
replica can write to its own map key.
test("GrowOnlyCounter"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
val remoteId = rdts.base.ReplicaId.predefined("replica-bob")
var gCounter = GrowOnlyCounter.zero
gCounter = gCounter `merge` gCounter.inc()(using localId)
gCounter = gCounter `merge` gCounter.add(5)(using localId)
assertEquals(gCounter.value, 6)
gCounter = gCounter `merge` GrowOnlyCounter.zero.inc()(using remoteId)
assertEquals(gCounter.value, 7)
PosNegCounter (PNCounter)
A counter for both increment and decrement. It is a product of two Counters (positive and negative), merged component-wise. Needs an identity.
test("PosNegCounter"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
val pnCounter = PosNegCounter.zero
val pn1 = pnCounter.add(5)(using localId)
val pn2 = pnCounter.add(-3)(using localId)
val mergedPN = pnCounter `merge` pn1 `merge` pn2
assertEquals(mergedPN.value, 2)
GrowOnlySet (G-Set)
A set that only supports adding elements. Merge is set union. No identity needed: elements are self-contained.
test("GrowOnlySet"):
val gSet = GrowOnlySet.empty[String]
val gs1 = gSet.add("apple")
val gs2 = gSet.add("banana")
val mergedGS = gSet `merge` gs1 `merge` gs2
assertEquals(mergedGS.set, Set("apple", "banana"))
val gs3 = gSet.add("apple")
val mergedGS2 = mergedGS `merge` gs3
assertEquals(mergedGS2.set, Set("apple", "banana"))
GrowOnlyList (G-List)
An append-only list that also supports insertion at an index. Elements are ordered by causal timestamps. Identity is used to allocate fresh timestamps for inserted elements.
test("GrowOnlyList"):
val gList = GrowOnlyList.empty[String]
val gl1 = gList.insertAfter(GrowOnlyList.headDot, "a")
val gl2 = gl1.insertAfter(GrowOnlyList.headDot, "b")
val mergedGL = gList `merge` gl1 `merge` gl2
assertEquals(mergedGL.toList, List("b", "a"))
ReplicatedSet
A set supporting add and remove (Observed-Remove Set). Removals do not override concurrent adds: the add wins. Needs an identity to create fresh dots for added elements.
test("ReplicatedSet"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
var rSet = ReplicatedSet.empty[String]
rSet = rSet `merge` rSet.add("x")(using localId)
rSet = rSet `merge` rSet.add("y")(using localId)
assertEquals(rSet.elements, Set("x", "y"))
rSet = rSet `merge` rSet.remove("x")
assertEquals(rSet.elements, Set("y"))
EnableWinsFlag (EWFlag)
A boolean flag where enable always wins over concurrent disable. Needs an identity to allocate fresh dots on enable.
test("EnableWinsFlag"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
val flag = EnableWinsFlag.empty
val enabled = flag.enable(using localId)()
assert(enabled.read == true, "enable should set flag")
val dEnabled = flag.disable()
val dDisabled = flag.enable(using localId)()
val mergedFlag = dEnabled `merge` dDisabled
assert(mergedFlag.read == true, "enable should win over disable")
MultiVersionRegister (MV-Register)
A register that holds at most one value without concurrent writes, or all concurrently written values as a set. Needs an identity to create fresh dots for written values.
test("MultiVersionRegister"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
val remoteId = rdts.base.ReplicaId.predefined("replica-bob")
val mvReg = MultiVersionRegister.empty[String]
val afterFirst = mvReg `merge` mvReg.write("first")(using localId)
assertEquals(afterFirst.read, Set("first"))
val afterConcurrent = afterFirst `merge` mvReg.write("second")(using remoteId)
assertEquals(afterConcurrent.read, Set("first", "second"))
val afterMerge = afterConcurrent `merge` afterConcurrent.write("third")(using localId)
assertEquals(afterMerge.read, Set("third"))
ObserveRemoveMap (OR-Map)
A map with key-value semantics. Removals are tracked via dots and do not override concurrent updates. Needs an identity for update operations.
test("ObserveRemoveMap"):
given Lattice[String] = Lattice.assertEquals
val localId = rdts.base.ReplicaId.predefined("replica-alice")
val orMap = ObserveRemoveMap.empty[String, String]
val afterM1 = orMap `merge` orMap.update("key1", "value1")(using localId)
assert(afterM1.get("key1") == Some("value1"), "should have key1")
val mRemove = afterM1.remove("key1")
val mAdd = afterM1.update("key1", "value2")(using localId)
val afterConcurrent2 = mRemove `merge` mAdd
assert(afterConcurrent2.get("key1") == Some("value2"), "add should win over remove")
ReplicatedList (RGA)
A replicated list with insert, update, and delete. Uses a directed graph of dots for causal ordering. Needs an identity for all modifying operations.
test("ReplicatedList"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
var rList = ReplicatedList.empty[String]
rList = rList `merge` rList.append("a")(using localId)
rList = rList `merge` rList.append("b")(using localId)
assertEquals(rList.toList, List("a", "b"))
rList = rList `merge` rList.insertAt(1, "c")(using localId)
assertEquals(rList.toList, List("a", "c", "b"))
rList = rList `merge` rList.delete(1)
assertEquals(rList.toList, List("a", "b"))
Epoch Wrapper
Epoch[E] adds a monotonic counter to any datatype.
A higher epoch wins over a lower epoch. Useful for wrapping types with weak
merge semantics.
test("Epoch"):
val localId = rdts.base.ReplicaId.predefined("replica-alice")
val epoch = Epoch.empty[GrowOnlyCounter]
val ep1 = epoch.write(epoch.value.inc()(using localId))
val ep2 = epoch.write(epoch.value.inc()(using localId))
val mergedEpoch = epoch `merge` ep1 `merge` ep2
assertEquals(mergedEpoch.read.value, 1)
The End
Don’t worry about the closing brace :-)
}