ceremonyclient/consensus/pacemaker/rank_tracker_test.go
Cassandra Heart c797d482f9
v2.1.0.5 (#457)
* wip: conversion of hotstuff from flow into Q-oriented model

* bulk of tests

* remaining non-integration tests

* add integration test, adjust log interface, small tweaks

* further adjustments, restore full pacemaker shape

* add component lifecycle management+supervisor

* further refinements

* resolve timeout hanging

* mostly finalized state for consensus

* bulk of engine swap out

* lifecycle-ify most types

* wiring nearly complete, missing needed hooks for proposals

* plugged in, vetting message validation paths

* global consensus, plugged in and verified

* app shard now wired in too

* do not decode empty keys.yml (#456)

* remove obsolete engine.maxFrames config parameter (#454)

* default to Info log level unless debug is enabled (#453)

* respect config's  "logging" section params, remove obsolete single-file logging (#452)

* Trivial code cleanup aiming to reduce Go compiler warnings (#451)

* simplify range traversal

* simplify channel read for single select case

* delete rand.Seed() deprecated in Go 1.20 and no-op as of Go 1.24

* simplify range traversal

* simplify channel read for single select case

* remove redundant type from array

* simplify range traversal

* simplify channel read for single select case

* RC slate

* finalize 2.1.0.5

* Update comments in StrictMonotonicCounter

Fix comment formatting and clarify description.

---------

Co-authored-by: Black Swan <3999712+blacks1ne@users.noreply.github.com>
2025-11-11 05:00:17 -06:00

254 lines
11 KiB
Go

package pacemaker
import (
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"source.quilibrium.com/quilibrium/monorepo/consensus/helper"
"source.quilibrium.com/quilibrium/monorepo/consensus/mocks"
"source.quilibrium.com/quilibrium/monorepo/consensus/models"
)
func TestRankTracker(t *testing.T) {
suite.Run(t, new(RankTrackerTestSuite))
}
type RankTrackerTestSuite struct {
suite.Suite
initialRank uint64
initialQC models.QuorumCertificate
initialTC models.TimeoutCertificate
livenessState *models.LivenessState // Caution: we hand the memory address to rankTracker, which could modify this
store *mocks.ConsensusStore[*helper.TestVote]
tracker rankTracker[*helper.TestState, *helper.TestVote]
}
func (s *RankTrackerTestSuite) SetupTest() {
s.initialRank = 5
s.initialQC = helper.MakeQC(helper.WithQCRank(4))
s.initialTC = nil
s.livenessState = &models.LivenessState{
LatestQuorumCertificate: s.initialQC,
PriorRankTimeoutCertificate: s.initialTC,
CurrentRank: s.initialRank, // we entered rank 5 by observing a QC for rank 4
}
s.store = mocks.NewConsensusStore[*helper.TestVote](s.T())
s.store.On("GetLivenessState", mock.Anything).Return(s.livenessState, nil).Once()
var err error
s.tracker, err = newRankTracker[*helper.TestState, *helper.TestVote](nil, s.store)
require.NoError(s.T(), err)
}
// confirmResultingState asserts that the rank tracker's stored LivenessState reflects the provided
// current rank, newest QC, and last rank TC.
func (s *RankTrackerTestSuite) confirmResultingState(curRank uint64, qc models.QuorumCertificate, tc models.TimeoutCertificate) {
require.Equal(s.T(), curRank, s.tracker.CurrentRank())
require.Equal(s.T(), qc, s.tracker.LatestQuorumCertificate())
if tc == nil {
require.Nil(s.T(), s.tracker.PriorRankTimeoutCertificate())
} else {
require.Equal(s.T(), tc, s.tracker.PriorRankTimeoutCertificate())
}
}
// TestReceiveQuorumCertificate_SkipIncreaseRankThroughQC tests that rankTracker increases rank when receiving QC,
// if applicable, by skipping ranks
func (s *RankTrackerTestSuite) TestReceiveQuorumCertificate_SkipIncreaseRankThroughQC() {
// seeing a QC for the current rank should advance the rank by one
qc := QC(s.initialRank)
expectedResultingRank := s.initialRank + 1
s.store.On("PutLivenessState", LivenessState(qc)).Return(nil).Once()
resultingCurrentRank, err := s.tracker.ReceiveQuorumCertificate(qc)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedResultingRank, resultingCurrentRank)
s.confirmResultingState(expectedResultingRank, qc, nil)
// seeing a QC for 10 ranks in the future should advance to rank +11
curRank := s.tracker.CurrentRank()
qc = QC(curRank + 10)
expectedResultingRank = curRank + 11
s.store.On("PutLivenessState", LivenessState(qc)).Return(nil).Once()
resultingCurrentRank, err = s.tracker.ReceiveQuorumCertificate(qc)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedResultingRank, resultingCurrentRank)
s.confirmResultingState(expectedResultingRank, qc, nil)
}
// TestReceiveTimeoutCertificate_SkipIncreaseRankThroughTC tests that rankTracker increases rank when receiving TC,
// if applicable, by skipping ranks
func (s *RankTrackerTestSuite) TestReceiveTimeoutCertificate_SkipIncreaseRankThroughTC() {
// seeing a TC for the current rank should advance the rank by one
qc := s.initialQC
tc := helper.MakeTC(helper.WithTCRank(s.initialRank), helper.WithTCNewestQC(qc))
expectedResultingRank := s.initialRank + 1
expectedLivenessState := &models.LivenessState{
CurrentRank: expectedResultingRank,
PriorRankTimeoutCertificate: tc,
LatestQuorumCertificate: qc,
}
s.store.On("PutLivenessState", expectedLivenessState).Return(nil).Once()
resultingCurrentRank, err := s.tracker.ReceiveTimeoutCertificate(tc)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedResultingRank, resultingCurrentRank)
s.confirmResultingState(expectedResultingRank, qc, tc)
// seeing a TC for 10 ranks in the future should advance to rank +11
curRank := s.tracker.CurrentRank()
tc = helper.MakeTC(helper.WithTCRank(curRank+10), helper.WithTCNewestQC(qc))
expectedResultingRank = curRank + 11
expectedLivenessState = &models.LivenessState{
CurrentRank: expectedResultingRank,
PriorRankTimeoutCertificate: tc,
LatestQuorumCertificate: qc,
}
s.store.On("PutLivenessState", expectedLivenessState).Return(nil).Once()
resultingCurrentRank, err = s.tracker.ReceiveTimeoutCertificate(tc)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedResultingRank, resultingCurrentRank)
s.confirmResultingState(expectedResultingRank, qc, tc)
}
// TestReceiveTimeoutCertificate_IgnoreOldTC tests that rankTracker ignores old TC and doesn't advance round.
func (s *RankTrackerTestSuite) TestReceiveTimeoutCertificate_IgnoreOldTC() {
curRank := s.tracker.CurrentRank()
tc := helper.MakeTC(
helper.WithTCRank(curRank-1),
helper.WithTCNewestQC(QC(curRank-2)))
resultingCurrentRank, err := s.tracker.ReceiveTimeoutCertificate(tc)
require.NoError(s.T(), err)
require.Equal(s.T(), curRank, resultingCurrentRank)
s.confirmResultingState(curRank, s.initialQC, s.initialTC)
}
// TestReceiveTimeoutCertificate_IgnoreNilTC tests that rankTracker accepts nil TC as allowed input but doesn't trigger a new rank event
func (s *RankTrackerTestSuite) TestReceiveTimeoutCertificate_IgnoreNilTC() {
curRank := s.tracker.CurrentRank()
resultingCurrentRank, err := s.tracker.ReceiveTimeoutCertificate(nil)
require.NoError(s.T(), err)
require.Equal(s.T(), curRank, resultingCurrentRank)
s.confirmResultingState(curRank, s.initialQC, s.initialTC)
}
// TestReceiveQuorumCertificate_PersistException tests that rankTracker propagates exception
// when processing QC
func (s *RankTrackerTestSuite) TestReceiveQuorumCertificate_PersistException() {
qc := QC(s.initialRank)
exception := errors.New("store-exception")
s.store.On("PutLivenessState", mock.Anything).Return(exception).Once()
_, err := s.tracker.ReceiveQuorumCertificate(qc)
require.ErrorIs(s.T(), err, exception)
}
// TestReceiveTimeoutCertificate_PersistException tests that rankTracker propagates exception
// when processing TC
func (s *RankTrackerTestSuite) TestReceiveTimeoutCertificate_PersistException() {
tc := helper.MakeTC(helper.WithTCRank(s.initialRank))
exception := errors.New("store-exception")
s.store.On("PutLivenessState", mock.Anything).Return(exception).Once()
_, err := s.tracker.ReceiveTimeoutCertificate(tc)
require.ErrorIs(s.T(), err, exception)
}
// TestReceiveQuorumCertificate_InvalidatesPriorRankTimeoutCertificate verifies that rankTracker does not retain any old
// TC if the last rank change was triggered by observing a QC from the previous rank.
func (s *RankTrackerTestSuite) TestReceiveQuorumCertificate_InvalidatesPriorRankTimeoutCertificate() {
initialRank := s.tracker.CurrentRank()
tc := helper.MakeTC(helper.WithTCRank(initialRank),
helper.WithTCNewestQC(s.initialQC))
s.store.On("PutLivenessState", mock.Anything).Return(nil).Twice()
resultingCurrentRank, err := s.tracker.ReceiveTimeoutCertificate(tc)
require.NoError(s.T(), err)
require.Equal(s.T(), initialRank+1, resultingCurrentRank)
require.NotNil(s.T(), s.tracker.PriorRankTimeoutCertificate())
qc := QC(initialRank + 1)
resultingCurrentRank, err = s.tracker.ReceiveQuorumCertificate(qc)
require.NoError(s.T(), err)
require.Equal(s.T(), initialRank+2, resultingCurrentRank)
require.Nil(s.T(), s.tracker.PriorRankTimeoutCertificate())
}
// TestReceiveQuorumCertificate_IgnoreOldQC tests that rankTracker ignores old QC and doesn't advance round
func (s *RankTrackerTestSuite) TestReceiveQuorumCertificate_IgnoreOldQC() {
qc := QC(s.initialRank - 1)
resultingCurrentRank, err := s.tracker.ReceiveQuorumCertificate(qc)
require.NoError(s.T(), err)
require.Equal(s.T(), s.initialRank, resultingCurrentRank)
s.confirmResultingState(s.initialRank, s.initialQC, s.initialTC)
}
// TestReceiveQuorumCertificate_UpdateLatestQuorumCertificate tests that rankTracker tracks the newest QC even if it has advanced past this rank.
// The only one scenario, where it is possible to receive a QC for a rank that we already has passed, yet this QC
// being newer than any known one is:
// - We advance ranks via TC.
// - A QC for a passed rank that is newer than any known one can arrive in 3 ways:
// 1. A QC (e.g. from the vote aggregator)
// 2. A QC embedded into a TC, where the TC is for a passed rank
// 3. A QC embedded into a TC, where the TC is for the current or newer rank
func (s *RankTrackerTestSuite) TestReceiveQuorumCertificate_UpdateLatestQuorumCertificate() {
// Setup
// * we start in rank 5
// * newest known QC is for rank 4
// * we receive a TC for rank 55, which results in entering rank 56
initialRank := s.tracker.CurrentRank() //
tc := helper.MakeTC(helper.WithTCRank(initialRank+50), helper.WithTCNewestQC(s.initialQC))
s.store.On("PutLivenessState", mock.Anything).Return(nil).Once()
expectedRank := uint64(56) // processing the TC should results in entering rank 56
resultingCurrentRank, err := s.tracker.ReceiveTimeoutCertificate(tc)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedRank, resultingCurrentRank)
s.confirmResultingState(expectedRank, s.initialQC, tc)
// Test 1: add QC for rank 9, which is newer than our initial QC - it should become our newest QC
qc := QC(s.tracker.LatestQuorumCertificate().GetRank() + 2)
expectedLivenessState := &models.LivenessState{
CurrentRank: expectedRank,
PriorRankTimeoutCertificate: tc,
LatestQuorumCertificate: qc,
}
s.store.On("PutLivenessState", expectedLivenessState).Return(nil).Once()
resultingCurrentRank, err = s.tracker.ReceiveQuorumCertificate(qc)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedRank, resultingCurrentRank)
s.confirmResultingState(expectedRank, qc, tc)
// Test 2: receiving a TC for a passed rank, but the embedded QC is newer than the one we know
qc2 := QC(s.tracker.LatestQuorumCertificate().GetRank() + 4)
olderTC := helper.MakeTC(helper.WithTCRank(qc2.GetRank()+3), helper.WithTCNewestQC(qc2))
expectedLivenessState = &models.LivenessState{
CurrentRank: expectedRank,
PriorRankTimeoutCertificate: tc,
LatestQuorumCertificate: qc2,
}
s.store.On("PutLivenessState", expectedLivenessState).Return(nil).Once()
resultingCurrentRank, err = s.tracker.ReceiveTimeoutCertificate(olderTC)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedRank, resultingCurrentRank)
s.confirmResultingState(expectedRank, qc2, tc)
// Test 3: receiving a TC for a newer rank, the embedded QC is newer than the one we know, but still for a passed rank
qc3 := QC(s.tracker.LatestQuorumCertificate().GetRank() + 7)
finalRank := expectedRank + 1
newestTC := helper.MakeTC(helper.WithTCRank(expectedRank), helper.WithTCNewestQC(qc3))
expectedLivenessState = &models.LivenessState{
CurrentRank: finalRank,
PriorRankTimeoutCertificate: newestTC,
LatestQuorumCertificate: qc3,
}
s.store.On("PutLivenessState", expectedLivenessState).Return(nil).Once()
resultingCurrentRank, err = s.tracker.ReceiveTimeoutCertificate(newestTC)
require.NoError(s.T(), err)
require.Equal(s.T(), finalRank, resultingCurrentRank)
s.confirmResultingState(finalRank, qc3, newestTC)
}