github.com/onflow/flow-go@v0.35.7-crescendo-preview.23-atree-inlining/consensus/hotstuff/cruisectl/block_time_controller_test.go (about) 1 package cruisectl 2 3 import ( 4 "context" 5 "fmt" 6 "math" 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 "go.uber.org/atomic" 15 16 "github.com/onflow/flow-go/consensus/hotstuff/model" 17 "github.com/onflow/flow-go/model/flow" 18 "github.com/onflow/flow-go/module/irrecoverable" 19 mockmodule "github.com/onflow/flow-go/module/mock" 20 mockprotocol "github.com/onflow/flow-go/state/protocol/mock" 21 "github.com/onflow/flow-go/utils/unittest" 22 "github.com/onflow/flow-go/utils/unittest/mocks" 23 ) 24 25 // BlockTimeControllerSuite encapsulates tests for the BlockTimeController. 26 type BlockTimeControllerSuite struct { 27 suite.Suite 28 29 initialView uint64 30 epochCounter uint64 31 curEpochFirstView uint64 32 curEpochFinalView uint64 33 curEpochTargetDuration uint64 34 curEpochTargetEndTime uint64 35 epochFallbackTriggered bool 36 37 metrics mockmodule.CruiseCtlMetrics 38 state mockprotocol.State 39 params mockprotocol.Params 40 snapshot mockprotocol.Snapshot 41 epochs mocks.EpochQuery 42 curEpoch mockprotocol.Epoch 43 44 config *Config 45 ctx irrecoverable.SignalerContext 46 cancel context.CancelFunc 47 ctl *BlockTimeController 48 } 49 50 func TestBlockTimeController(t *testing.T) { 51 suite.Run(t, new(BlockTimeControllerSuite)) 52 } 53 54 // EpochDurationSeconds returns the number of seconds in the epoch (1hr). 55 func (bs *BlockTimeControllerSuite) EpochDurationSeconds() uint64 { 56 return 60 * 60 57 } 58 59 // SetupTest initializes mocks and default values. 60 func (bs *BlockTimeControllerSuite) SetupTest() { 61 bs.config = DefaultConfig() 62 bs.config.MaxViewDuration = atomic.NewDuration(2 * time.Second) 63 bs.config.Enabled.Store(true) 64 bs.initialView = 0 65 bs.epochCounter = uint64(0) 66 bs.curEpochFirstView = uint64(0) 67 bs.curEpochFinalView = bs.EpochDurationSeconds() - 1 // 1 view/sec for 1hr epoch; term `-1` is needed because view 0 also takes 1 second 68 bs.curEpochTargetDuration = bs.EpochDurationSeconds() 69 bs.curEpochTargetEndTime = uint64(time.Now().Unix()) + bs.EpochDurationSeconds() 70 bs.epochFallbackTriggered = false 71 setupMocks(bs) 72 } 73 74 func setupMocks(bs *BlockTimeControllerSuite) { 75 bs.metrics = *mockmodule.NewCruiseCtlMetrics(bs.T()) 76 bs.metrics.On("PIDError", mock.Anything, mock.Anything, mock.Anything).Maybe() 77 bs.metrics.On("TargetProposalDuration", mock.Anything).Maybe() 78 bs.metrics.On("ControllerOutput", mock.Anything).Maybe() 79 80 bs.state = *mockprotocol.NewState(bs.T()) 81 bs.params = *mockprotocol.NewParams(bs.T()) 82 bs.snapshot = *mockprotocol.NewSnapshot(bs.T()) 83 bs.epochs = *mocks.NewEpochQuery(bs.T(), bs.epochCounter) 84 bs.curEpoch = *mockprotocol.NewEpoch(bs.T()) 85 86 bs.state.On("Final").Return(&bs.snapshot) 87 bs.state.On("AtHeight", mock.Anything).Return(&bs.snapshot).Maybe() 88 bs.state.On("Params").Return(&bs.params) 89 bs.params.On("EpochFallbackTriggered").Return( 90 func() bool { return bs.epochFallbackTriggered }, 91 func() error { return nil }) 92 bs.snapshot.On("Phase").Return( 93 func() flow.EpochPhase { return bs.epochs.Phase() }, 94 func() error { return nil }) 95 bs.snapshot.On("Head").Return(unittest.BlockHeaderFixture(unittest.HeaderWithView(bs.initialView+11)), nil).Maybe() 96 bs.snapshot.On("Epochs").Return(&bs.epochs) 97 bs.curEpoch.On("Counter").Return(bs.epochCounter, nil) 98 bs.curEpoch.On("FirstView").Return(bs.curEpochFirstView, nil) 99 bs.curEpoch.On("FinalView").Return(bs.curEpochFinalView, nil) 100 bs.curEpoch.On("TargetDuration").Return(bs.curEpochTargetDuration, nil) 101 bs.curEpoch.On("TargetEndTime").Return(bs.curEpochTargetEndTime, nil) 102 bs.epochs.Add(&bs.curEpoch) 103 104 bs.ctx, bs.cancel = irrecoverable.NewMockSignalerContextWithCancel(bs.T(), context.Background()) 105 } 106 107 // CreateAndStartController creates and starts the BlockTimeController. 108 // Should be called only once per test case. 109 func (bs *BlockTimeControllerSuite) CreateAndStartController() { 110 ctl, err := NewBlockTimeController(unittest.Logger(), &bs.metrics, bs.config, &bs.state, bs.initialView) 111 require.NoError(bs.T(), err) 112 bs.ctl = ctl 113 bs.ctl.Start(bs.ctx) 114 unittest.RequireCloseBefore(bs.T(), bs.ctl.Ready(), time.Second, "component did not start") 115 } 116 117 // StopController stops the BlockTimeController. 118 func (bs *BlockTimeControllerSuite) StopController() { 119 bs.cancel() 120 unittest.RequireCloseBefore(bs.T(), bs.ctl.Done(), time.Second, "component did not stop") 121 } 122 123 // AssertCorrectInitialization checks that the controller is configured as expected after construction. 124 func (bs *BlockTimeControllerSuite) AssertCorrectInitialization() { 125 // at initialization, controller should be set up to release blocks without delay 126 controllerTiming := bs.ctl.getProposalTiming() 127 now := time.Now().UTC() 128 129 if bs.ctl.epochFallbackTriggered || !bs.ctl.config.Enabled.Load() { 130 // if epoch fallback is triggered or controller is disabled, use fallback timing 131 assert.Equal(bs.T(), now.Add(bs.ctl.config.FallbackProposalDelay.Load()), controllerTiming.TargetPublicationTime(7, now, unittest.IdentifierFixture())) 132 } else { 133 // otherwise should publish immediately 134 assert.Equal(bs.T(), now, controllerTiming.TargetPublicationTime(7, now, unittest.IdentifierFixture())) 135 } 136 if bs.ctl.epochFallbackTriggered { 137 return 138 } 139 140 // should initialize epoch info 141 epoch := bs.ctl.epochInfo 142 assert.Equal(bs.T(), bs.curEpochFirstView, epoch.curEpochFirstView) 143 assert.Equal(bs.T(), bs.curEpochFinalView, epoch.curEpochFinalView) 144 assert.Equal(bs.T(), bs.curEpochTargetDuration, epoch.curEpochTargetDuration) 145 assert.Equal(bs.T(), bs.curEpochTargetEndTime, epoch.curEpochTargetEndTime) 146 147 // if next epoch is set up, final view should be set 148 if phase := bs.epochs.Phase(); phase > flow.EpochPhaseStaking { 149 finalView, err := bs.epochs.Next().FinalView() 150 require.NoError(bs.T(), err) 151 assert.Equal(bs.T(), finalView, *epoch.nextEpochFinalView) 152 } else { 153 assert.Nil(bs.T(), epoch.nextEpochFinalView) 154 } 155 156 // should create an initial measurement 157 assert.Equal(bs.T(), bs.initialView, controllerTiming.ObservationView()) 158 assert.WithinDuration(bs.T(), time.Now(), controllerTiming.ObservationTime(), time.Minute) 159 // errors should be initialized to zero 160 assert.Equal(bs.T(), float64(0), bs.ctl.proportionalErr.Value()) 161 assert.Equal(bs.T(), float64(0), bs.ctl.integralErr.Value()) 162 } 163 164 // SanityCheckSubsequentMeasurements checks that two consecutive states of the BlockTimeController are different or equal and 165 // broadly reasonable. It does not assert exact values, because part of the measurements depend on timing in the worker. 166 func (bs *BlockTimeControllerSuite) SanityCheckSubsequentMeasurements(d1, d2 *controllerStateDigest, expectedEqual bool) { 167 if expectedEqual { 168 // later input should have left state invariant, including the Observation 169 assert.Equal(bs.T(), d1.latestProposalTiming.ObservationTime(), d2.latestProposalTiming.ObservationTime()) 170 assert.Equal(bs.T(), d1.latestProposalTiming.ObservationView(), d2.latestProposalTiming.ObservationView()) 171 // new measurement should have same error 172 assert.Equal(bs.T(), d1.proportionalErr.Value(), d2.proportionalErr.Value()) 173 assert.Equal(bs.T(), d1.integralErr.Value(), d2.integralErr.Value()) 174 } else { 175 // later input should have caused a new Observation to be recorded 176 assert.True(bs.T(), d1.latestProposalTiming.ObservationTime().Before(d2.latestProposalTiming.ObservationTime())) 177 // new measurement should have different error 178 assert.NotEqual(bs.T(), d1.proportionalErr.Value(), d2.proportionalErr.Value()) 179 assert.NotEqual(bs.T(), d1.integralErr.Value(), d2.integralErr.Value()) 180 } 181 } 182 183 // PrintMeasurement prints the current state of the controller and the last measurement. 184 func (bs *BlockTimeControllerSuite) PrintMeasurement(parentBlockId flow.Identifier) { 185 ctl := bs.ctl 186 m := ctl.getProposalTiming() 187 tpt := m.TargetPublicationTime(m.ObservationView()+1, m.ObservationTime(), parentBlockId) 188 fmt.Printf("v=%d\tt=%s\tPD=%s\te_N=%.3f\tI_M=%.3f\n", 189 m.ObservationView(), m.ObservationTime(), tpt.Sub(m.ObservationTime()), 190 ctl.proportionalErr.Value(), ctl.integralErr.Value()) 191 } 192 193 // TestStartStop tests that the component can be started and stopped gracefully. 194 func (bs *BlockTimeControllerSuite) TestStartStop() { 195 bs.CreateAndStartController() 196 bs.StopController() 197 } 198 199 // TestInit_EpochStakingPhase tests initializing the component in the EpochStaking phase. 200 // Measurement and epoch info should be initialized, next epoch final view should be nil. 201 func (bs *BlockTimeControllerSuite) TestInit_EpochStakingPhase() { 202 bs.CreateAndStartController() 203 defer bs.StopController() 204 bs.AssertCorrectInitialization() 205 } 206 207 // TestInit_EpochStakingPhase tests initializing the component in the EpochSetup phase. 208 // Measurement and epoch info should be initialized, next epoch final view should be set. 209 func (bs *BlockTimeControllerSuite) TestInit_EpochSetupPhase() { 210 nextEpoch := mockprotocol.NewEpoch(bs.T()) 211 nextEpoch.On("Counter").Return(bs.epochCounter+1, nil) 212 nextEpoch.On("FinalView").Return(bs.curEpochFinalView*2, nil) 213 nextEpoch.On("TargetDuration").Return(bs.EpochDurationSeconds(), nil) 214 nextEpoch.On("TargetEndTime").Return(bs.curEpochTargetEndTime+bs.EpochDurationSeconds(), nil) 215 bs.epochs.Add(nextEpoch) 216 217 bs.CreateAndStartController() 218 defer bs.StopController() 219 bs.AssertCorrectInitialization() 220 } 221 222 // TestInit_EpochFallbackTriggered tests initializing the component when epoch fallback is triggered. 223 // Default GetProposalTiming should be set. 224 func (bs *BlockTimeControllerSuite) TestInit_EpochFallbackTriggered() { 225 bs.epochFallbackTriggered = true 226 bs.CreateAndStartController() 227 defer bs.StopController() 228 bs.AssertCorrectInitialization() 229 } 230 231 // TestEpochFallbackTriggered tests epoch fallback: 232 // - the GetProposalTiming should revert to default 233 // - duplicate events should be no-ops 234 func (bs *BlockTimeControllerSuite) TestEpochFallbackTriggered() { 235 bs.CreateAndStartController() 236 defer bs.StopController() 237 238 // update error so that GetProposalTiming is non-default 239 bs.ctl.proportionalErr.AddObservation(20.0) 240 bs.ctl.integralErr.AddObservation(20.0) 241 err := bs.ctl.measureViewDuration(makeTimedBlock(bs.initialView+1, unittest.IdentifierFixture(), time.Now())) 242 require.NoError(bs.T(), err) 243 assert.NotEqual(bs.T(), bs.config.FallbackProposalDelay, bs.ctl.getProposalTiming()) 244 245 // send the event 246 bs.ctl.EpochEmergencyFallbackTriggered() 247 // async: should revert to default GetProposalTiming 248 require.Eventually(bs.T(), func() bool { 249 now := time.Now().UTC() 250 return now.Add(bs.config.FallbackProposalDelay.Load()) == bs.ctl.getProposalTiming().TargetPublicationTime(7, now, unittest.IdentifierFixture()) 251 }, time.Second, time.Millisecond) 252 253 // additional EpochEmergencyFallbackTriggered events should be no-ops 254 // (send capacity+1 events to guarantee one is processed) 255 for i := 0; i <= cap(bs.ctl.epochFallbacks); i++ { 256 bs.ctl.EpochEmergencyFallbackTriggered() 257 } 258 // state should be unchanged 259 now := time.Now().UTC() 260 assert.Equal(bs.T(), now.Add(bs.config.FallbackProposalDelay.Load()), bs.ctl.getProposalTiming().TargetPublicationTime(12, now, unittest.IdentifierFixture())) 261 262 // additional OnBlockIncorporated events should be no-ops 263 for i := 0; i <= cap(bs.ctl.incorporatedBlocks); i++ { 264 header := unittest.BlockHeaderFixture(unittest.HeaderWithView(bs.initialView + 1 + uint64(i))) 265 header.ParentID = unittest.IdentifierFixture() 266 bs.ctl.OnBlockIncorporated(model.BlockFromFlow(header)) 267 } 268 // wait for the channel to drain, since OnBlockIncorporated doesn't block on sending 269 require.Eventually(bs.T(), func() bool { 270 return len(bs.ctl.incorporatedBlocks) == 0 271 }, time.Second, time.Millisecond) 272 // state should be unchanged 273 now = time.Now().UTC() 274 assert.Equal(bs.T(), now.Add(bs.config.FallbackProposalDelay.Load()), bs.ctl.getProposalTiming().TargetPublicationTime(17, now, unittest.IdentifierFixture())) 275 } 276 277 // TestOnBlockIncorporated_UpdateProposalDelay tests that a new measurement is taken and 278 // GetProposalTiming updated upon receiving an OnBlockIncorporated event. 279 func (bs *BlockTimeControllerSuite) TestOnBlockIncorporated_UpdateProposalDelay() { 280 bs.CreateAndStartController() 281 defer bs.StopController() 282 283 initialControllerState := captureControllerStateDigest(bs.ctl) // copy initial controller state 284 initialProposalDelay := bs.ctl.getProposalTiming() 285 block := model.BlockFromFlow(unittest.BlockHeaderFixture(unittest.HeaderWithView(bs.initialView + 1))) 286 bs.ctl.OnBlockIncorporated(block) 287 require.Eventually(bs.T(), func() bool { 288 return bs.ctl.getProposalTiming().ObservationView() > bs.initialView 289 }, time.Second, time.Millisecond) 290 nextControllerState := captureControllerStateDigest(bs.ctl) 291 nextProposalDelay := bs.ctl.getProposalTiming() 292 293 bs.SanityCheckSubsequentMeasurements(initialControllerState, nextControllerState, false) 294 // new measurement should update GetProposalTiming 295 now := time.Now().UTC() 296 assert.NotEqual(bs.T(), 297 initialProposalDelay.TargetPublicationTime(bs.initialView+2, now, unittest.IdentifierFixture()), 298 nextProposalDelay.TargetPublicationTime(bs.initialView+2, now, block.BlockID)) 299 300 // duplicate events should be no-ops 301 for i := 0; i <= cap(bs.ctl.incorporatedBlocks); i++ { 302 bs.ctl.OnBlockIncorporated(block) 303 } 304 // wait for the channel to drain, since OnBlockIncorporated doesn't block on sending 305 require.Eventually(bs.T(), func() bool { 306 return len(bs.ctl.incorporatedBlocks) == 0 307 }, time.Second, time.Millisecond) 308 309 // state should be unchanged 310 finalControllerState := captureControllerStateDigest(bs.ctl) 311 bs.SanityCheckSubsequentMeasurements(nextControllerState, finalControllerState, true) 312 assert.Equal(bs.T(), nextProposalDelay, bs.ctl.getProposalTiming()) 313 } 314 315 // TestEnableDisable tests that the controller responds to enabling and disabling. 316 func (bs *BlockTimeControllerSuite) TestEnableDisable() { 317 // start in a disabled state 318 err := bs.config.SetEnabled(false) 319 require.NoError(bs.T(), err) 320 bs.CreateAndStartController() 321 defer bs.StopController() 322 323 now := time.Now() 324 325 initialControllerState := captureControllerStateDigest(bs.ctl) 326 initialProposalDelay := bs.ctl.getProposalTiming() 327 // the initial proposal timing should use fallback timing 328 assert.Equal(bs.T(), now.Add(bs.ctl.config.FallbackProposalDelay.Load()), initialProposalDelay.TargetPublicationTime(bs.initialView+1, now, unittest.IdentifierFixture())) 329 330 block := model.BlockFromFlow(unittest.BlockHeaderFixture(unittest.HeaderWithView(bs.initialView + 1))) 331 bs.ctl.OnBlockIncorporated(block) 332 require.Eventually(bs.T(), func() bool { 333 return bs.ctl.getProposalTiming().ObservationView() > bs.initialView 334 }, time.Second, time.Millisecond) 335 secondProposalDelay := bs.ctl.getProposalTiming() 336 337 // new measurement should not change GetProposalTiming 338 assert.Equal(bs.T(), 339 initialProposalDelay.TargetPublicationTime(bs.initialView+2, now, unittest.IdentifierFixture()), 340 secondProposalDelay.TargetPublicationTime(bs.initialView+2, now, block.BlockID)) 341 342 // now, enable the controller 343 err = bs.ctl.config.SetEnabled(true) 344 require.NoError(bs.T(), err) 345 346 // send another block 347 block = model.BlockFromFlow(unittest.BlockHeaderFixture(unittest.HeaderWithView(bs.initialView + 2))) 348 bs.ctl.OnBlockIncorporated(block) 349 require.Eventually(bs.T(), func() bool { 350 return bs.ctl.getProposalTiming().ObservationView() > bs.initialView 351 }, time.Second, time.Millisecond) 352 353 thirdControllerState := captureControllerStateDigest(bs.ctl) 354 thirdProposalDelay := bs.ctl.getProposalTiming() 355 356 // new measurement should change GetProposalTiming 357 bs.SanityCheckSubsequentMeasurements(initialControllerState, thirdControllerState, false) 358 assert.NotEqual(bs.T(), 359 initialProposalDelay.TargetPublicationTime(bs.initialView+3, now, unittest.IdentifierFixture()), 360 thirdProposalDelay.TargetPublicationTime(bs.initialView+3, now, block.BlockID)) 361 362 } 363 364 // TestOnBlockIncorporated_EpochTransition_Enabled tests epoch transition with controller enabled. 365 func (bs *BlockTimeControllerSuite) TestOnBlockIncorporated_EpochTransition_Enabled() { 366 err := bs.ctl.config.SetEnabled(true) 367 require.NoError(bs.T(), err) 368 bs.testOnBlockIncorporated_EpochTransition() 369 } 370 371 // TestOnBlockIncorporated_EpochTransition_Disabled tests epoch transition with controller disabled. 372 func (bs *BlockTimeControllerSuite) TestOnBlockIncorporated_EpochTransition_Disabled() { 373 err := bs.ctl.config.SetEnabled(false) 374 require.NoError(bs.T(), err) 375 bs.testOnBlockIncorporated_EpochTransition() 376 } 377 378 // testOnBlockIncorporated_EpochTransition tests that a view change into the next epoch 379 // updates the local state to reflect the new epoch. 380 func (bs *BlockTimeControllerSuite) testOnBlockIncorporated_EpochTransition() { 381 nextEpoch := mockprotocol.NewEpoch(bs.T()) 382 nextEpoch.On("Counter").Return(bs.epochCounter+1, nil) 383 nextEpoch.On("FinalView").Return(bs.curEpochFinalView*2, nil) 384 nextEpoch.On("TargetDuration").Return(bs.EpochDurationSeconds(), nil) // 1s/view 385 nextEpoch.On("TargetEndTime").Return(bs.curEpochTargetEndTime+bs.EpochDurationSeconds(), nil) 386 bs.epochs.Add(nextEpoch) 387 bs.CreateAndStartController() 388 defer bs.StopController() 389 390 initialControllerState := captureControllerStateDigest(bs.ctl) 391 bs.epochs.Transition() 392 timedBlock := makeTimedBlock(bs.curEpochFinalView+1, unittest.IdentifierFixture(), time.Now().UTC()) 393 err := bs.ctl.processIncorporatedBlock(timedBlock) 394 require.True(bs.T(), bs.ctl.getProposalTiming().ObservationView() > bs.initialView) 395 require.NoError(bs.T(), err) 396 nextControllerState := captureControllerStateDigest(bs.ctl) 397 398 bs.SanityCheckSubsequentMeasurements(initialControllerState, nextControllerState, false) 399 // epoch boundaries should be updated 400 assert.Equal(bs.T(), bs.curEpochFinalView+1, bs.ctl.epochInfo.curEpochFirstView) 401 assert.Equal(bs.T(), bs.ctl.epochInfo.curEpochFinalView, bs.curEpochFinalView*2) 402 assert.Equal(bs.T(), bs.ctl.epochInfo.curEpochTargetEndTime, bs.curEpochTargetEndTime+bs.EpochDurationSeconds()) 403 assert.Nil(bs.T(), bs.ctl.nextEpochFinalView) 404 } 405 406 // TestOnEpochSetupPhaseStarted ensures that the epoch info is updated when the next epoch is set up. 407 func (bs *BlockTimeControllerSuite) TestOnEpochSetupPhaseStarted() { 408 nextEpoch := mockprotocol.NewEpoch(bs.T()) 409 nextEpoch.On("Counter").Return(bs.epochCounter+1, nil) 410 nextEpoch.On("FinalView").Return(bs.curEpochFinalView*2, nil) 411 nextEpoch.On("TargetDuration").Return(bs.EpochDurationSeconds(), nil) 412 nextEpoch.On("TargetEndTime").Return(bs.curEpochTargetEndTime+bs.EpochDurationSeconds(), nil) 413 bs.epochs.Add(nextEpoch) 414 bs.CreateAndStartController() 415 defer bs.StopController() 416 417 header := unittest.BlockHeaderFixture() 418 bs.ctl.EpochSetupPhaseStarted(bs.epochCounter, header) 419 require.Eventually(bs.T(), func() bool { 420 return bs.ctl.nextEpochFinalView != nil 421 }, time.Second, time.Millisecond) 422 423 assert.Equal(bs.T(), bs.curEpochFinalView*2, *bs.ctl.nextEpochFinalView) 424 assert.Equal(bs.T(), bs.curEpochTargetEndTime+bs.EpochDurationSeconds(), *bs.ctl.nextEpochTargetEndTime) 425 426 // duplicate events should be no-ops 427 for i := 0; i <= cap(bs.ctl.epochSetups); i++ { 428 bs.ctl.EpochSetupPhaseStarted(bs.epochCounter, header) 429 } 430 assert.Equal(bs.T(), bs.curEpochFinalView*2, *bs.ctl.nextEpochFinalView) 431 assert.Equal(bs.T(), bs.curEpochTargetEndTime+bs.EpochDurationSeconds(), *bs.ctl.nextEpochTargetEndTime) 432 } 433 434 // TestProposalDelay_AfterTargetTransitionTime tests the behaviour of the controller 435 // when we have passed the target end time for the current epoch. 436 // We should approach the min GetProposalTiming (increase view rate as much as possible) 437 func (bs *BlockTimeControllerSuite) TestProposalDelay_AfterTargetTransitionTime() { 438 // we are near the end of the epoch in view terms 439 bs.initialView = uint64(float64(bs.curEpochFinalView) * .95) 440 bs.CreateAndStartController() 441 defer bs.StopController() 442 443 lastProposalDelay := float64(bs.EpochDurationSeconds()) // start with large dummy value 444 for view := bs.initialView + 1; view < bs.ctl.curEpochFinalView; view++ { 445 // we have passed the target end time of the epoch 446 receivedParentBlockAt := unix2time(bs.ctl.curEpochTargetEndTime + view) 447 timedBlock := makeTimedBlock(view, unittest.IdentifierFixture(), receivedParentBlockAt) 448 err := bs.ctl.measureViewDuration(timedBlock) 449 require.NoError(bs.T(), err) 450 451 // compute proposal delay: 452 pubTime := bs.ctl.getProposalTiming().TargetPublicationTime(view+1, time.Now().UTC(), timedBlock.Block.BlockID) // simulate building a child of `timedBlock` 453 delay := pubTime.Sub(receivedParentBlockAt) 454 455 assert.LessOrEqual(bs.T(), delay.Seconds(), lastProposalDelay) 456 lastProposalDelay = delay.Seconds() 457 458 // transition views until the end of the epoch, or for 100 views 459 if view-bs.initialView >= 100 { 460 break 461 } 462 } 463 } 464 465 // TestProposalDelay_BehindSchedule tests the behaviour of the controller when the 466 // projected epoch switchover is LATER than the target switchover time, i.e. 467 // we are behind schedule. 468 // We should respond by lowering the GetProposalTiming (increasing view rate) 469 func (bs *BlockTimeControllerSuite) TestProposalDelay_BehindSchedule() { 470 // we are 50% of the way through the epoch in view terms 471 bs.initialView = uint64(float64(bs.curEpochFinalView) * .5) 472 bs.CreateAndStartController() 473 defer bs.StopController() 474 475 lastProposalDelay := float64(bs.EpochDurationSeconds()) // start with large dummy value 476 idealEnteredViewTime := unix2time(bs.ctl.curEpochTargetEndTime - (bs.EpochDurationSeconds() / 2)) 477 478 // 1s behind of schedule 479 receivedParentBlockAt := idealEnteredViewTime.Add(time.Second) 480 for view := bs.initialView + 1; view < bs.ctl.curEpochFinalView; view++ { 481 // hold the instantaneous error constant for each view 482 receivedParentBlockAt = receivedParentBlockAt.Add(sec2dur(bs.ctl.targetViewTime())) 483 timedBlock := makeTimedBlock(view, unittest.IdentifierFixture(), receivedParentBlockAt) 484 err := bs.ctl.measureViewDuration(timedBlock) 485 require.NoError(bs.T(), err) 486 487 // compute proposal delay: 488 pubTime := bs.ctl.getProposalTiming().TargetPublicationTime(view+1, time.Now().UTC(), timedBlock.Block.BlockID) // simulate building a child of `timedBlock` 489 delay := pubTime.Sub(receivedParentBlockAt) 490 // expecting decreasing GetProposalTiming 491 assert.LessOrEqual(bs.T(), delay.Seconds(), lastProposalDelay, "got non-decreasing delay on view %d (initial view: %d)", view, bs.initialView) 492 lastProposalDelay = delay.Seconds() 493 494 // transition views until the end of the epoch, or for 100 views 495 if view-bs.initialView >= 100 { 496 break 497 } 498 } 499 } 500 501 // TestProposalDelay_AheadOfSchedule tests the behaviour of the controller when the 502 // projected epoch switchover is EARLIER than the target switchover time, i.e. 503 // we are ahead of schedule. 504 // We should respond by increasing the GetProposalTiming (lowering view rate) 505 func (bs *BlockTimeControllerSuite) TestProposalDelay_AheadOfSchedule() { 506 // we are 50% of the way through the epoch in view terms 507 bs.initialView = uint64(float64(bs.curEpochFinalView) * .5) 508 bs.CreateAndStartController() 509 defer bs.StopController() 510 511 lastProposalDelay := time.Duration(0) // start with large dummy value 512 idealEnteredViewTime := bs.ctl.curEpochTargetEndTime - (bs.EpochDurationSeconds() / 2) 513 // 1s ahead of schedule 514 receivedParentBlockAt := idealEnteredViewTime - 1 515 for view := bs.initialView + 1; view < bs.ctl.curEpochFinalView; view++ { 516 // hold the instantaneous error constant for each view 517 receivedParentBlockAt = receivedParentBlockAt + uint64(bs.ctl.targetViewTime()) 518 timedBlock := makeTimedBlock(view, unittest.IdentifierFixture(), unix2time(receivedParentBlockAt)) 519 err := bs.ctl.measureViewDuration(timedBlock) 520 require.NoError(bs.T(), err) 521 522 // compute proposal delay: 523 pubTime := bs.ctl.getProposalTiming().TargetPublicationTime(view+1, time.Now().UTC(), timedBlock.Block.BlockID) // simulate building a child of `timedBlock` 524 delay := pubTime.Sub(unix2time(receivedParentBlockAt)) 525 526 // expecting increasing GetProposalTiming 527 assert.GreaterOrEqual(bs.T(), delay, lastProposalDelay) 528 lastProposalDelay = delay 529 530 // transition views until the end of the epoch, or for 100 views 531 if view-bs.initialView >= 100 { 532 break 533 } 534 } 535 } 536 537 // TestMetricsWhenObservingBlock tests that when observing a new block the correct metrics are tracked. 538 func (bs *BlockTimeControllerSuite) TestMetricsWhenObservingBlock() { 539 bs.metrics = *mockmodule.NewCruiseCtlMetrics(bs.T()) 540 // should set metrics upon initialization 541 bs.metrics.On("PIDError", float64(0), float64(0), float64(0)).Once() 542 bs.metrics.On("TargetProposalDuration", time.Duration(0)).Once() 543 bs.metrics.On("ControllerOutput", time.Duration(0)).Once() 544 bs.CreateAndStartController() 545 defer bs.StopController() 546 bs.metrics.AssertExpectations(bs.T()) 547 548 // we are at view 1 of the epoch, but the time is suddenly the target end time 549 enteredViewAt := bs.ctl.curEpochTargetEndTime 550 view := bs.initialView + 1 551 // we should observe a large error 552 bs.metrics.On("PIDError", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { 553 p := args[0].(float64) 554 i := args[1].(float64) 555 d := args[2].(float64) 556 assert.Greater(bs.T(), p, float64(0)) 557 assert.Greater(bs.T(), i, float64(0)) 558 assert.Greater(bs.T(), d, float64(0)) 559 }).Once() 560 // should immediately use min proposal duration 561 bs.metrics.On("TargetProposalDuration", bs.config.MinViewDuration.Load()).Once() 562 // should have a large negative controller output 563 bs.metrics.On("ControllerOutput", mock.Anything).Run(func(args mock.Arguments) { 564 output := args[0].(time.Duration) 565 assert.Greater(bs.T(), output, time.Duration(0)) 566 }).Once() 567 568 timedBlock := makeTimedBlock(view, unittest.IdentifierFixture(), unix2time(enteredViewAt)) 569 err := bs.ctl.measureViewDuration(timedBlock) 570 require.NoError(bs.T(), err) 571 } 572 573 // TestMetrics_TargetPublicationTime tests that when recalling the `TargetPublicationTime`, 574 // the controller metrics capture the corresponding publication _delay_. 575 func (bs *BlockTimeControllerSuite) TestMetrics_TargetPublicationTime() { 576 // epoch's first view is 0, it has 3600 views in total, with each view targeted to be 1s long 577 bs.initialView = 7 // controller is initialized for view 7 of Epoch 578 epochTargetStartTime := uint64(time.Now().Unix()) - 7 // this is in the range (-8s,-7s] before 'now', because we truncate fractions of a second 579 bs.curEpochTargetEndTime = epochTargetStartTime + bs.EpochDurationSeconds() 580 setupMocks(bs) 581 582 // In the tests, the controller starts at the _beginning_ of view 7, i.e. 7 views (0, 1, ...,6) have concluded 583 bs.CreateAndStartController() 584 defer bs.StopController() 585 // Sanity check that controller's notion of target view time is 1 second. Otherwise, our test is inconsistent. 586 assert.Equal(bs.T(), bs.ctl.epochInfo.targetViewTime(), 1.0) 587 588 // Without any context, the controller should default to "publish immediately". Hence, when 589 // we recall `TargetPublicationTime`, the delay relative to 'now' should be very close to zero. 590 bs.metrics.On("ProposalPublicationDelay", inProximityOf(0, 50*time.Millisecond)).Once() 591 bs.ctl.TargetPublicationTime(0, time.Now(), flow.ZeroID) 592 593 // We assume block 7 arrives with perfect timing. When observing the proposal for view 10, the controller should have 594 // computed its first proper ProposalTiming. 595 timedBlock := makeTimedBlock(bs.initialView, unittest.IdentifierFixture(), unix2time(epochTargetStartTime).Add(7*time.Second)) 596 assert.NoError(bs.T(), bs.ctl.measureViewDuration(timedBlock)) 597 // As all the timing is perfect so far from the controller's point of view (mathematically, all errors are zero) 598 // the target publication time for the subsequent view 8 (=bs.initialView + 1) should be exactly epochTargetStartTime + 8s, 599 // because in total 8 views (0,..,7) have already concluded before. 600 expectedPublicationTime2 := unix2time(epochTargetStartTime).Add(8 * time.Second) 601 602 // Assuming this node is primary in view 8 (=bs.initialView + 1). It has just finished constructing its proposal 603 // and is computing when to publish it. 604 now := time.Now() 605 expectedDelay := expectedPublicationTime2.Sub(now) // approximate value that the metrics-component should capture as delay 606 bs.metrics.On("ProposalPublicationDelay", inProximityOf(expectedDelay, 100*time.Millisecond)).Once() 607 bs.ctl.TargetPublicationTime(bs.initialView+1, now, timedBlock.Block.BlockID) 608 } 609 610 // Test_vs_PythonSimulation performs a regression test. We implemented the controller in python 611 // together with a statistical model for the view duration. We used the python implementation to tune 612 // the PID controller parameters which we are using here. 613 // In this test, we feed values pre-generated with the python simulation into the Go implementation 614 // and compare the outputs to the pre-generated outputs from the python controller implementation. 615 func (bs *BlockTimeControllerSuite) Test_vs_PythonSimulation() { 616 // PART 1: setup system to mirror python simulation 617 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 618 refT := time.Now().UTC() 619 refT = time.Date(refT.Year(), refT.Month(), refT.Day(), refT.Hour(), refT.Minute(), 0, 0, time.UTC) // truncate to past minute 620 621 totalEpochViews := 483000 622 bs.initialView = 0 623 bs.curEpochFirstView, bs.curEpochFinalView = uint64(0), uint64(totalEpochViews-1) // views [0, .., totalEpochViews-1] 624 bs.curEpochTargetDuration = 7 * 24 * 60 * 60 // 1 week in seconds 625 bs.curEpochTargetEndTime = time2unix(refT) + bs.curEpochTargetDuration // now + 1 week 626 bs.epochFallbackTriggered = false 627 628 bs.config = &Config{ 629 TimingConfig: TimingConfig{ 630 FallbackProposalDelay: atomic.NewDuration(500 * time.Millisecond), // irrelevant for this test, as controller should never enter fallback mode 631 MinViewDuration: atomic.NewDuration(470 * time.Millisecond), 632 MaxViewDuration: atomic.NewDuration(2010 * time.Millisecond), 633 Enabled: atomic.NewBool(true), 634 }, 635 ControllerParams: ControllerParams{KP: 2.0, KI: 0.06, KD: 3.0, N_ewma: 5, N_itg: 50}, 636 } 637 638 setupMocks(bs) 639 bs.CreateAndStartController() 640 defer bs.StopController() 641 642 // PART 2: timing generated from python simulation and corresponding controller response 643 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 644 ref := struct { 645 // targetViewTime is the idealized view duration of a perfect system. 646 // In Python simulation, this is the array `EpochSimulation.ideal_view_time` 647 targetViewTime float64 // units: seconds 648 649 // observedMinViewTimes[i] is the minimal time required time to execute the protocol for view i 650 // - Duration from the primary observing the parent block (indexed by i) to having its child proposal (block for view i+1) ready for publication. 651 // - This is the minimal time required to execute the protocol. Nodes can only delay their proposal but not progress any faster. 652 // - in Python simulation, this is the array `EpochSimulation.min_view_times + EpochSimulation.observation_noise` 653 // with is returned by function `EpochSimulation.current_view_observation()` 654 // Note that this is generally different than the time it takes the committee as a whole to transition 655 // through views. This is because the primary changes from view to view, and nodes observe blocks at slightly 656 // different times (small noise term). The real world (as well as the simulation) depend on collective swarm 657 // behaviour of the consensus committee, which is not observable by nodes individually. 658 // In contrast, our `observedMinViewTimes` here contains an additional noise term, to emulate 659 // the observations of a node in the real world. 660 observedMinViewTimes []float64 // units: seconds 661 662 // controllerTargetedViewDuration[i] is the duration targeted by the Python controller : 663 // - measured from observing the parent until publishing the child block for view i+1 664 controllerTargetedViewDuration []float64 // units: seconds 665 666 // realWorldViewDuration[i] is the duration of the ith view for the entire committee. 667 // This value occurs in response to the controller output and is not observable by nodes individually. 668 // - in Python simulation, this is the array `EpochSimulation.real_world_view_duration` 669 // with is recorded by the environment upon the call of `EpochSimulation.delay()` 670 realWorldViewDuration []float64 // units: seconds 671 }{ 672 targetViewTime: 1.2521739130434784, 673 observedMinViewTimes: []float64{0.8139115907362099, 0.7093851608587579, 0.7370057913407495, 0.8378050305605419, 0.8221876685439506, 0.8129097289534515, 0.7835810854212116, 0.7419219104134447, 0.7122331139614623, 0.7263645183403751, 1.2481399484109290, 0.8741906105412369, 0.7082127929564489, 0.8175969272012624, 0.8040687048886446, 0.8163336940928989, 0.6354390018677689, 1.0568897015119771, 0.8283653995502240, 0.8649826738831023, 0.7249163864295024, 0.6572694879104934, 0.8796994117267707, 0.8251533370085626, 0.8383599333817994, 0.7561765091071196, 1.4239532706257330, 2.3848404271162811, 0.6997792104740760, 0.6783155065018911, 0.7397146999404549, 0.7568604144415827, 0.8224399309953295, 0.8635091458596464, 0.6292564656694590, 0.6399775559845721, 0.7551854294536755, 0.7493031513209824, 0.7916989850940226, 0.8584875376770561, 0.5733027665412744, 0.8190610271623866, 0.6664088123579012, 0.6856899641942998, 0.8235905136098289, 0.7673984464333541, 0.7514768668170753, 0.7145945518569533, 0.8076879859786521, 0.6890844388873341, 0.7782307638665685, 1.0031597171903470, 0.8056874789572074, 1.1894678554682030, 0.7751504335630999, 0.6598342159237116, 0.7198783916113262, 0.7231184452829420, 0.7291287772166142, 0.8941150065282033, 0.8216597987064465, 0.7074775436893693, 0.7886375844003763, 0.8028714839193359, 0.6473851384702657, 0.8247230728633490, 0.8268367270238434, 0.7776181863431995, 1.2870341252966155, 0.9022036087098005, 0.8608476621564736, 0.7448392402085238, 0.7030664985775897, 0.7343372879803260, 0.8501776646839836, 0.7949969493471933, 0.7030853022640485, 0.8506339844198412, 0.8520038195041865, 1.2159232403369129, 0.9501009619276108, 0.7063032843664507, 0.7676066345629766, 0.8050982844953996, 0.7460373897798731, 0.7531147127154058, 0.8276552672727131, 0.6777639708691676, 0.7759833549063068, 0.8861636486602165, 0.8272606701022402, 0.6742194284453155, 0.8270012408910985, 1.0799793512385585, 0.8343711941947437, 0.6424938240651709, 0.8314721058034046, 0.8687591599744876, 0.7681132139163648, 0.7993270549538212}, 674 realWorldViewDuration: []float64{1.2707067231074189, 1.3797713099533957, 1.1803368837187869, 1.0710943548975358, 1.3055277182347431, 1.3142312827952587, 1.2748087784689972, 1.2580713757160862, 1.2389594986278398, 1.2839951451881206, 0.8404551372521588, 1.7402295383244093, 1.2486807727203340, 1.1529076722170450, 1.2303564416007062, 1.1919067015405667, 1.4317417513319299, 0.8851802701506968, 1.4621618954558588, 1.2629599000198048, 1.3845528649513363, 1.3083813148510797, 1.0320875660949032, 1.2138806234836066, 1.2922205615230111, 1.3530469860253094, 1.5124780338765653, 2.4800000000000000, 0.8339877775027843, 0.7270580752471872, 0.8013511652567021, 0.7489973886099706, 0.9647668631144197, 1.4406086304771719, 1.6376005221775904, 1.3686144679115566, 1.2051140074616571, 1.2232170397428770, 1.1785015757024468, 1.2720488631325702, 1.4845607775546621, 1.0038608184511295, 1.4011693227324362, 1.2782420466946043, 1.0808595015305793, 1.2923716723984215, 1.2876404222029678, 1.3024029638718018, 1.1243308902566644, 1.3825311808461356, 1.1826028495527394, 1.0753560400260920, 1.4587594729770430, 1.3281281084314180, 1.1987898717701806, 1.3212567274973721, 1.2131355949220173, 1.2202213287069972, 1.2345177139086974, 1.1415707241388824, 1.2618615652263814, 1.3978228798726429, 1.1676202853133009, 1.2821402577607839, 1.4378331263208257, 1.0764974304705950, 1.1968636840861584, 1.3079197545950789, 1.3246769344178762, 1.0956265919521080, 1.3056225547363036, 1.3094504040915045, 1.2916519124885637, 1.2995343661957905, 1.0839793112463321, 1.2515453598485311, 1.3907042923175941, 1.1137329234266407, 1.2293962485228747, 1.4537855131563087, 1.1564260868809058, 1.2616419368628695, 1.1777963280146100, 1.2782540498222059, 1.2617698479511545, 1.2911000941099631, 1.1719344274281953, 1.3904853415093545, 1.1612440756337188, 1.1800751870755894, 1.2653752924717137, 1.3987404424771417, 1.1573292016433725, 1.2132227320045601, 1.2835627159341194, 1.3950341330597937, 1.0774862045842490, 1.2361956384863142, 1.3415505497959577, 1.1881870996394799}, 675 controllerTargetedViewDuration: []float64{1.2521739130434784, 1.2325291342837938, 1.0924796023620962, 1.1315714628442570, 1.3109201861848649, 1.2904005140483341, 1.2408200617376139, 1.2143186827596988, 1.2001258197216824, 1.2059386524427240, 1.1687014183641575, 1.5938588248347272, 1.1735049856838198, 1.1322996720968055, 1.2010702989934061, 1.2193620268012733, 1.2847380812524840, 1.1111384877632171, 1.4676632072726421, 1.3127404884038874, 1.3036822199799039, 1.1627828776831781, 1.0686746584877680, 1.2585854668086294, 1.3196479113378341, 1.3040688380370420, 1.2092520716891777, 0.9174437864843878, 0.4700000000000000, 0.4700000000000000, 0.4700000000000000, 0.4700000000000000, 0.9677983536241768, 1.4594930877396231, 1.4883132720086421, 1.2213393879261234, 1.1167787676139602, 1.1527862655996910, 1.1844688515164143, 1.2712560882996764, 1.2769188516898307, 1.0483030535756364, 1.2667785513482170, 1.1360673946540731, 1.0930571503977162, 1.2553993593963664, 1.2412509734564154, 1.2173708810202102, 1.1668170515618597, 1.2919854192770974, 1.1785774891590928, 1.2397180299682444, 1.4349751903776191, 1.2686663464941463, 1.1793337443757632, 1.2094760506747269, 1.1283680467942478, 1.1456014869605273, 1.1695603482439110, 1.1883473989997737, 1.3102878097954334, 1.3326636354319201, 1.2033095908546276, 1.2765637682955560, 1.2533105511679674, 1.0561925258579383, 1.1944030230453759, 1.2584181515051163, 1.2181701773236133, 1.1427643645565180, 1.2912929540520488, 1.2606456249879283, 1.2079980980125691, 1.1582846527456185, 1.0914599072895725, 1.2436632334468321, 1.2659732625682767, 1.1373906460646186, 1.2636670215783354, 1.3065542716228340, 1.1145058661373550, 1.1821457478344533, 1.1686494999739092, 1.2421504164081945, 1.2292642544361261, 1.2247229593559099, 1.1857675147732030, 1.2627704665069508, 1.1302481979483210, 1.2027256964130453, 1.2826968566299934, 1.2903197193121982, 1.1497164007008540, 1.2248494620352162, 1.2695192555858241, 1.2492112043621006, 1.1006141873118667, 1.2513218024356318, 1.2846249908259910, 1.2077144025965167}, 676 } 677 678 // PART 3: run controller and ensure output matches pre-generated controller response from python ref implementation 679 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 680 // sanity checks: 681 require.Equal(bs.T(), uint64(604800), bs.ctl.curEpochTargetEndTime-time2unix(refT), "Epoch should end 1 week from now, i.e. 604800s") 682 require.InEpsilon(bs.T(), ref.targetViewTime, bs.ctl.targetViewTime(), 1e-15) // ideal view time 683 require.Equal(bs.T(), len(ref.observedMinViewTimes), len(ref.realWorldViewDuration)) 684 685 // Notes: 686 // - We specifically make the first observation at when the full time of the epoch is left. 687 // The python simulation we compare with proceed exactly the same way. 688 // - we first make an observation, before requesting the controller output. Thereby, we 689 // avoid artifacts of recalling a controller that was just initialized with fallback values. 690 // - we call `measureViewDuration(..)` (_not_ `processIncorporatedBlock(..)`) to 691 // interfering with the deduplication logic. Here we want to test correct numerics. 692 // Correctness of the deduplication logic is verified in the different test. 693 observationTime := refT 694 695 for v := 0; v < len(ref.observedMinViewTimes); v++ { 696 observedBlock := makeTimedBlock(uint64(v), unittest.IdentifierFixture(), observationTime) 697 err := bs.ctl.measureViewDuration(observedBlock) 698 require.NoError(bs.T(), err) 699 proposalTiming := bs.ctl.getProposalTiming() 700 tpt := proposalTiming.TargetPublicationTime(uint64(v+1), time.Now(), observedBlock.Block.BlockID) // value for `timeViewEntered` should be irrelevant here 701 702 controllerTargetedViewDuration := tpt.Sub(observedBlock.TimeObserved).Seconds() 703 bs.T().Logf("%d: ctl=%f\tref=%f\tdiff=%f", v, controllerTargetedViewDuration, ref.controllerTargetedViewDuration[v], controllerTargetedViewDuration-ref.controllerTargetedViewDuration[v]) 704 require.InEpsilon(bs.T(), ref.controllerTargetedViewDuration[v], controllerTargetedViewDuration, 1e-5, "implementations deviate for view %d", v) // ideal view time 705 706 observationTime = observationTime.Add(sec2dur(ref.realWorldViewDuration[v])) 707 } 708 } 709 710 func makeTimedBlock(view uint64, parentID flow.Identifier, time time.Time) TimedBlock { 711 header := unittest.BlockHeaderFixture(unittest.HeaderWithView(view)) 712 header.ParentID = parentID 713 return TimedBlock{ 714 Block: model.BlockFromFlow(header), 715 TimeObserved: time, 716 } 717 } 718 719 type controllerStateDigest struct { 720 proportionalErr Ewma 721 integralErr LeakyIntegrator 722 723 // latestProposalTiming holds the ProposalTiming that the controller generated in response to processing the latest observation 724 latestProposalTiming ProposalTiming 725 } 726 727 func captureControllerStateDigest(ctl *BlockTimeController) *controllerStateDigest { 728 return &controllerStateDigest{ 729 proportionalErr: ctl.proportionalErr, 730 integralErr: ctl.integralErr, 731 latestProposalTiming: ctl.getProposalTiming(), 732 } 733 } 734 735 // inProximityOf returns a testify `argumentMatcher` that only accepts durations d, 736 // such that |d - t| ≤ ε, for specified constants targetValue t and acceptedDeviation ε. 737 func inProximityOf(targetValue, acceptedDeviation time.Duration) interface{} { 738 return mock.MatchedBy(func(duration time.Duration) bool { 739 e := targetValue.Seconds() - duration.Seconds() 740 return math.Abs(e) <= acceptedDeviation.Abs().Seconds() 741 }) 742 }