blob: c55a6ce289e582d13eb0b87827f96fed19f66ad6 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package testsharder
import (
"container/heap"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"math"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"go.fuchsia.dev/fuchsia/tools/build"
"go.fuchsia.dev/fuchsia/tools/testing/runtests"
)
const (
// The maximum number of runs that testsharder will calculate for a multiplied
// test if totalRuns is unset.
// TODO(olivernewman): Apply a maximum to user-specified values too, but
// probably not in testsharder since we'll want to get feedback to users ASAP if
// validation fails.
multipliedTestMaxRuns = 1000
// The maximum total runs across all automatically multiplied tests.
MaxMultipliedRunsPerShard = 1000
// The maximum number of multiplied shards allowed per environment.
maxMultipliedShardsPerEnv = 3
// The prefix added to the names of shards that run affected hermetic tests.
AffectedShardPrefix = "affected:"
// The prefix added to the names of shards that run unaffected hermetic tests.
HermeticShardPrefix = "hermetic:"
// The prefix added to the names of shards that run multiplied tests.
MultipliedShardPrefix = "multiplied:"
// The name of the key of the expected duration test tag.
expectedDurationTagKey = "expected_duration_milliseconds"
)
// ApplyModifiers will return an error that unwraps to this if multiple default
// test modifiers are provided.
var errMultipleDefaultModifiers = fmt.Errorf("too many default modifiers, only one is allowed")
// MarkShardsSkipped will return an error that unwraps to this if the shards
// contain an affected test.
var errSkippedAffectedTest = fmt.Errorf("attempted to skip an affected test")
func ExtractDeps(shards []*Shard, fuchsiaBuildDir string) error {
for _, shard := range shards {
if err := extractDepsFromShard(shard, fuchsiaBuildDir); err != nil {
return err
}
}
return nil
}
func extractDepsFromShard(shard *Shard, fuchsiaBuildDir string) error {
var shardDeps []string
for i := range shard.Tests {
test, deps, err := extractDepsFromTest(shard.Tests[i], fuchsiaBuildDir)
if err != nil {
return err
}
// extractDepsFromTest may modify the test, so we need to overwrite the
// entry.
shard.Tests[i] = test
// Any test that doesn't run on Fuchsia is invoked via an executable in
// the build out directory. The executable itself needs to be copied to
// the testing bot along with the test's deps.
if test.OS != "fuchsia" && test.Path != "" {
deps = append(deps, test.Path)
}
for _, dep := range deps {
_, err := os.Stat(filepath.Join(fuchsiaBuildDir, dep))
if os.IsNotExist(err) {
return fmt.Errorf("dependency for test %q was not built: %s", test.Name, dep)
} else if err != nil {
return err
}
}
shardDeps = append(shardDeps, deps...)
}
shard.AddDeps(shardDeps)
return nil
}
func extractDepsFromTest(test Test, fuchsiaBuildDir string) (Test, []string, error) {
if test.RuntimeDepsFile == "" {
return test, nil, nil
}
path := filepath.Join(fuchsiaBuildDir, test.RuntimeDepsFile)
f, err := os.Open(path)
if err != nil {
return test, nil, err
}
defer f.Close()
// RuntimeDepsFile is no longer needed at this point and clutters the output.
test.RuntimeDepsFile = ""
var deps []string
err = json.NewDecoder(f).Decode(&deps)
return test, deps, err
}
func envsEqual(env1, env2 build.Environment) bool {
return environmentName(env1) == environmentName(env2)
}
// SplitOutMultipliers appends new shards to shards where each new shard contains one test
// repeated multiple times according to the TotalRuns for the test. Multiplier modifiers
// should already be applied to the shards beforehand using ApplyModifiers().
// It also removes all multiplied tests from the input shards.
func SplitOutMultipliers(
ctx context.Context,
shards []*Shard,
testDurations TestDurationsMap,
// TODO(olivernewman): Use the adjusted target duration calculated by
// WithTargetDuration instead of the original target duration.
targetDuration time.Duration,
targetTestCount int,
maxMultipliedRunsPerShard int,
prefix string,
) []*Shard {
shardIdxToMatches := make([]map[int]struct{}, len(shards))
for si, shard := range shards {
shardIdxToMatches[si] = make(map[int]struct{})
for ti, test := range shard.Tests {
if test.RunAlgorithm != StopOnFailure {
continue
}
shard.Tests[ti] = test
shardIdxToMatches[si][ti] = struct{}{}
}
}
for si := len(shards) - 1; si >= 0; si-- {
shard := shards[si]
matches := shardIdxToMatches[si]
// Remove the multiplied tests from the other shard. It's wasteful to run it in
// different shards.
// If entire shard is empty (every test is matched to a multiplier), remove it.
shardRemoved := false
if len(matches) == len(shard.Tests) {
copy(shards[si:], shards[si+1:])
shards = shards[:len(shards)-1]
shardRemoved = true
}
var multipliedTests []Test
totalTestsDuration := 0
maxTestsDuration := 0
totalTestCount := 0
maxTestCount := 0
for ti := len(shard.Tests) - 1; ti >= 0; ti-- {
if _, ok := matches[ti]; !ok {
// If there is no match associated with this test,
// leave it in the original shard and don't multiply.
continue
}
test := shard.Tests[ti]
if targetDuration > 0 {
expectedDuration := testDurations.Get(test).MedianDuration
totalTestsDuration += int(expectedDuration) * max(1, test.Runs)
maxTestsDuration += int(expectedDuration) * test.maxRuns()
} else if targetTestCount > 0 {
totalTestCount += max(1, test.Runs)
maxTestCount += test.maxRuns()
} else {
test.Runs = max(test.Runs, 1)
}
multipliedTests = append(multipliedTests, test)
if shardRemoved {
continue
}
// Remove individual tests from the shard.
copy(shard.Tests[ti:], shard.Tests[ti+1:])
shard.Tests = shard.Tests[:len(shard.Tests)-1]
}
if len(multipliedTests) > 0 {
maxMultipliedShards := maxMultipliedShardsPerEnv
if targetDuration > 0 {
maxMultipliedShards = min(maxMultipliedShards, divRoundUp(maxTestsDuration, int(targetDuration)))
} else if targetTestCount > 0 {
maxMultipliedShards = min(maxMultipliedShards, divRoundUp(maxTestCount, targetTestCount))
} else {
maxMultipliedShards = 1
}
numNewShards := min(len(multipliedTests), maxMultipliedShards)
multShard := *shard
multShard.Name = prefix + shard.Name
multShard.Tests = multipliedTests
newShards := shardByTime(&multShard, testDurations, numNewShards)
for _, newShard := range newShards {
// Give priority to the tests that had a total_runs specified and
// use the remaining time/test count to fill up the rest of the
// shard with the remaining tests.
var usedUpDuration time.Duration
usedUpCount := 0
var fillUpTestIdxs []int
for i, test := range newShard.Tests {
if test.Runs > 0 {
if targetDuration > 0 {
usedUpDuration += testDurations.Get(test).MedianDuration * time.Duration(test.Runs)
} else if targetTestCount > 0 {
usedUpCount += test.Runs
}
} else {
fillUpTestIdxs = append(fillUpTestIdxs, i)
}
}
// Fill up the remainder of the shard with the remaining tests,
// splitting up the duration/count evenly between the tests and
// running each at least once.
sort.Slice(fillUpTestIdxs, func(a, b int) bool {
aDuration := testDurations.Get(newShard.Tests[fillUpTestIdxs[a]]).MedianDuration
bDuration := testDurations.Get(newShard.Tests[fillUpTestIdxs[b]]).MedianDuration
return aDuration > bDuration
})
// If a shard's tests generate many test cases in a short amount
// of time, the resulting summary.json file may be too large to
// be processed. So we set a limit on the total number of
// multiplied test runs across all tests in the shard.
maxRunsPerFillUpTest := multipliedTestMaxRuns
if len(fillUpTestIdxs) > 0 {
maxRunsPerFillUpTest = min(maxRunsPerFillUpTest, divRoundUp(maxMultipliedRunsPerShard, len(fillUpTestIdxs)))
}
for i := len(fillUpTestIdxs) - 1; i >= 0; i-- {
idx := fillUpTestIdxs[i]
test := newShard.Tests[idx]
if targetDuration > 0 {
remainingDuration := max(0, int(targetDuration-usedUpDuration))
durationPerTest := divRoundUp(remainingDuration, len(fillUpTestIdxs))
testDuration := testDurations.Get(test).MedianDuration
test.Runs = min(maxRunsPerFillUpTest, divRoundUp(durationPerTest, int(testDuration)))
usedUpDuration += time.Duration(min(int(testDuration)*maxRunsPerFillUpTest, durationPerTest))
test.Runs = max(1, test.Runs)
test.StopRepeatingAfterSecs = int(time.Duration(durationPerTest).Seconds())
} else if targetTestCount > 0 {
remainingCount := max(0, targetTestCount-usedUpCount)
countPerTest := divRoundUp(remainingCount, len(fillUpTestIdxs))
usedUpCount += countPerTest
test.Runs = max(1, countPerTest)
} else {
test.Runs = 1
}
newShard.Tests[idx] = test
fillUpTestIdxs = fillUpTestIdxs[:i]
}
newShard.TimeoutSecs = max(newShard.TimeoutSecs, int(computeShardTimeout(subshard{targetDuration, newShard.Tests}).Seconds()))
}
shards = append(shards, newShards...)
}
}
return shards
}
// AddExpectedDurationTags uses the given TestDurations to annotate each test
// with an expected duration tag.
func AddExpectedDurationTags(shards []*Shard, testDurations TestDurationsMap) []*Shard {
for _, shard := range shards {
var newTests []Test
for _, test := range shard.Tests {
td := testDurations.Get(test)
test.Tags = append(test.Tags, build.TestTag{
Key: expectedDurationTagKey,
Value: strconv.FormatInt(td.MedianDuration.Milliseconds(), 10),
})
newTests = append(newTests, test)
}
shard.Tests = newTests
}
return shards
}
// ApplyModifiers applies the given test modifiers to tests in the given shards.
func ApplyModifiers(shards []*Shard, modMatches []ModifierMatch) ([]*Shard, error) {
modsPerEnv := make(map[string]ModifierMatch)
for _, modMatch := range modMatches {
if modMatch.Test == "" {
if _, ok := modsPerEnv[environmentName(modMatch.Env)]; ok {
return nil, errMultipleDefaultModifiers
}
modMatch.Modifier.TotalRuns = -1
modsPerEnv[environmentName(modMatch.Env)] = modMatch
}
}
defaultEnv := build.Environment{}
if _, ok := modsPerEnv[environmentName(defaultEnv)]; !ok {
modsPerEnv[environmentName(defaultEnv)] = ModifierMatch{Modifier: TestModifier{TotalRuns: -1}}
}
for _, shard := range shards {
var modifiedTests []Test
for _, test := range shard.Tests {
defaultModForEnv, ok := modsPerEnv[environmentName(shard.Env)]
if !ok {
defaultModForEnv = modsPerEnv[environmentName(defaultEnv)]
}
test.applyModifier(defaultModForEnv.Modifier)
for _, modMatch := range modMatches {
if !envsEqual(modMatch.Env, build.Environment{}) && !envsEqual(modMatch.Env, shard.Env) {
continue
}
if modMatch.Test == test.Name {
test.applyModifier(modMatch.Modifier)
}
}
modifiedTests = append(modifiedTests, test)
}
shard.Tests = modifiedTests
}
return shards, nil
}
// PartitionShards splits a set of shards in two using the given partition
// function.
func PartitionShards(shards []*Shard, partitionFunc func(Test) bool, prefix string) ([]*Shard, []*Shard) {
matchingShards := make([]*Shard, 0, len(shards))
nonmatchingShards := make([]*Shard, 0, len(shards))
for _, shard := range shards {
var matching []Test
var nonmatching []Test
for _, test := range shard.Tests {
if partitionFunc(test) {
matching = append(matching, test)
} else {
nonmatching = append(nonmatching, test)
}
}
if len(matching) > 0 {
newShard := *shard
newShard.Name = prefix + shard.Name
newShard.Tests = matching
matchingShards = append(matchingShards, &newShard)
}
if len(nonmatching) > 0 {
shard.Tests = nonmatching
nonmatchingShards = append(nonmatchingShards, shard)
}
}
return matchingShards, nonmatchingShards
}
// MarkShardsSkipped marks the entire set of shards skipped.
func MarkShardsSkipped(shards []*Shard) ([]*Shard, error) {
var newShards []*Shard
for _, shard := range shards {
var summary runtests.TestSummary
for _, test := range shard.Tests {
if test.Affected {
return nil, errSkippedAffectedTest
}
summary.Tests = append(summary.Tests, runtests.TestDetails{
Name: test.Name,
GNLabel: test.Label,
Result: runtests.TestSkipped,
Tags: test.Tags,
})
}
newShard := *shard
newShard.Summary = summary
newShards = append(newShards, &newShard)
}
return newShards, nil
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func divRoundUp(a, b int) int {
if a%b == 0 {
return a / b
}
return (a / b) + 1
}
// WithTargetDuration returns a list of shards such that all shards are expected
// to complete in approximately `targetDuration` time.
// If targetDuration <= 0, just returns its input.
// Alternatively, accepts a `targetTestCount` argument for backwards compatibility.
//
// Each resulting shard will have a TimeoutSecs field populated dynamically
// based on the expected total runtime of its tests. The caller can choose to
// respect and enforce this timeout, or ignore it.
func WithTargetDuration(
shards []*Shard,
targetDuration time.Duration,
targetTestCount,
maxShardSize,
maxShardsPerEnvironment int,
testDurations TestDurationsMap,
) ([]*Shard, time.Duration) {
if targetDuration <= 0 && targetTestCount <= 0 && maxShardSize <= 0 {
return shards, targetDuration
}
if maxShardsPerEnvironment <= 0 {
maxShardsPerEnvironment = math.MaxInt64
}
shardsPerEnv := make(map[string][]*Shard)
targetDurationPerEnv := make(map[string]time.Duration)
for _, shard := range shards {
envName := environmentName(shard.Env)
shardsPerEnv[envName] = append(shardsPerEnv[envName], shard)
targetDurationPerEnv[envName] = targetDuration
}
largestTargetDuration := time.Duration(0)
if targetDuration > 0 {
for env, shards := range shardsPerEnv {
totalTestsPerEnv := 0
minTargetDurationForEnv := time.Duration(0)
targetDurationForEnv := targetDuration
var shardDuration time.Duration
for _, shard := range shards {
// If any single test is expected to take longer than `targetDuration`,
// it's no use creating shards whose entire expected runtimes are
// shorter than that one test. So in that case we use the longest test's
// expected duration as the target duration.
for _, t := range shard.Tests {
duration := testDurations.Get(t).MedianDuration
if duration > targetDurationForEnv {
minTargetDurationForEnv = duration
targetDurationForEnv = duration
}
shardDuration += duration * time.Duration(t.minRequiredRuns())
}
totalTestsPerEnv += len(shard.Tests)
}
// If any environment would exceed the maximum shard count (either because the total
// shard duration is too long or the total test count is too big), then its
// shard durations will exceed the specified target duration. So
// increase the target duration accordingly.
subShardCount := divRoundUp(int(shardDuration), int(targetDurationForEnv))
if subShardCount > maxShardsPerEnvironment {
subShardCount = maxShardsPerEnvironment
} else if subShardCount > 0 {
// If the number of shards does not exceed the max shards per env,
// then check that the average tests per shards wouldn't exceed
// the max shard size. If it does, increase the expected number
// of shards to try to respect the maxShardSize as long as it fits
// within the max shards per env.
avgTestsPerShard := divRoundUp(totalTestsPerEnv, subShardCount)
if maxShardSize > 0 && avgTestsPerShard > maxShardSize {
subShardCount = divRoundUp(totalTestsPerEnv, maxShardSize)
if subShardCount > maxShardsPerEnvironment {
subShardCount = maxShardsPerEnvironment
}
}
}
if subShardCount > 0 {
newTargetDurationForEnv := time.Duration(divRoundUp(int(shardDuration), subShardCount))
// In the case where maxShardSize > 0 and we try to increase the number of shards,
// the new target duration would be decreased. However, we still need to make sure
// it's greater than the minimum reuired target duration.
if newTargetDurationForEnv > minTargetDurationForEnv {
targetDurationForEnv = newTargetDurationForEnv
}
}
if targetDurationForEnv > largestTargetDuration {
largestTargetDuration = targetDurationForEnv
}
targetDurationPerEnv[env] = targetDurationForEnv
}
}
output := make([]*Shard, 0, len(shards))
for _, shard := range shards {
numNewShards := 0
totalTests := 0
for _, t := range shard.Tests {
totalTests += t.minRequiredRuns()
}
if targetDuration > 0 {
var targetDurationForShard time.Duration
if maxShardSize > 0 {
// If we're limiting by max shard size, then the target duration per env might be different.
targetDurationForShard = targetDurationPerEnv[environmentName(shard.Env)]
} else {
// Otherwise use the largest duration size.
targetDurationForShard = largestTargetDuration
}
var total time.Duration
for _, t := range shard.Tests {
total += testDurations.Get(t).MedianDuration * time.Duration(t.minRequiredRuns())
}
numNewShards = divRoundUp(int(total), int(targetDurationForShard))
// While calculating the targetDurationForShard, some rounding might happen
// that could cause it to be slightly less than the duration of a test, which
// could cause numNewShards to be greater than the number of tests in the shard,
// so set it to the number of tests instead so that at least one test is run
// per shard.
numNewShards = min(totalTests, numNewShards)
} else {
numNewShards = divRoundUp(totalTests, targetTestCount)
}
if numNewShards == 0 {
// If targetDuration is set but all durations are zero, we'll
// determine that we need zero new shards. In this case, we'll be
// careful and assume that we need the maximum allowed shards to be
// able to fit all tests or one shard per test if the number of tests
// is less than the maximum allowed shards.
numNewShards = min(totalTests, maxShardsPerEnvironment)
}
numNewShards = min(numNewShards, maxShardsPerEnvironment)
newShards := shardByTime(shard, testDurations, numNewShards)
output = append(output, newShards...)
}
return output, targetDuration
}
type subshard struct {
duration time.Duration
tests []Test
}
// A subshardHeap is a min heap of subshards, using subshard duration as the key
// to sort by.
// It implements heap.Interface.
type subshardHeap []subshard
func (h subshardHeap) Len() int {
return len(h)
}
func (h subshardHeap) Less(i, j int) bool {
if h[i].duration != h[j].duration {
return h[i].duration < h[j].duration
}
// All durations being equal, fall back to comparing test counts. This
// ensures that even if all expected durations are zero (which generally
// shouldn't happen, but is possible), we'll still divide tests evenly by
// test count across shards.
return len(h[i].tests) < len(h[j].tests)
}
func (h subshardHeap) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *subshardHeap) Push(s interface{}) {
*h = append(*h, s.(subshard))
}
func (h *subshardHeap) Pop() interface{} {
old := *h
n := len(old)
s := old[n-1]
*h = old[0 : n-1]
return s
}
// shardByTime breaks a single original shard into numNewShards subshards such
// that each subshard has approximately the same expected total duration.
//
// It does this using a greedy approximation algorithm for static multiprocessor
// scheduling (https://en.wikipedia.org/wiki/Multiprocessor_scheduling). It
// first sorts the tests in descending order by expected duration and then
// successively allocates each test to the subshard with the lowest total
// expected duration so far.
//
// Within each returned shard, tests will be sorted pseudo-randomly.
func shardByTime(shard *Shard, testDurations TestDurationsMap, numNewShards int) []*Shard {
sort.Slice(shard.Tests, func(index1, index2 int) bool {
test1, test2 := shard.Tests[index1], shard.Tests[index2]
duration1 := testDurations.Get(test1).MedianDuration
duration2 := testDurations.Get(test2).MedianDuration
if duration1 == duration2 {
// Sort by name for tests of equal duration to ensure deterministic
// ordering.
return test1.Name < test2.Name
}
// "greater than" instead of "less than" to achieve descending ordering
return duration1 > duration2
})
var h subshardHeap
for i := 0; i < numNewShards; i++ {
// Initialize each subshard to have an empty list of tests so that it will
// be properly outputted in the json output file as a list instead of a nil
// value if the shard ends up with zero tests.
s := subshard{tests: []Test{}}
h = append(h, s)
}
for _, test := range shard.Tests {
shardsPerTest := 1
// Only if the test must be run more than once and it's the only test
// in the shard should we split it across multiple shards.
splitAcrossShards := test.minRequiredRuns() > 1 && len(shard.Tests) == 1
if splitAcrossShards {
shardsPerTest = numNewShards
}
runsPerShard := divRoundUp(test.minRequiredRuns(), shardsPerTest)
extra := runsPerShard*shardsPerTest - test.minRequiredRuns()
for i := 0; i < shardsPerTest; i++ {
testCopy := test
var runs int
if i < numNewShards-extra {
runs = runsPerShard
} else {
runs = runsPerShard - 1
}
if runs == 0 {
break
}
if splitAcrossShards {
testCopy.Runs = runs
}
// Assign this test to the subshard with the lowest total expected
// duration at this iteration of the for loop.
ss := heap.Pop(&h).(subshard)
ss.duration += testDurations.Get(test).MedianDuration * time.Duration(testCopy.minRequiredRuns())
ss.tests = append(ss.tests, testCopy)
heap.Push(&h, ss)
}
}
// Sort the resulting shards by the basename of the first test. Otherwise,
// changes to the input set of tests (adding, removing or renaming a test)
// result in confusing reordering of the shard names. This ensures that a
// given named shard (e.g. "QEMU-(1)") will contain roughly the same set of
// longer-running tests across multiple builds, even if the input set of
// tests changes. Shorter tests are more likely to be switched between
// shards because we're sorting by the name of the longest test.
sort.Slice(h, func(i, j int) bool {
return h[i].tests[0].Name < h[j].tests[0].Name
})
newShards := make([]*Shard, 0, numNewShards)
for i, subshard := range h {
// If we left tests in descending order by duration, updates to
// checked-in test durations could arbitrarily reorder tests, making
// those updates more likely to break non-hermetic tests.
// To avoid that, sort by a hash of the name so that similar tests (e.g.
// all tests from one area of the codebase, or all host-side tests)
// tests don't always run sequentially, since similar tests are more
// likely to non-hermetically conflict with each other. Using a
// deterministic hash ensures that given tests A and B in the same
// shard, A will *always* run before B or vice versa.
sort.Slice(subshard.tests, func(i, j int) bool {
return hash(subshard.tests[i].Name) < hash(subshard.tests[j].Name)
})
name := shard.Name
if numNewShards > 1 {
name = fmt.Sprintf("%s-(%d)", shard.Name, i+1)
}
newShard := *shard
newShard.Name = name
newShard.Tests = subshard.tests
newShard.TimeoutSecs = int(computeShardTimeout(subshard).Seconds())
newShards = append(newShards, &newShard)
}
return newShards
}
// computeShardTimeout dynamically calculates an appropriate timeout for the
// given shard based on the number of tests it runs and the expected duration of
// each test, with a large buffer to account for tests occasionally taking much
// longer than expected.
//
// The constants we use to compute the timeout are somewhat arbitrary and can be
// adjusted if necessary.
func computeShardTimeout(s subshard) time.Duration {
// Allow for tests running longer than expected by a constant factor. It's
// unlikely that *every* test would be this much slower than expected, but
// it's conceivable that a significant number of tests could be so affected.
perTestMultiplier := time.Duration(2)
// Conservative estimated overhead for the shard independent of how many
// tests the shard runs.
shardOverhead := 10 * time.Minute
// Conservative estimate of the overhead for running each test.
perTestOverhead := time.Second
testCount := time.Duration(len(s.tests))
// Take into account the expected overhead if a test times out to make sure
// that the shard timeout leaves time for individual tests to time out. This
// is determined by taking the maximum test timeout minus the average
// expected test runtime, and doubling it if the test is allowed to run
// multiple times or if there are multiple tests.
longestTimeoutTest := s.tests[0]
for _, test := range s.tests {
if test.Timeout > longestTimeoutTest.Timeout {
longestTimeoutTest = test
}
}
avgDuration := s.duration / testCount
testTimeoutOverhead := longestTimeoutTest.Timeout - avgDuration
if longestTimeoutTest.Runs > 1 || len(s.tests) > 1 {
// Only add enough overhead to guarantee two test runs enough
// time to time out. If we allowed enough overhead for *all*
// tests to time out then the task timeout would be way too
// high. If all tests are timing out we should cancel the shard
// early rather than waiting for all the tests to finish, which
// would be wasteful.
testTimeoutOverhead *= 2
}
timeout := perTestMultiplier * (s.duration + testCount*perTestOverhead)
if testTimeoutOverhead > 0 {
timeout += testTimeoutOverhead
}
timeout += shardOverhead
return timeout
}
func hash(s string) uint32 {
h := fnv.New32a()
h.Write([]byte(s))
return h.Sum32()
}
// Removes leading slashes and replaces all other `/` with `_`. This allows the
// shard name to appear in filepaths.
func normalizeTestName(name string) string {
trimmedName := strings.TrimLeft(name, "/")
return strings.ReplaceAll(trimmedName, "/", "_")
}
// ApplyTestTimeouts sets the timeout field on every test to the specified
// duration. Timeouts already declared for tests in tests.json take precedence.
func ApplyTestTimeouts(shards []*Shard, perTestTimeout time.Duration) {
for _, shard := range shards {
for i := range shard.Tests {
testTimeoutSecs := shard.Tests[i].Test.TimeoutSecs
if testTimeoutSecs == 0 {
shard.Tests[i].Timeout = perTestTimeout
} else {
shard.Tests[i].Timeout = time.Duration(testTimeoutSecs) * time.Second
}
}
}
}