Skip to content

Commit

Permalink
Skip ahead to future rounds when justified
Browse files Browse the repository at this point in the history
Implement a rebroadcast protocol improvement where upon receiving
sufficient justification a participant jumps ahead to future rounds.
Justification from a future rounds is considered sufficient when the
participant receives:
 * at least one `CONVERGE` message, and
 * a weak quorum of `PREPARE` messages

for that round.

Fixes #241
  • Loading branch information
masih committed May 24, 2024
1 parent add8260 commit c6edf7b
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 18 deletions.
122 changes: 104 additions & 18 deletions gpbft/gpbft.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,30 @@ func (i *instance) Receive(msg *GMessage) error {
}

func (i *instance) ReceiveAlarm() error {
previousPhase := i.phase
if err := i.tryCurrentPhase(); err != nil {
return fmt.Errorf("failed completing protocol phase: %w", err)
}

// Check if the alarm ended QUALITY phase, i.e. transition from QUALITY to PREPARE phase.
qualityPhaseEnded := previousPhase == QUALITY_PHASE && i.phase == PREPARE_PHASE
if qualityPhaseEnded {
// Check if there are any eligible future rounds to which to skip.

// TODO: Potential optimisations:
// 1) Consider preferring higher rounds to jump to first. FIP does not specify
// any constraints here.
// 2) Build an incremental index of round candidates as messages arrive, allowing
// for efficient jumping to the target round without scanning all state history.
for round, state := range i.rounds {
if round > i.round {
if justification, skipToRound := i.shouldSkipToRound(round, state); skipToRound {
i.skipToRound(round, justification)
}
}
}
}

// A phase may have been successfully completed.
// Re-process any queued messages for the next phase.
return i.drainInbox()
Expand Down Expand Up @@ -341,6 +361,11 @@ func (i *instance) receiveOne(msg *GMessage) error {
i.log("unexpected message %v", msg)
}

// Check whether the instance should skip ahead to future round.
if justification, skip := i.shouldSkipToRound(msg.Vote.Round, round); skip {
i.skipToRound(msg.Vote.Round, justification)
return nil
}
// Every COMMIT phase stays open to new messages even after the protocol moves on to
// a new round. Late-arriving COMMITS can still (must) cause a local decision, *in that round*.
// Try to complete the COMMIT phase for the round specified by the message.
Expand All @@ -351,6 +376,45 @@ func (i *instance) receiveOne(msg *GMessage) error {
return i.tryCurrentPhase()
}

// shouldSkipToRound determines whether to skip to round, and justification
// either for a value to sway to, or of COMMIT bottom to justify our own
// proposal. Otherwise, it returns nil justification and false. When a
// justification exists for a value to sway to this function does sway to that
// value. The caller is then expected to do skip to the given round.
//
// See: skipToRound.
func (i *instance) shouldSkipToRound(round uint64, state *roundState) (*Justification, bool) {

// Check if the given round is ahead of current round and this instance is not in
// QUALITY nor DECIDE phase as dedicated by FIP-0086.
//
// Note that sipping ahead from QUALITY phase does not violate the correctness
// proof of gPBFT. The fact that QUALITY always terminates for a participant
// means this participant will eventually jump, but it may do it earlier.
// However, if a node skips to future rounds without executing the QUALITY phase
// it will only help reaching consensus for bottom.
//
// Future work may consider skipping ahead from QUALITY for faster census in
// certain scenarios. For now, this implementation conforms to the FIP.
if round <= i.round || i.phase == QUALITY_PHASE || i.phase == DECIDE_PHASE {
return nil, false
}
proposal := state.converged.FindMaxTicketProposal(i.powerTable)
if proposal.Justification == nil {
return nil, false
}
if !state.prepared.ReceivedFromWeakQuorum() {
fmt.Println("no weak quorum")
return nil, false
}
if proposal.Justification.Vote.Step == PREPARE_PHASE {
i.log("⚠️ swaying from %s to %s by skip to round %d", &i.proposal, proposal.Chain, i.round)
i.candidates = append(i.candidates, proposal.Chain)
i.proposal = proposal.Chain
}
return proposal.Justification, true
}

// Attempts to complete the current phase and round.
func (i *instance) tryCurrentPhase() error {
i.log("try step %s", i.phase)
Expand Down Expand Up @@ -567,26 +631,11 @@ func (i *instance) tryQuality() error {
return nil
}

func (i *instance) beginConverge() {
// beginConverge initiates CONVERGE_PHASE justified by the given justification.
func (i *instance) beginConverge(justification *Justification) {
i.phase = CONVERGE_PHASE

i.phaseTimeout = i.alarmAfterSynchrony()
prevRoundState := i.roundState(i.round - 1)

// Proposal was updated at the end of COMMIT phase to be some value for which
// this node received a COMMIT message (bearing justification), if there were any.
// If there were none, there must have been a strong quorum for bottom instead.
var justification *Justification
if quorum, ok := prevRoundState.committed.FindStrongQuorumFor(""); ok {
// Build justification for strong quorum of COMMITs for bottom in the previous round.
justification = i.buildJustification(quorum, i.round-1, COMMIT_PHASE, ECChain{})
} else {
// Extract the justification received from some participant (possibly this node itself).
justification, ok = prevRoundState.committed.receivedJustification[i.proposal.Key()]
if !ok {
panic("beginConverge called but no justification for proposal")
}
}
_, pubkey := i.powerTable.Get(i.participant.id)
ticket, err := MakeTicket(i.beacon, i.instanceID, i.round, pubkey, i.participant.host)
if err != nil {
Expand Down Expand Up @@ -790,7 +839,38 @@ func (i *instance) roundState(r uint64) *roundState {
func (i *instance) beginNextRound() {
i.round += 1
i.log("moving to round %d with %s", i.round, i.proposal.String())
i.beginConverge()

prevRoundState := i.roundState(i.round - 1)
// Proposal was updated at the end of COMMIT phase to be some value for which
// this node received a COMMIT message (bearing justification), if there were any.
// If there were none, there must have been a strong quorum for bottom instead.
var justification *Justification
if quorum, ok := prevRoundState.committed.FindStrongQuorumFor(""); ok {
// Build justification for strong quorum of COMMITs for bottom in the previous round.
justification = i.buildJustification(quorum, i.round-1, COMMIT_PHASE, ECChain{})
} else {
// Extract the justification received from some participant (possibly this node itself).
justification, ok = prevRoundState.committed.receivedJustification[i.proposal.Key()]
if !ok {
panic("beginConverge called but no justification for proposal")
}
}

i.beginConverge(justification)
}

// skipToRound jumps ahead to the given round by initiating CONVERGE with the given justification.
//
// See shouldSkipToRound.
func (i *instance) skipToRound(round uint64, justification *Justification) {
i.log("skipping from round %d to round %d with %s", i.round, round, i.proposal.String())
i.round = round

// TODO: Also update rebroadcast timeout once implemented according to the
// following pseudocode borrowed from the FIP:
// 107: timeout_rebroadcast ← max(timeout+1, timeout_rebroadcast)

i.beginConverge(justification)
}

// Returns whether a chain is acceptable as a proposal for this instance to vote for.
Expand Down Expand Up @@ -1011,6 +1091,12 @@ func (q *quorumState) ReceivedFromStrongQuorum() bool {
return hasStrongQuorum(q.sendersTotalPower, q.powerTable.Total)
}

// ReceivedFromWeakQuorum checks whether at least one message has been received
// from a weak quorum of senders.
func (q *quorumState) ReceivedFromWeakQuorum() bool {
return hasWeakQuorum(q.sendersTotalPower, q.powerTable.Total)
}

// Checks whether a chain has reached a strong quorum.
func (q *quorumState) HasStrongQuorumFor(key ChainKey) bool {
supportForChain, ok := q.chainSupport[key]
Expand Down
65 changes: 65 additions & 0 deletions sim/adversary/deny.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package adversary

import (
"time"

"github.com/filecoin-project/go-f3/gpbft"
)

var _ Receiver = (*Deny)(nil)

// Deny adversary denies all messages to a given set of participants for a
// configured duration of time through the experiment.
type Deny struct {
id gpbft.ActorID
host Host
targetsByID map[gpbft.ActorID]struct{}
gst time.Time
}

func NewDeny(id gpbft.ActorID, host Host, denialDuration time.Duration, targets ...gpbft.ActorID) *Deny {
targetsByID := make(map[gpbft.ActorID]struct{})
for _, target := range targets {
targetsByID[target] = struct{}{}
}
return &Deny{
id: id,
host: host,
targetsByID: targetsByID,
gst: time.Time{}.Add(denialDuration),
}
}

func NewDenyGenerator(power *gpbft.StoragePower, denialDuration time.Duration, targets ...gpbft.ActorID) Generator {
return func(id gpbft.ActorID, host Host) *Adversary {
wc := NewDeny(id, host, denialDuration, targets...)
return &Adversary{
Receiver: wc,
Power: power,
}
}
}

func (d *Deny) ID() gpbft.ActorID {
return d.id
}

func (d *Deny) AllowMessage(from gpbft.ActorID, to gpbft.ActorID, msg gpbft.GMessage) bool {
// Deny all messages to or from targets until Global Stabilisation Time has elapsed.
if d.host.Time().Before(d.gst) {
if d.isTarget(from) || d.isTarget(to) {
return false
}
}
return true
}

func (d *Deny) isTarget(id gpbft.ActorID) bool {
_, found := d.targetsByID[id]
return found
}

func (*Deny) Start() error { return nil }
func (*Deny) ValidateMessage(*gpbft.GMessage) (bool, error) { return true, nil }
func (*Deny) ReceiveMessage(*gpbft.GMessage, bool) (bool, error) { return true, nil }
func (*Deny) ReceiveAlarm() error { return nil }
39 changes: 39 additions & 0 deletions test/skip_to_rounds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package test

import (
"math"
"testing"

"github.com/filecoin-project/go-f3/gpbft"
"github.com/filecoin-project/go-f3/sim"
"github.com/filecoin-project/go-f3/sim/adversary"
"github.com/stretchr/testify/require"
)

func TestHonest_JumpsRounds(t *testing.T) {

t.Skip("WIP")

const (
instanceCount = 2000
denialTarget = 0
denialDuration = 200 * EcEpochDuration
)

tsg := sim.NewTipSetGenerator(86523)
baseChain := generateECChain(t, tsg)
ecChainGenerator := sim.NewUniformECChainGenerator(54445, 1, 5)
//fixedEcChain := sim.NewFixedECChainGenerator(baseChain)
sm, err := sim.NewSimulation(
syncOptions(
sim.AddHonestParticipants(6, ecChainGenerator, uniformOneStoragePower),
sim.WithBaseChain(&baseChain),
sim.WithAdversary(adversary.NewDenyGenerator(oneStoragePower, denialDuration, denialTarget)),
sim.WithGlobalStabilizationTime(denialDuration/2),
)...,
)
require.NoError(t, err)
require.NoErrorf(t, sm.Run(instanceCount, maxRounds), "%s", sm.Describe())
chain := ecChainGenerator.GenerateECChain(instanceCount-1, gpbft.TipSet{}, math.MaxUint64)
requireConsensusAtInstance(t, sm, instanceCount-1, *chain.Head())
}

0 comments on commit c6edf7b

Please sign in to comment.