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 }