Skip to content

Commit

Permalink
add property-based test for multipaxos
Browse files Browse the repository at this point in the history
  • Loading branch information
haaase committed Jan 16, 2025
1 parent 91e2dc7 commit e9f2dd7
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 18 deletions.
107 changes: 107 additions & 0 deletions Modules/RDTs/.jvm/src/test/scala/MultiPaxosSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import org.scalacheck.Arbitrary.arbitrary
import org.scalacheck.Prop.propBoolean
import org.scalacheck.{Arbitrary, Gen, Prop}
import rdts.base.{Lattice, LocalUid}
import rdts.datatypes.experiments.protocols.{MultiPaxos, Participants}

import scala.util.Try

extension [A](s1: Seq[A])
def isPrefix(s2: Seq[A]): Boolean = s1.indexOfSlice(s2) == 0

class MultiPaxosSuite extends munit.ScalaCheckSuite {
// override def scalaCheckInitialSeed = "6Y9lv63LraBdJTHwHFLm3ItFEF7sm6Ok2D3S22VQcTO="

override def scalaCheckTestParameters =
super.scalaCheckTestParameters
.withMinSuccessfulTests(1000)
.withMinSize(30)
.withMaxSize(500)

property("Multipaxos")(MultiPaxosSpec[Int](
logging = false,
minDevices = 3,
maxDevices = 5,
proposeFreq = 5,
startElectionFreq = 1,
mergeFreq = 80
).property())
}

class MultiPaxosSpec[A: Arbitrary](
logging: Boolean = false,
minDevices: Int,
maxDevices: Int,
proposeFreq: Int,
startElectionFreq: Int,
mergeFreq: Int
) extends CommandsARDTs[MultiPaxos[A]] {

override def genInitialState: Gen[State] =
for
numDevices <- Gen.choose(minDevices, maxDevices)
ids = Range(0, numDevices).map(_ => LocalUid.gen()).toList
yield ids.map(id => (id, MultiPaxos())).toMap

override def genCommand(state: State): Gen[Command] =
Gen.frequency(
(mergeFreq, genMerge(state)),
(proposeFreq, genPropose(state)),
(startElectionFreq, genStartElection(state))
)

def genPropose(state: State): Gen[Propose] =
for
id <- genId(state)
value <- arbitrary[A]
yield Propose(id, value)

def genStartElection(state: State): Gen[StartElection] = genId(state).map(StartElection(_))

def genMerge(state: State): Gen[Merge] =
for
(left, right) <- genId2(state)
yield Merge(left, right)

// commands: (merge), upkeep, write, addMember, removeMember
case class Merge(left: LocalUid, right: LocalUid) extends ACommand(left):
override def nextLocalState(states: Map[LocalUid, MultiPaxos[A]]): MultiPaxos[A] =
given Participants(states.keySet.map(_.uid))
val merged = states(left).merge(states(right))
val result = merged.merge(merged.upkeep(using left))

if logging then
if result.read.length > 1 then
println(result)
result

override def postCondition(state: State, result: Try[Result]): Prop =
given Participants(state.keySet.map(_.uid))
val res: Map[LocalUid, MultiPaxos[A]] = result.get
Prop.forAll(genId2(res)) {
(index1, index2) =>
(state(index1), state(index2), res(index1), res(index2)) match
case (oldMultipaxos1, oldMultipaxos2, multipaxos1, multipaxos2) =>
val (log1, log2) = (multipaxos1.read, multipaxos2.read)
val (oldLog1, oldLog2) = (oldMultipaxos1.read, oldMultipaxos2.read)
(log1.isPrefix(log2) || log2.isPrefix(
log1
)) :| s"every log is a prefix of another log or vice versa, but we had:\n${multipaxos1.rounds.counter}${multipaxos1.read}\n${multipaxos2.rounds.counter}${multipaxos2.read}" &&
// ((multipaxos1.rounds.counter != multipaxos2.rounds.counter) || multipaxos1.leader.isEmpty || multipaxos2.leader.isEmpty || (multipaxos1.leader == multipaxos2.leader)) :| s"There can only ever be one leader for a given epoch but we got:\n${multipaxos1.leader}\n${multipaxos2.leader}" &&
(log1.isPrefix(oldLog1) && log2.isPrefix(oldLog2)) :| s"logs never shrink"
}

case class Propose(proposer: LocalUid, value: A) extends ACommand(proposer):
override def nextLocalState(states: Map[LocalUid, MultiPaxos[A]]): MultiPaxos[A] =
given Participants(states.keySet.map(_.uid))
val delta = states(proposer).proposeIfLeader(value)(using proposer)
val proposed = Lattice.merge(states(proposer), delta)
Lattice.merge(proposed, proposed.upkeep(using proposer))

case class StartElection(initiator: LocalUid) extends ACommand(initiator):
override def nextLocalState(states: Map[LocalUid, MultiPaxos[A]]): MultiPaxos[A] =
given Participants(states.keySet.map(_.uid))
val delta = states(initiator).startLeaderElection(using initiator)
val merged = Lattice.merge(states(initiator), delta)
Lattice.merge(merged, merged.upkeep(using initiator))
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,22 @@ case class MultiPaxos[A](
private def currentPaxos = rounds.value.paxos

// public API
def leader: Option[Uid] = rounds.value.leader.value
def leader: Option[Uid] = rounds.value.leader.value
def paxosLeader(using Participants): Option[Uid] = currentPaxos.currentRound match
case Some((_, PaxosRound(leaderElection, _))) => leaderElection.result
case None => None

def phase: MultipaxosPhase = rounds.value.phase.value
def read: List[A] = log.toList.sortBy(_._1).map(_._2)

def startLeaderElection(using LocalUid, Participants): MultiPaxos[A] =
MultiPaxos(
rounds =
// transition to new epoch
rounds.epocheWrite(MultiPaxosRound(
leader = LastWriterWins.now(None), // set leader to none
paxos = Paxos().phase1a, // start new Paxos round with self proposed as leader
phase = LastWriterWins.now(MultipaxosPhase.LeaderElection)
leader = rounds.value.leader.write(None), // set leader to none
paxos = Paxos().phase1a, // start new Paxos round with self proposed as leader
phase = rounds.value.phase.write(MultipaxosPhase.LeaderElection)
))
)

Expand All @@ -47,7 +52,7 @@ case class MultiPaxos[A](
MultiPaxos(
rounds = rounds.write(MultiPaxosRound(
paxos = afterProposal, // phase 2a already checks if I am the leader
phase = LastWriterWins.now(MultipaxosPhase.Voting)
phase = rounds.value.phase.write(MultipaxosPhase.Voting)
))
)

Expand All @@ -59,15 +64,17 @@ case class MultiPaxos[A](
rounds.value.phase.value match
case MultipaxosPhase.LeaderElection =>
// we are in a leader election
// check if there is a result
// check if this process was elected as a leader
newPaxos.currentRound match
case Some((_, PaxosRound(leaderElection, proposals))) if leaderElection.result.nonEmpty =>
case Some((_, PaxosRound(leaderElection, _)))
if leaderElection.result.nonEmpty // if leaderElection.result == Some(replicaId)
=>
// there is a result
MultiPaxos(rounds =
rounds.write(MultiPaxosRound(
leader = LastWriterWins.now(leaderElection.result),
leader = rounds.value.leader.write(leaderElection.result),
paxos = deltaPaxos,
phase = LastWriterWins.now(MultipaxosPhase.Idle)
phase = rounds.value.phase.write(MultipaxosPhase.Idle)
))
)
case _ =>
Expand All @@ -94,8 +101,8 @@ case class MultiPaxos[A](
// return new Multipaxos with: appended log, set leader, phase as idle,
MultiPaxos(
rounds = rounds.epocheWrite(MultiPaxosRound(
leader = LastWriterWins.now(leader),
phase = LastWriterWins.now(MultipaxosPhase.Idle),
leader = rounds.value.leader.write(paxosLeader),
phase = rounds.value.phase.write(MultipaxosPhase.Idle),
paxos = newPaxos
)),
log = newLog
Expand All @@ -112,6 +119,10 @@ case class MultiPaxos[A](
MultiPaxos()
}

override def toString: String =
lazy val s = s"MultiPaxos(epoch: ${rounds.counter}, phase: $phase, log: $read)"
s

object MultiPaxos:
given [A]: Bottom[MultiPaxosRound[A]] = Bottom.provide(MultiPaxosRound())
given [A]: Lattice[MultiPaxosRound[A]] = Lattice.derived
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package test.rdts.protocols

import rdts.base.LocalUid
import rdts.datatypes.experiments.protocols.Paxos.given
import rdts.datatypes.experiments.protocols.{MultiPaxos, MultipaxosPhase, Participants, Paxos}
import rdts.datatypes.experiments.protocols.{MultiPaxos, MultipaxosPhase, Participants}

class MultiPaxosTest extends munit.FunSuite {

Expand All @@ -16,7 +15,7 @@ class MultiPaxosTest extends munit.FunSuite {
test("happy path") {
var testPaxosObject = emptyPaxosObject

assertEquals(testPaxosObject.leader, None)
assertEquals(testPaxosObject.paxosLeader, None)
assertEquals(testPaxosObject.phase, MultipaxosPhase.LeaderElection, "multipaxos starts in leader election phase")

val proposeValue = 1
Expand All @@ -26,8 +25,9 @@ class MultiPaxosTest extends munit.FunSuite {
// testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id1))
testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id2))
testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id3))
testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id1))

assertEquals(testPaxosObject.leader, Some(id1.uid), "id1 should be the leader")
assertEquals(testPaxosObject.paxosLeader, Some(id1.uid), "id1 should be the leader")

// replica 2 tries to write
val afterwrite = testPaxosObject.merge(testPaxosObject.proposeIfLeader(2)(using id2))
Expand Down Expand Up @@ -56,13 +56,14 @@ class MultiPaxosTest extends munit.FunSuite {
testPaxosObject = testPaxosObject.merge(testPaxosObject.startLeaderElection(using id3))
assertEquals(testPaxosObject.log.values.toList.sorted, List(1, 12), "log survives new leader election")
assertEquals(testPaxosObject.phase, MultipaxosPhase.LeaderElection)
assertEquals(testPaxosObject.leader, None)
assertEquals(testPaxosObject.paxosLeader, None)
testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id3))
assertEquals(testPaxosObject.phase, MultipaxosPhase.LeaderElection)
assertEquals(testPaxosObject.leader, None)
assertEquals(testPaxosObject.paxosLeader, None)
testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id2))
testPaxosObject = testPaxosObject.merge(testPaxosObject.upkeep(using id3))
assertEquals(testPaxosObject.phase, MultipaxosPhase.Idle)
assertEquals(testPaxosObject.leader, Some(id3.uid))
assertEquals(testPaxosObject.paxosLeader, Some(id3.uid))
}

test("conflicting proposals") {
Expand Down Expand Up @@ -90,4 +91,54 @@ class MultiPaxosTest extends munit.FunSuite {

assert(testPaxosObject.log.values.head == 1 || testPaxosObject.log.values.head == 2)
}

test("counterexample?") {
var rep1 = emptyPaxosObject
var rep2 = emptyPaxosObject
var rep3 = emptyPaxosObject

// id1 leader election
rep1 = rep1.merge(rep1.startLeaderElection(using id1))
rep1 = rep1.merge(rep1.upkeep(using id1))

// 2. id2 <- id1
rep2 = rep2.merge(rep1)
rep2 = rep2.merge(rep2.upkeep(using id2))

// 3. i1 <- i2
rep1 = rep1.merge(rep2)
rep1 = rep1.merge(rep1.upkeep(using id1))

// 4. id3 <- id2
rep3 = rep3.merge(rep2)
rep3 = rep3.merge(rep3.upkeep(using id3))

// 5. leaderelect id3
rep3 = rep3.merge(rep3.startLeaderElection(using id3))
rep3 = rep3.merge(rep3.upkeep(using id3))

// 6. propose id1
rep1 = rep1.merge(rep1.proposeIfLeader(1)(using id1))
rep1 = rep1.merge(rep1.upkeep(using id1))

// 7. id2 <- id1
rep2 = rep2.merge(rep1)
rep2 = rep2.merge(rep2.upkeep(using id2))

// 8. id1 <- id3
rep1 = rep1.merge(rep3)
rep1 = rep1.merge(rep1.upkeep(using id1))

// 9. id3 <- id1
rep3 = rep3.merge(rep1)
rep3 = rep3.merge(rep3.upkeep(using id3))

rep3 = rep3.merge(rep2)
rep3 = rep3.merge(rep3.upkeep(using id3))

rep2 = rep2.merge(rep3)
rep2 = rep2.merge(rep2.upkeep(using id2))

assert(true)
}
}

0 comments on commit e9f2dd7

Please sign in to comment.