github.com/onflow/flow-go@v0.35.7-crescendo-preview.23-atree-inlining/engine/collection/synchronization/engine_test.go (about) 1 package synchronization 2 3 import ( 4 "io" 5 "math" 6 "math/rand" 7 "testing" 8 "time" 9 10 "github.com/rs/zerolog" 11 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/mock" 13 "github.com/stretchr/testify/require" 14 "github.com/stretchr/testify/suite" 15 16 "github.com/onflow/flow-go/engine" 17 mockcollection "github.com/onflow/flow-go/engine/collection/mock" 18 clustermodel "github.com/onflow/flow-go/model/cluster" 19 "github.com/onflow/flow-go/model/flow" 20 "github.com/onflow/flow-go/model/flow/filter" 21 "github.com/onflow/flow-go/model/messages" 22 "github.com/onflow/flow-go/module/chainsync" 23 synccore "github.com/onflow/flow-go/module/chainsync" 24 "github.com/onflow/flow-go/module/metrics" 25 module "github.com/onflow/flow-go/module/mock" 26 netint "github.com/onflow/flow-go/network" 27 "github.com/onflow/flow-go/network/channels" 28 "github.com/onflow/flow-go/network/mocknetwork" 29 clusterint "github.com/onflow/flow-go/state/cluster" 30 cluster "github.com/onflow/flow-go/state/cluster/mock" 31 storerr "github.com/onflow/flow-go/storage" 32 storage "github.com/onflow/flow-go/storage/mock" 33 "github.com/onflow/flow-go/utils/unittest" 34 ) 35 36 func TestSyncEngine(t *testing.T) { 37 suite.Run(t, new(SyncSuite)) 38 } 39 40 type SyncSuite struct { 41 suite.Suite 42 myID flow.Identifier 43 participants flow.IdentityList 44 head *flow.Header 45 heights map[uint64]*clustermodel.Block 46 blockIDs map[flow.Identifier]*clustermodel.Block 47 net *mocknetwork.Network 48 con *mocknetwork.Conduit 49 me *module.Local 50 state *cluster.State 51 snapshot *cluster.Snapshot 52 params *cluster.Params 53 blocks *storage.ClusterBlocks 54 comp *mockcollection.Compliance 55 core *module.SyncCore 56 e *Engine 57 } 58 59 func (ss *SyncSuite) SetupTest() { 60 // generate own ID 61 ss.participants = unittest.IdentityListFixture(3, unittest.WithRole(flow.RoleCollection)) 62 ss.myID = ss.participants[0].NodeID 63 64 // generate a header for the final state 65 header := unittest.BlockHeaderFixture() 66 ss.head = header 67 68 // create maps to enable block returns 69 ss.heights = make(map[uint64]*clustermodel.Block) 70 ss.blockIDs = make(map[flow.Identifier]*clustermodel.Block) 71 clusterID := header.ChainID 72 73 // set up the network module mock 74 ss.net = &mocknetwork.Network{} 75 ss.net.On("Register", channels.SyncCluster(clusterID), mock.Anything).Return( 76 func(network channels.Channel, engine netint.MessageProcessor) netint.Conduit { 77 return ss.con 78 }, 79 nil, 80 ) 81 82 // set up the network conduit mock 83 ss.con = &mocknetwork.Conduit{} 84 85 // set up the local module mock 86 ss.me = &module.Local{} 87 ss.me.On("NodeID").Return( 88 func() flow.Identifier { 89 return ss.myID 90 }, 91 ) 92 93 // set up the protocol state mock 94 ss.state = &cluster.State{} 95 ss.state.On("Params").Return( 96 func() clusterint.Params { 97 return ss.params 98 }, 99 ) 100 101 ss.params = &cluster.Params{} 102 ss.params.On("ChainID").Return(ss.head.ChainID, nil) 103 104 ss.state.On("Final").Return( 105 func() clusterint.Snapshot { 106 return ss.snapshot 107 }, 108 ) 109 110 // set up the snapshot mock 111 ss.snapshot = &cluster.Snapshot{} 112 ss.snapshot.On("Head").Return( 113 func() *flow.Header { 114 return ss.head 115 }, 116 nil, 117 ) 118 ss.snapshot.On("Identities", mock.Anything).Return( 119 func(selector flow.IdentityFilter[flow.Identity]) flow.IdentityList { 120 return ss.participants.Filter(selector) 121 }, 122 nil, 123 ) 124 125 // set up blocks storage mock 126 ss.blocks = &storage.ClusterBlocks{} 127 ss.blocks.On("ByHeight", mock.Anything).Return( 128 func(height uint64) *clustermodel.Block { 129 return ss.heights[height] 130 }, 131 func(height uint64) error { 132 _, enabled := ss.heights[height] 133 if !enabled { 134 return storerr.ErrNotFound 135 } 136 return nil 137 }, 138 ) 139 ss.blocks.On("ByID", mock.Anything).Return( 140 func(blockID flow.Identifier) *clustermodel.Block { 141 return ss.blockIDs[blockID] 142 }, 143 func(blockID flow.Identifier) error { 144 _, enabled := ss.blockIDs[blockID] 145 if !enabled { 146 return storerr.ErrNotFound 147 } 148 return nil 149 }, 150 ) 151 152 // set up compliance engine mock 153 ss.comp = mockcollection.NewCompliance(ss.T()) 154 155 // set up sync core 156 ss.core = &module.SyncCore{} 157 158 // initialize the engine 159 log := zerolog.New(io.Discard) 160 metrics := metrics.NewNoopCollector() 161 162 e, err := New(log, metrics, ss.net, ss.me, ss.participants.ToSkeleton(), ss.state, ss.blocks, ss.comp, ss.core) 163 require.NoError(ss.T(), err, "should pass engine initialization") 164 165 ss.e = e 166 } 167 168 func (ss *SyncSuite) TestOnSyncRequest() { 169 170 // generate origin and request message 171 originID := unittest.IdentifierFixture() 172 req := &messages.SyncRequest{ 173 Nonce: rand.Uint64(), 174 Height: 0, 175 } 176 177 // regardless of request height, if within tolerance, we should not respond 178 ss.core.On("HandleHeight", ss.head, req.Height) 179 ss.core.On("WithinTolerance", ss.head, req.Height).Return(true) 180 err := ss.e.requestHandler.onSyncRequest(originID, req) 181 ss.Assert().NoError(err, "same height sync request should pass") 182 ss.con.AssertNotCalled(ss.T(), "Unicast", mock.Anything, mock.Anything) 183 184 // if request height is higher than local finalized, we should not respond 185 req.Height = ss.head.Height + 1 186 ss.core.On("HandleHeight", ss.head, req.Height) 187 ss.core.On("WithinTolerance", ss.head, req.Height).Return(false) 188 err = ss.e.requestHandler.onSyncRequest(originID, req) 189 ss.Assert().NoError(err, "same height sync request should pass") 190 ss.con.AssertNotCalled(ss.T(), "Unicast", mock.Anything, mock.Anything) 191 192 // if the request height is lower than head and outside tolerance, we should submit correct response 193 req.Height = ss.head.Height - 1 194 ss.core.On("HandleHeight", ss.head, req.Height) 195 ss.core.On("WithinTolerance", ss.head, req.Height).Return(false) 196 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Run( 197 func(args mock.Arguments) { 198 res := args.Get(0).(*messages.SyncResponse) 199 assert.Equal(ss.T(), ss.head.Height, res.Height, "response should contain head height") 200 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 201 recipientID := args.Get(1).(flow.Identifier) 202 assert.Equal(ss.T(), originID, recipientID, "should send response to original sender") 203 }, 204 ) 205 err = ss.e.requestHandler.onSyncRequest(originID, req) 206 require.NoError(ss.T(), err, "smaller height sync request should pass") 207 208 ss.core.AssertExpectations(ss.T()) 209 } 210 211 func (ss *SyncSuite) TestOnSyncResponse() { 212 213 // generate origin ID and response message 214 originID := unittest.IdentifierFixture() 215 res := &messages.SyncResponse{ 216 Nonce: rand.Uint64(), 217 Height: rand.Uint64(), 218 } 219 220 // the height should be handled 221 ss.core.On("HandleHeight", ss.head, res.Height) 222 ss.e.onSyncResponse(originID, res) 223 ss.core.AssertExpectations(ss.T()) 224 } 225 226 func (ss *SyncSuite) TestOnRangeRequest() { 227 228 // generate originID and range request 229 originID := unittest.IdentifierFixture() 230 req := &messages.RangeRequest{ 231 Nonce: rand.Uint64(), 232 FromHeight: 0, 233 ToHeight: 0, 234 } 235 236 // fill in blocks at heights -1 to -4 from head 237 ref := ss.head.Height 238 for height := ref; height >= ref-4; height-- { 239 block := unittest.ClusterBlockFixture() 240 block.Header.Height = height 241 ss.heights[height] = &block 242 } 243 244 // empty range should be a no-op 245 ss.T().Run("empty range", func(t *testing.T) { 246 req.FromHeight = ref 247 req.ToHeight = ref - 1 248 err := ss.e.requestHandler.onRangeRequest(originID, req) 249 require.NoError(ss.T(), err, "empty range request should pass") 250 ss.con.AssertNumberOfCalls(ss.T(), "Unicast", 0) 251 }) 252 253 // range with only unknown block should be a no-op 254 ss.T().Run("range with unknown block", func(t *testing.T) { 255 req.FromHeight = ref + 1 256 req.ToHeight = ref + 3 257 err := ss.e.requestHandler.onRangeRequest(originID, req) 258 require.NoError(ss.T(), err, "unknown range request should pass") 259 ss.con.AssertNumberOfCalls(ss.T(), "Unicast", 0) 260 }) 261 262 // a request for same from and to should send single block 263 ss.T().Run("from == to", func(t *testing.T) { 264 req.FromHeight = ref - 1 265 req.ToHeight = ref - 1 266 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Once().Run( 267 func(args mock.Arguments) { 268 res := args.Get(0).(*messages.ClusterBlockResponse) 269 expected := ss.heights[ref-1] 270 actual := res.Blocks[0].ToInternal() 271 assert.Equal(ss.T(), expected.ID(), actual.ID(), "response should contain right block") 272 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 273 recipientID := args.Get(1).(flow.Identifier) 274 assert.Equal(ss.T(), originID, recipientID, "should send response to original requester") 275 }, 276 ) 277 err := ss.e.requestHandler.onRangeRequest(originID, req) 278 require.NoError(ss.T(), err, "range request with higher to height should pass") 279 }) 280 281 // a request for a range that we partially have should send partial response 282 ss.T().Run("have partial range", func(t *testing.T) { 283 req.FromHeight = ref - 2 284 req.ToHeight = ref + 2 285 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Once().Run( 286 func(args mock.Arguments) { 287 res := args.Get(0).(*messages.ClusterBlockResponse) 288 expected := []*clustermodel.Block{ss.heights[ref-2], ss.heights[ref-1], ss.heights[ref]} 289 assert.ElementsMatch(ss.T(), expected, res.BlocksInternal(), "response should contain right blocks") 290 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 291 recipientID := args.Get(1).(flow.Identifier) 292 assert.Equal(ss.T(), originID, recipientID, "should send response to original requester") 293 }, 294 ) 295 err := ss.e.requestHandler.onRangeRequest(originID, req) 296 require.NoError(ss.T(), err, "valid range with missing blocks should fail") 297 }) 298 299 // a request for a range we entirely have should send all blocks 300 ss.T().Run("have entire range", func(t *testing.T) { 301 req.FromHeight = ref - 2 302 req.ToHeight = ref 303 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Once().Run( 304 func(args mock.Arguments) { 305 res := args.Get(0).(*messages.ClusterBlockResponse) 306 expected := []*clustermodel.Block{ss.heights[ref-2], ss.heights[ref-1], ss.heights[ref]} 307 assert.ElementsMatch(ss.T(), expected, res.BlocksInternal(), "response should contain right blocks") 308 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 309 recipientID := args.Get(1).(flow.Identifier) 310 assert.Equal(ss.T(), originID, recipientID, "should send response to original requester") 311 }, 312 ) 313 err := ss.e.requestHandler.onRangeRequest(originID, req) 314 require.NoError(ss.T(), err, "valid range request should pass") 315 }) 316 317 // a request for an oversized range should return blocks within range 318 ss.T().Run("oversized range", func(t *testing.T) { 319 // range should get reset to FromHeight to FromHeight + MaxSize (i.e. MaxSize+1 blocks) 320 req.FromHeight = ref - 4 321 req.ToHeight = math.MaxUint64 322 323 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Once().Run( 324 func(args mock.Arguments) { 325 res := args.Get(0).(*messages.ClusterBlockResponse) 326 expected := []*clustermodel.Block{ss.heights[ref-4], ss.heights[ref-3], ss.heights[ref-2]} 327 assert.ElementsMatch(ss.T(), expected, res.BlocksInternal(), "response should contain right blocks") 328 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 329 recipientID := args.Get(1).(flow.Identifier) 330 assert.Equal(ss.T(), originID, recipientID, "should send response to original requester") 331 }, 332 ) 333 334 // Rebuild sync core with a smaller max size 335 var err error 336 config := chainsync.DefaultConfig() 337 config.MaxSize = 2 338 ss.e.requestHandler.core, err = chainsync.New(ss.e.log, config, metrics.NewNoopCollector(), flow.Localnet) 339 require.NoError(ss.T(), err) 340 341 err = ss.e.requestHandler.onRangeRequest(originID, req) 342 require.NoError(ss.T(), err, "valid range request should pass") 343 }) 344 } 345 346 func (ss *SyncSuite) TestOnBatchRequest() { 347 348 // generate origin ID and batch request 349 originID := unittest.IdentifierFixture() 350 req := &messages.BatchRequest{ 351 Nonce: rand.Uint64(), 352 BlockIDs: nil, 353 } 354 355 // an empty request should not lead to response 356 ss.T().Run("empty request", func(t *testing.T) { 357 req.BlockIDs = []flow.Identifier{} 358 err := ss.e.requestHandler.onBatchRequest(originID, req) 359 require.NoError(ss.T(), err, "should pass empty request") 360 ss.con.AssertNumberOfCalls(ss.T(), "Unicast", 0) 361 }) 362 363 // a non-empty request for missing block ID should be a no-op 364 ss.T().Run("request for missing blocks", func(t *testing.T) { 365 req.BlockIDs = unittest.IdentifierListFixture(1) 366 err := ss.e.requestHandler.onBatchRequest(originID, req) 367 require.NoError(ss.T(), err, "should pass request for missing block") 368 ss.con.AssertNumberOfCalls(ss.T(), "Unicast", 0) 369 }) 370 371 // a non-empty request for existing block IDs should send right response 372 ss.T().Run("request for existing blocks", func(t *testing.T) { 373 block := unittest.ClusterBlockFixture() 374 block.Header.Height = ss.head.Height - 1 375 req.BlockIDs = []flow.Identifier{block.ID()} 376 ss.blockIDs[block.ID()] = &block 377 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Once().Run( 378 func(args mock.Arguments) { 379 res := args.Get(0).(*messages.ClusterBlockResponse) 380 assert.Equal(ss.T(), &block, res.Blocks[0].ToInternal(), "response should contain right block") 381 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 382 recipientID := args.Get(1).(flow.Identifier) 383 assert.Equal(ss.T(), originID, recipientID, "response should be send to original requester") 384 }, 385 ) 386 err := ss.e.requestHandler.onBatchRequest(originID, req) 387 require.NoError(ss.T(), err, "should pass request with valid block") 388 }) 389 390 // a request for an oversized batch should return MaxSize blocks 391 ss.T().Run("oversized range", func(t *testing.T) { 392 // setup request for 5 blocks. response should contain the first 2 (MaxSize) 393 ss.blockIDs = make(map[flow.Identifier]*clustermodel.Block) 394 req.BlockIDs = make([]flow.Identifier, 5) 395 for i := 0; i < len(req.BlockIDs); i++ { 396 b := unittest.ClusterBlockFixture() 397 b.Header.Height = ss.head.Height - uint64(i) 398 req.BlockIDs[i] = b.ID() 399 ss.blockIDs[b.ID()] = &b 400 } 401 402 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil).Once().Run( 403 func(args mock.Arguments) { 404 res := args.Get(0).(*messages.ClusterBlockResponse) 405 assert.ElementsMatch(ss.T(), []*clustermodel.Block{ss.blockIDs[req.BlockIDs[0]], ss.blockIDs[req.BlockIDs[1]]}, res.BlocksInternal(), "response should contain right block") 406 assert.Equal(ss.T(), req.Nonce, res.Nonce, "response should contain request nonce") 407 recipientID := args.Get(1).(flow.Identifier) 408 assert.Equal(ss.T(), originID, recipientID, "response should be send to original requester") 409 }, 410 ) 411 412 // Rebuild sync core with a smaller max size 413 var err error 414 config := chainsync.DefaultConfig() 415 config.MaxSize = 2 416 ss.e.requestHandler.core, err = chainsync.New(ss.e.log, config, metrics.NewNoopCollector(), flow.Localnet) 417 require.NoError(ss.T(), err) 418 419 err = ss.e.requestHandler.onBatchRequest(originID, req) 420 require.NoError(ss.T(), err, "should pass request with valid block") 421 }) 422 } 423 424 func (ss *SyncSuite) TestOnBlockResponse() { 425 426 // generate origin and block response 427 originID := unittest.IdentifierFixture() 428 res := &messages.ClusterBlockResponse{ 429 Nonce: rand.Uint64(), 430 Blocks: []messages.UntrustedClusterBlock{}, 431 } 432 433 // add one block that should be processed 434 processable := unittest.ClusterBlockFixture() 435 ss.core.On("HandleBlock", processable.Header).Return(true) 436 res.Blocks = append(res.Blocks, messages.UntrustedClusterBlockFromInternal(&processable)) 437 438 // add one block that should not be processed 439 unprocessable := unittest.ClusterBlockFixture() 440 ss.core.On("HandleBlock", unprocessable.Header).Return(false) 441 res.Blocks = append(res.Blocks, messages.UntrustedClusterBlockFromInternal(&unprocessable)) 442 443 ss.comp.On("OnSyncedClusterBlock", mock.Anything).Run(func(args mock.Arguments) { 444 res := args.Get(0).(flow.Slashable[*messages.ClusterBlockProposal]) 445 converted := res.Message.Block.ToInternal() 446 ss.Assert().Equal(processable.Header, converted.Header) 447 ss.Assert().Equal(processable.Payload, converted.Payload) 448 ss.Assert().Equal(originID, res.OriginID) 449 }).Return(nil) 450 451 ss.e.onBlockResponse(originID, res) 452 ss.comp.AssertExpectations(ss.T()) 453 ss.core.AssertExpectations(ss.T()) 454 } 455 456 func (ss *SyncSuite) TestPollHeight() { 457 458 // check that we send to three nodes from our total list 459 others := ss.participants.Filter(filter.HasNodeID[flow.Identity](ss.participants[1:].NodeIDs()...)) 460 ss.con.On("Multicast", mock.Anything, synccore.DefaultPollNodes, others[0].NodeID, others[1].NodeID).Return(nil).Run( 461 func(args mock.Arguments) { 462 req := args.Get(0).(*messages.SyncRequest) 463 require.Equal(ss.T(), ss.head.Height, req.Height, "request should contain finalized height") 464 }, 465 ) 466 ss.e.pollHeight() 467 ss.con.AssertExpectations(ss.T()) 468 } 469 470 func (ss *SyncSuite) TestSendRequests() { 471 472 ranges := unittest.RangeListFixture(1) 473 batches := unittest.BatchListFixture(1) 474 475 // should submit and mark requested all ranges 476 ss.con.On("Multicast", mock.AnythingOfType("*messages.RangeRequest"), synccore.DefaultBlockRequestNodes, mock.Anything, mock.Anything).Return(nil).Run( 477 func(args mock.Arguments) { 478 req := args.Get(0).(*messages.RangeRequest) 479 ss.Assert().Equal(ranges[0].From, req.FromHeight) 480 ss.Assert().Equal(ranges[0].To, req.ToHeight) 481 }, 482 ) 483 ss.core.On("RangeRequested", ranges[0]) 484 485 // should submit and mark requested all batches 486 ss.con.On("Multicast", mock.AnythingOfType("*messages.BatchRequest"), synccore.DefaultBlockRequestNodes, mock.Anything, mock.Anything, mock.Anything).Return(nil).Run( 487 func(args mock.Arguments) { 488 req := args.Get(0).(*messages.BatchRequest) 489 ss.Assert().Equal(batches[0].BlockIDs, req.BlockIDs) 490 }, 491 ) 492 ss.core.On("BatchRequested", batches[0]) 493 494 // exclude my node ID 495 ss.e.sendRequests(ranges, batches) 496 ss.con.AssertExpectations(ss.T()) 497 } 498 499 // test a synchronization engine can be started and stopped 500 func (ss *SyncSuite) TestStartStop() { 501 unittest.AssertReturnsBefore(ss.T(), func() { 502 <-ss.e.Ready() 503 <-ss.e.Done() 504 }, time.Second) 505 } 506 507 // TestProcessingMultipleItems tests that items are processed in async way 508 func (ss *SyncSuite) TestProcessingMultipleItems() { 509 <-ss.e.Ready() 510 511 originID := unittest.IdentifierFixture() 512 for i := 0; i < 5; i++ { 513 msg := &messages.SyncResponse{ 514 Nonce: uint64(i), 515 Height: uint64(1000 + i), 516 } 517 ss.core.On("HandleHeight", mock.Anything, msg.Height).Once() 518 require.NoError(ss.T(), ss.e.Process(channels.SyncCommittee, originID, msg)) 519 } 520 521 finalHeight := ss.head.Height 522 for i := 0; i < 5; i++ { 523 msg := &messages.SyncRequest{ 524 Nonce: uint64(i), 525 Height: finalHeight - 100, 526 } 527 528 originID := unittest.IdentifierFixture() 529 ss.core.On("WithinTolerance", mock.Anything, mock.Anything).Return(false).Once() 530 ss.core.On("HandleHeight", mock.Anything, msg.Height).Once() 531 ss.con.On("Unicast", mock.Anything, mock.Anything).Return(nil) 532 533 require.NoError(ss.T(), ss.e.Process(channels.SyncCommittee, originID, msg)) 534 } 535 536 // give at least some time to process items 537 time.Sleep(time.Millisecond * 100) 538 539 ss.core.AssertExpectations(ss.T()) 540 } 541 542 // TestProcessUnsupportedMessageType tests that Process and ProcessLocal correctly handle a case where invalid message type 543 // was submitted from network layer. 544 func (ss *SyncSuite) TestProcessUnsupportedMessageType() { 545 invalidEvent := uint64(42) 546 engines := []netint.Engine{ss.e, ss.e.requestHandler} 547 for _, e := range engines { 548 err := e.Process("ch", unittest.IdentifierFixture(), invalidEvent) 549 // shouldn't result in error since byzantine inputs are expected 550 require.NoError(ss.T(), err) 551 // in case of local processing error cannot be consumed since all inputs are trusted 552 err = e.ProcessLocal(invalidEvent) 553 require.Error(ss.T(), err) 554 require.True(ss.T(), engine.IsIncompatibleInputTypeError(err)) 555 } 556 }