kaspad/stability-tests/daa/daa_test.go
Michael Sutton 0e1d247915
Fix stability test mining (allow submission of non DAA blocks too) (#1917)
* Fix stability test mining (allow submission of non DAA blocks too)

* Comments for go fmt

* Use SubmitBlockAlsoIfNonDAA for all tests

Co-authored-by: Elichai Turkel <elichai.turkel@gmail.com>
2021-12-30 11:04:56 +02:00

320 lines
11 KiB
Go

package daa
import (
"github.com/kaspanet/kaspad/app/appmessage"
"github.com/kaspanet/kaspad/domain/consensus/model/externalapi"
"github.com/kaspanet/kaspad/domain/consensus/utils/pow"
"github.com/kaspanet/kaspad/domain/dagconfig"
"github.com/kaspanet/kaspad/infrastructure/network/rpcclient"
"github.com/kaspanet/kaspad/stability-tests/common"
"math"
"math/rand"
"os"
"testing"
"time"
)
const rpcAddress = "localhost:9000"
const miningAddress = "kaspadev:qrcqat6l9zcjsu7swnaztqzrv0s7hu04skpaezxk43y4etj8ncwfkuhy0zmax"
const blockRateDeviationThreshold = 0.5
const averageBlockRateSampleSize = 60
const averageHashRateSampleSize = 100_000
func TestDAA(t *testing.T) {
if os.Getenv("RUN_STABILITY_TESTS") == "" {
t.Skip()
}
machineHashNanoseconds := measureMachineHashNanoseconds(t)
t.Logf("Machine hashes per second: %d", hashNanosecondsToHashesPerSecond(machineHashNanoseconds))
tests := []struct {
name string
runDuration time.Duration
// targetHashNanosecondsFunction receives the duration of time between now and the start
// of the run (moments before the first hash has been calculated). It returns the target
// duration of a single hash operation in nanoseconds (greater return value = lower hash rate)
targetHashNanosecondsFunction func(totalElapsedDuration time.Duration) int64
}{
{
name: "constant hash rate",
runDuration: 10 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
return machineHashNanoseconds * 2
},
},
{
name: "sudden hash rate drop",
runDuration: 45 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
if totalElapsedDuration < 5*time.Minute {
return machineHashNanoseconds * 2
}
return machineHashNanoseconds * 10
},
},
{
name: "sudden hash rate jump",
runDuration: 15 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
if totalElapsedDuration < 5*time.Minute {
return machineHashNanoseconds * 10
}
return machineHashNanoseconds * 2
},
},
{
name: "hash rate peak",
runDuration: 10 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
if totalElapsedDuration > 4*time.Minute && totalElapsedDuration < 5*time.Minute {
return machineHashNanoseconds * 2
}
return machineHashNanoseconds * 10
},
},
{
name: "hash rate valley",
runDuration: 10 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
if totalElapsedDuration > 4*time.Minute && totalElapsedDuration < 5*time.Minute {
return machineHashNanoseconds * 10
}
return machineHashNanoseconds * 2
},
},
{
name: "periodic hash rate peaks",
runDuration: 10 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
if int(totalElapsedDuration.Seconds())%30 == 0 {
return machineHashNanoseconds * 2
}
return machineHashNanoseconds * 10
},
},
{
name: "periodic hash rate valleys",
runDuration: 10 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
if int(totalElapsedDuration.Seconds())%30 == 0 {
return machineHashNanoseconds * 10
}
return machineHashNanoseconds * 2
},
},
{
name: "constant exponential hash rate increase",
runDuration: 15 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
fromHashNanoseconds := machineHashNanoseconds * 10
toHashNanoseconds := machineHashNanoseconds * 2
if totalElapsedDuration < 10*time.Minute {
exponentialIncreaseDuration := 10 * time.Minute
timeElapsedFraction := float64(totalElapsedDuration.Nanoseconds()) / float64(exponentialIncreaseDuration.Nanoseconds())
return fromHashNanoseconds -
int64(math.Pow(float64(fromHashNanoseconds-toHashNanoseconds), timeElapsedFraction))
}
// 5 minute cooldown. We expect the DAA to still be "catching up" at the end
// of the exponential increase so, for the sake of testing, we wait a while for
// the hash rate to stabilize
return toHashNanoseconds
},
},
{
name: "constant exponential hash rate decrease",
runDuration: 45 * time.Minute,
targetHashNanosecondsFunction: func(totalElapsedDuration time.Duration) int64 {
fromHashNanoseconds := machineHashNanoseconds * 2
toHashNanoseconds := machineHashNanoseconds * 10
if totalElapsedDuration < 10*time.Minute {
exponentialDecreaseDuration := 10 * time.Minute
timeElapsedFraction := float64(totalElapsedDuration.Nanoseconds()) / float64(exponentialDecreaseDuration.Nanoseconds())
return fromHashNanoseconds +
int64(math.Pow(float64(toHashNanoseconds-fromHashNanoseconds), timeElapsedFraction))
}
// 5 minute cooldown. We expect the DAA to still be "catching up" at the end
// of the exponential decrease so, for the sake of testing, we wait a while for
// the hash rate to stabilize
return toHashNanoseconds
},
},
}
for _, test := range tests {
runDAATest(t, test.name, test.runDuration, test.targetHashNanosecondsFunction)
}
}
func measureMachineHashNanoseconds(t *testing.T) int64 {
t.Logf("Measuring machine hash rate")
defer t.Logf("Finished measuring machine hash rate")
genesisBlock := dagconfig.DevnetParams.GenesisBlock
state := pow.NewState(genesisBlock.Header.ToMutable())
machineHashesPerSecondMeasurementDuration := 10 * time.Second
hashes := int64(0)
state.Nonce = rand.Uint64()
loopForDuration(machineHashesPerSecondMeasurementDuration, func(isFinished *bool) {
state.CheckProofOfWork()
hashes++
state.IncrementNonce()
})
return machineHashesPerSecondMeasurementDuration.Nanoseconds() / hashes
}
func runDAATest(t *testing.T, testName string, runDuration time.Duration,
targetHashNanosecondsFunction func(totalElapsedDuration time.Duration) int64) {
t.Logf("DAA TEST STARTED: %s", testName)
defer t.Logf("DAA TEST FINISHED: %s", testName)
tearDownKaspad := common.RunKaspadForTesting(t, "kaspad-daa-test", rpcAddress)
defer tearDownKaspad()
rpcClient, err := rpcclient.NewRPCClient(rpcAddress)
if err != nil {
t.Fatalf("NewRPCClient: %s", err)
}
// These variables are for gathering stats. Useful mostly for debugging
averageHashDuration := newAverageDuration(averageHashRateSampleSize)
averageMiningDuration := newAverageDuration(averageBlockRateSampleSize)
previousDifficulty := float64(0)
blocksMined := 0
// Mine blocks the same way a CPU miner mines blocks until `runDuration` elapses
startTime := time.Now()
loopForDuration(runDuration, func(isFinished *bool) {
templateBlock := fetchBlockForMining(t, rpcClient)
headerForMining := templateBlock.Header.ToMutable()
minerState := pow.NewState(headerForMining)
// Try hashes until we find a valid block
miningStartTime := time.Now()
minerState.Nonce = rand.Uint64()
for {
hashStartTime := time.Now()
if minerState.CheckProofOfWork() {
headerForMining.SetNonce(minerState.Nonce)
templateBlock.Header = headerForMining.ToImmutable()
break
}
// Throttle the hash rate by waiting until the target hash duration elapses
waitUntilTargetHashDurationHadElapsed(startTime, hashStartTime, targetHashNanosecondsFunction)
// Collect stats about hash rate
hashDuration := time.Since(hashStartTime)
averageHashDuration.add(hashDuration)
// Exit early if the test is finished
if *isFinished {
return
}
minerState.IncrementNonce()
}
// Collect stats about block rate
miningDuration := time.Since(miningStartTime)
averageMiningDuration.add(miningDuration)
logMinedBlockStatsAndUpdateStatFields(t, rpcClient, averageMiningDuration, averageHashDuration, startTime,
miningDuration, &previousDifficulty, &blocksMined)
// Exit early if the test is finished
if *isFinished {
return
}
submitMinedBlock(t, rpcClient, templateBlock)
})
averageMiningDurationInSeconds := averageMiningDuration.toDuration().Seconds()
expectedAverageMiningDurationInSeconds := float64(1)
deviation := math.Abs(expectedAverageMiningDurationInSeconds - averageMiningDurationInSeconds)
if deviation > blockRateDeviationThreshold {
t.Errorf("Block rate deviation %f is higher than threshold %f. Want: %f, got: %f",
deviation, blockRateDeviationThreshold, expectedAverageMiningDurationInSeconds, averageMiningDurationInSeconds)
}
}
func fetchBlockForMining(t *testing.T, rpcClient *rpcclient.RPCClient) *externalapi.DomainBlock {
getBlockTemplateResponse, err := rpcClient.GetBlockTemplate(miningAddress)
if err != nil {
t.Fatalf("GetBlockTemplate: %s", err)
}
templateBlock, err := appmessage.RPCBlockToDomainBlock(getBlockTemplateResponse.Block)
if err != nil {
t.Fatalf("RPCBlockToDomainBlock: %s", err)
}
return templateBlock
}
func waitUntilTargetHashDurationHadElapsed(startTime time.Time, hashStartTime time.Time,
targetHashNanosecondsFunction func(totalElapsedDuration time.Duration) int64) {
// Yielding a thread in Go takes up to a few milliseconds whereas hashing once
// takes a few hundred nanoseconds, so we spin in place instead of e.g. calling time.Sleep()
for {
targetHashNanoseconds := targetHashNanosecondsFunction(time.Since(startTime))
hashElapsedDurationNanoseconds := time.Since(hashStartTime).Nanoseconds()
if hashElapsedDurationNanoseconds >= targetHashNanoseconds {
break
}
}
}
func logMinedBlockStatsAndUpdateStatFields(t *testing.T, rpcClient *rpcclient.RPCClient,
averageMiningDuration *averageDuration, averageHashDurations *averageDuration,
startTime time.Time, miningDuration time.Duration, previousDifficulty *float64, blocksMined *int) {
averageMiningDurationAsDuration := averageMiningDuration.toDuration()
averageHashNanoseconds := averageHashDurations.toDuration().Nanoseconds()
averageHashesPerSecond := hashNanosecondsToHashesPerSecond(averageHashNanoseconds)
blockDAGInfoResponse, err := rpcClient.GetBlockDAGInfo()
if err != nil {
t.Fatalf("GetBlockDAGInfo: %s", err)
}
difficultyDelta := blockDAGInfoResponse.Difficulty - *previousDifficulty
*previousDifficulty = blockDAGInfoResponse.Difficulty
*blocksMined++
t.Logf("Mined block. Took: %s, average block mining duration: %s, "+
"average hashes per second: %d, difficulty delta: %f, time elapsed: %s, blocks mined: %d",
miningDuration, averageMiningDurationAsDuration, averageHashesPerSecond, difficultyDelta, time.Since(startTime), *blocksMined)
}
func submitMinedBlock(t *testing.T, rpcClient *rpcclient.RPCClient, block *externalapi.DomainBlock) {
_, err := rpcClient.SubmitBlockAlsoIfNonDAA(block)
if err != nil {
t.Fatalf("SubmitBlock: %s", err)
}
}
func hashNanosecondsToHashesPerSecond(hashNanoseconds int64) int64 {
return time.Second.Nanoseconds() / hashNanoseconds
}
func loopForDuration(duration time.Duration, runFunction func(isFinished *bool)) {
isFinished := false
go func() {
for !isFinished {
runFunction(&isFinished)
}
}()
time.Sleep(duration)
isFinished = true
}