github.com/onflow/flow-go@v0.33.17/engine/common/follower/compliance_core_test.go (about)

     1  package follower
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"sync"
     7  	"testing"
     8  	"time"
     9  
    10  	"github.com/stretchr/testify/assert"
    11  	"github.com/stretchr/testify/mock"
    12  	"github.com/stretchr/testify/require"
    13  	"github.com/stretchr/testify/suite"
    14  
    15  	hotstuff "github.com/onflow/flow-go/consensus/hotstuff/mocks"
    16  	"github.com/onflow/flow-go/consensus/hotstuff/model"
    17  	"github.com/onflow/flow-go/engine/common/follower/cache"
    18  	"github.com/onflow/flow-go/model/flow"
    19  	"github.com/onflow/flow-go/module/irrecoverable"
    20  	"github.com/onflow/flow-go/module/metrics"
    21  	module "github.com/onflow/flow-go/module/mock"
    22  	"github.com/onflow/flow-go/module/trace"
    23  	protocol "github.com/onflow/flow-go/state/protocol/mock"
    24  	"github.com/onflow/flow-go/utils/unittest"
    25  )
    26  
    27  func TestFollowerCore(t *testing.T) {
    28  	suite.Run(t, new(CoreSuite))
    29  }
    30  
    31  // CoreSuite maintains minimal state for testing ComplianceCore.
    32  // Performs startup & shutdown using `module.Startable` and `module.ReadyDoneAware` interfaces.
    33  type CoreSuite struct {
    34  	suite.Suite
    35  
    36  	originID         flow.Identifier
    37  	finalizedBlock   *flow.Header
    38  	state            *protocol.FollowerState
    39  	follower         *module.HotStuffFollower
    40  	sync             *module.BlockRequester
    41  	validator        *hotstuff.Validator
    42  	followerConsumer *hotstuff.FollowerConsumer
    43  
    44  	ctx    irrecoverable.SignalerContext
    45  	cancel context.CancelFunc
    46  	errs   <-chan error
    47  	core   *ComplianceCore
    48  }
    49  
    50  func (s *CoreSuite) SetupTest() {
    51  	s.state = protocol.NewFollowerState(s.T())
    52  	s.follower = module.NewHotStuffFollower(s.T())
    53  	s.validator = hotstuff.NewValidator(s.T())
    54  	s.sync = module.NewBlockRequester(s.T())
    55  	s.followerConsumer = hotstuff.NewFollowerConsumer(s.T())
    56  
    57  	s.originID = unittest.IdentifierFixture()
    58  	s.finalizedBlock = unittest.BlockHeaderFixture()
    59  	finalSnapshot := protocol.NewSnapshot(s.T())
    60  	finalSnapshot.On("Head").Return(func() *flow.Header { return s.finalizedBlock }, nil).Once()
    61  	s.state.On("Final").Return(finalSnapshot).Once()
    62  
    63  	metrics := metrics.NewNoopCollector()
    64  	var err error
    65  	s.core, err = NewComplianceCore(
    66  		unittest.Logger(),
    67  		metrics,
    68  		metrics,
    69  		s.followerConsumer,
    70  		s.state,
    71  		s.follower,
    72  		s.validator,
    73  		s.sync,
    74  		trace.NewNoopTracer(),
    75  	)
    76  	require.NoError(s.T(), err)
    77  
    78  	s.ctx, s.cancel, s.errs = irrecoverable.WithSignallerAndCancel(context.Background())
    79  	s.core.Start(s.ctx)
    80  	unittest.RequireCloseBefore(s.T(), s.core.Ready(), time.Second, "core failed to start")
    81  }
    82  
    83  // TearDownTest stops the engine and checks there are no errors thrown to the SignallerContext.
    84  func (s *CoreSuite) TearDownTest() {
    85  	s.cancel()
    86  	unittest.RequireCloseBefore(s.T(), s.core.Done(), time.Second, "core failed to stop")
    87  	select {
    88  	case err := <-s.errs:
    89  		assert.NoError(s.T(), err)
    90  	default:
    91  	}
    92  }
    93  
    94  // TestProcessingSingleBlock tests processing a range with length 1, it must result in block being validated and added to cache.
    95  // If block is already in cache it should be no-op.
    96  func (s *CoreSuite) TestProcessingSingleBlock() {
    97  	block := unittest.BlockWithParentFixture(s.finalizedBlock)
    98  
    99  	// incoming block has to be validated
   100  	s.validator.On("ValidateProposal", model.ProposalFromFlow(block.Header)).Return(nil).Once()
   101  
   102  	err := s.core.OnBlockRange(s.originID, []*flow.Block{block})
   103  	require.NoError(s.T(), err)
   104  	require.NotNil(s.T(), s.core.pendingCache.Peek(block.ID()))
   105  
   106  	err = s.core.OnBlockRange(s.originID, []*flow.Block{block})
   107  	require.NoError(s.T(), err)
   108  }
   109  
   110  // TestAddFinalizedBlock tests that adding block below finalized height results in processing it, but since cache was pruned
   111  // to finalized view, it must be rejected by it.
   112  func (s *CoreSuite) TestAddFinalizedBlock() {
   113  	block := unittest.BlockFixture()
   114  	block.Header.View = s.finalizedBlock.View - 1 // block is below finalized view
   115  
   116  	// incoming block has to be validated
   117  	s.validator.On("ValidateProposal", model.ProposalFromFlow(block.Header)).Return(nil).Once()
   118  
   119  	err := s.core.OnBlockRange(s.originID, []*flow.Block{&block})
   120  	require.NoError(s.T(), err)
   121  	require.Nil(s.T(), s.core.pendingCache.Peek(block.ID()))
   122  }
   123  
   124  // TestProcessingRangeHappyPath tests processing range of blocks with length > 1, which should result
   125  // in a chain of certified blocks that have been
   126  //  1. validated
   127  //  2. added to the pending cache
   128  //  3. added to the pending tree
   129  //  4. added to the protocol state
   130  //
   131  // Finally, the certified blocks should be forwarded to the HotStuff follower.
   132  func (s *CoreSuite) TestProcessingRangeHappyPath() {
   133  	blocks := unittest.ChainFixtureFrom(10, s.finalizedBlock)
   134  
   135  	var wg sync.WaitGroup
   136  	wg.Add(len(blocks) - 1)
   137  	for i := 1; i < len(blocks); i++ {
   138  		s.state.On("ExtendCertified", mock.Anything, blocks[i-1], blocks[i].Header.QuorumCertificate()).Return(nil).Once()
   139  		s.follower.On("AddCertifiedBlock", blockWithID(blocks[i-1].ID())).Run(func(args mock.Arguments) {
   140  			wg.Done()
   141  		}).Return().Once()
   142  	}
   143  	s.validator.On("ValidateProposal", model.ProposalFromFlow(blocks[len(blocks)-1].Header)).Return(nil).Once()
   144  
   145  	err := s.core.OnBlockRange(s.originID, blocks)
   146  	require.NoError(s.T(), err)
   147  
   148  	unittest.RequireReturnsBefore(s.T(), wg.Wait, 500*time.Millisecond, "expect all blocks to be processed before timeout")
   149  }
   150  
   151  // TestProcessingNotOrderedBatch tests that submitting a batch which is not properly ordered(meaning the batch is not connected)
   152  // has to result in error.
   153  func (s *CoreSuite) TestProcessingNotOrderedBatch() {
   154  	blocks := unittest.ChainFixtureFrom(10, s.finalizedBlock)
   155  	blocks[2], blocks[3] = blocks[3], blocks[2]
   156  
   157  	s.validator.On("ValidateProposal", model.ProposalFromFlow(blocks[len(blocks)-1].Header)).Return(nil).Once()
   158  
   159  	err := s.core.OnBlockRange(s.originID, blocks)
   160  	require.ErrorIs(s.T(), err, cache.ErrDisconnectedBatch)
   161  }
   162  
   163  // TestProcessingInvalidBlock tests that processing a batch which ends with invalid block discards the whole batch
   164  func (s *CoreSuite) TestProcessingInvalidBlock() {
   165  	blocks := unittest.ChainFixtureFrom(10, s.finalizedBlock)
   166  
   167  	invalidProposal := model.ProposalFromFlow(blocks[len(blocks)-1].Header)
   168  	sentinelError := model.NewInvalidProposalErrorf(invalidProposal, "")
   169  	s.validator.On("ValidateProposal", invalidProposal).Return(sentinelError).Once()
   170  	s.followerConsumer.On("OnInvalidBlockDetected", flow.Slashable[model.InvalidProposalError]{
   171  		OriginID: s.originID,
   172  		Message:  sentinelError.(model.InvalidProposalError),
   173  	}).Return().Once()
   174  	err := s.core.OnBlockRange(s.originID, blocks)
   175  	require.NoError(s.T(), err, "sentinel error has to be handled internally")
   176  
   177  	exception := errors.New("validate-proposal-exception")
   178  	s.validator.On("ValidateProposal", invalidProposal).Return(exception).Once()
   179  	err = s.core.OnBlockRange(s.originID, blocks)
   180  	require.ErrorIs(s.T(), err, exception, "exception has to be propagated")
   181  }
   182  
   183  // TestProcessingBlocksAfterShutdown tests that submitting blocks after shutdown doesn't block producers.
   184  func (s *CoreSuite) TestProcessingBlocksAfterShutdown() {
   185  	s.cancel()
   186  	unittest.RequireCloseBefore(s.T(), s.core.Done(), time.Second, "core failed to stop")
   187  
   188  	// at this point workers are stopped and processing valid range of connected blocks won't be delivered
   189  	// to the protocol state
   190  
   191  	blocks := unittest.ChainFixtureFrom(10, s.finalizedBlock)
   192  	s.validator.On("ValidateProposal", model.ProposalFromFlow(blocks[len(blocks)-1].Header)).Return(nil).Once()
   193  
   194  	err := s.core.OnBlockRange(s.originID, blocks)
   195  	require.NoError(s.T(), err)
   196  }
   197  
   198  // TestProcessingConnectedRangesOutOfOrder tests that processing range of connected blocks [B1 <- ... <- BN+1] our of order
   199  // results in extending [B1 <- ... <- BN] in correct order.
   200  func (s *CoreSuite) TestProcessingConnectedRangesOutOfOrder() {
   201  	blocks := unittest.ChainFixtureFrom(10, s.finalizedBlock)
   202  	midpoint := len(blocks) / 2
   203  	firstHalf, secondHalf := blocks[:midpoint], blocks[midpoint:]
   204  
   205  	s.validator.On("ValidateProposal", mock.Anything).Return(nil).Once()
   206  	err := s.core.OnBlockRange(s.originID, secondHalf)
   207  	require.NoError(s.T(), err)
   208  
   209  	var wg sync.WaitGroup
   210  	wg.Add(len(blocks) - 1)
   211  	for _, block := range blocks[:len(blocks)-1] {
   212  		s.follower.On("AddCertifiedBlock", blockWithID(block.ID())).Return().Run(func(args mock.Arguments) {
   213  			wg.Done()
   214  		}).Once()
   215  	}
   216  
   217  	lastSubmittedBlockID := flow.ZeroID
   218  	s.state.On("ExtendCertified", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
   219  		block := args.Get(1).(*flow.Block)
   220  		if lastSubmittedBlockID != flow.ZeroID {
   221  			if block.Header.ParentID != lastSubmittedBlockID {
   222  				s.Failf("blocks not sequential",
   223  					"blocks submitted to protocol state are not sequential at height %d", block.Header.Height)
   224  			}
   225  		}
   226  		lastSubmittedBlockID = block.ID()
   227  	}).Return(nil).Times(len(blocks) - 1)
   228  
   229  	s.validator.On("ValidateProposal", mock.Anything).Return(nil).Once()
   230  	err = s.core.OnBlockRange(s.originID, firstHalf)
   231  	require.NoError(s.T(), err)
   232  	unittest.RequireReturnsBefore(s.T(), wg.Wait, time.Millisecond*500, "expect to process all blocks before timeout")
   233  }
   234  
   235  // TestDetectingProposalEquivocation tests that block equivocation is properly detected and reported to specific consumer.
   236  func (s *CoreSuite) TestDetectingProposalEquivocation() {
   237  	block := unittest.BlockWithParentFixture(s.finalizedBlock)
   238  	otherBlock := unittest.BlockWithParentFixture(s.finalizedBlock)
   239  	otherBlock.Header.View = block.Header.View
   240  
   241  	s.validator.On("ValidateProposal", mock.Anything).Return(nil).Times(2)
   242  	s.followerConsumer.On("OnDoubleProposeDetected", mock.Anything, mock.Anything).Return().Once()
   243  
   244  	err := s.core.OnBlockRange(s.originID, []*flow.Block{block})
   245  	require.NoError(s.T(), err)
   246  
   247  	err = s.core.OnBlockRange(s.originID, []*flow.Block{otherBlock})
   248  	require.NoError(s.T(), err)
   249  }
   250  
   251  // TestConcurrentAdd simulates multiple workers adding batches of connected blocks out of order.
   252  // We use the following setup:
   253  // Number of workers - workers
   254  //   - Number of workers - workers
   255  //   - Number of batches submitted by worker - batchesPerWorker
   256  //   - Number of blocks in each batch submitted by worker - blocksPerBatch
   257  //   - Each worker submits batchesPerWorker*blocksPerBatch blocks
   258  //
   259  // In total we will submit workers*batchesPerWorker*blocksPerBatch
   260  // After submitting all blocks we expect that chain of blocks except last one will be added to the protocol state and
   261  // submitted for further processing to Hotstuff layer.
   262  func (s *CoreSuite) TestConcurrentAdd() {
   263  	workers := 5
   264  	batchesPerWorker := 10
   265  	blocksPerBatch := 10
   266  	blocksPerWorker := blocksPerBatch * batchesPerWorker
   267  	blocks := unittest.ChainFixtureFrom(workers*blocksPerWorker, s.finalizedBlock)
   268  	targetSubmittedBlockID := blocks[len(blocks)-2].ID()
   269  	require.Lessf(s.T(), len(blocks), defaultPendingBlocksCacheCapacity, "this test works under assumption that we operate under cache upper limit")
   270  
   271  	s.validator.On("ValidateProposal", mock.Anything).Return(nil) // any proposal is valid
   272  	done := make(chan struct{})
   273  
   274  	s.follower.On("AddCertifiedBlock", mock.Anything).Return(nil).Run(func(args mock.Arguments) {
   275  		// ensure that proposals are submitted in-order
   276  		block := args.Get(0).(*model.CertifiedBlock)
   277  		if block.ID() == targetSubmittedBlockID {
   278  			close(done)
   279  		}
   280  	}).Return().Times(len(blocks) - 1) // all proposals have to be submitted
   281  	lastSubmittedBlockID := flow.ZeroID
   282  	s.state.On("ExtendCertified", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
   283  		block := args.Get(1).(*flow.Block)
   284  		if lastSubmittedBlockID != flow.ZeroID {
   285  			if block.Header.ParentID != lastSubmittedBlockID {
   286  				s.Failf("blocks not sequential",
   287  					"blocks submitted to protocol state are not sequential at height %d", block.Header.Height)
   288  			}
   289  		}
   290  		lastSubmittedBlockID = block.ID()
   291  	}).Return(nil).Times(len(blocks) - 1)
   292  
   293  	var wg sync.WaitGroup
   294  	wg.Add(workers)
   295  
   296  	for i := 0; i < workers; i++ {
   297  		go func(blocks []*flow.Block) {
   298  			defer wg.Done()
   299  			for batch := 0; batch < batchesPerWorker; batch++ {
   300  				err := s.core.OnBlockRange(s.originID, blocks[batch*blocksPerBatch:(batch+1)*blocksPerBatch])
   301  				require.NoError(s.T(), err)
   302  			}
   303  		}(blocks[i*blocksPerWorker : (i+1)*blocksPerWorker])
   304  	}
   305  
   306  	unittest.RequireReturnsBefore(s.T(), wg.Wait, time.Millisecond*500, "should submit blocks before timeout")
   307  	unittest.AssertClosesBefore(s.T(), done, time.Millisecond*500, "should process all blocks before timeout")
   308  }
   309  
   310  // blockWithID returns a testify `argumentMatcher` that only accepts blocks with the given ID
   311  func blockWithID(expectedBlockID flow.Identifier) interface{} {
   312  	return mock.MatchedBy(func(block *model.CertifiedBlock) bool { return expectedBlockID == block.ID() })
   313  }