blob: 226d24a6dd9662b1c3b5a4d1b7229460a5ae30b2 [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 (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"strings"
"go.fuchsia.dev/fuchsia/tools/build"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
)
const (
fuchsia = "fuchsia"
linux = "linux"
x64 = "x64"
// The maximum number of tests that a multiplier can match. testsharder will
// fail if this is exceeded.
defaultMaxMatchesPerMultiplier = 50
)
// matchModifiersToTests will return an error that unwraps to this if a multiplier's
// "name" field does not compile to a valid regex.
var errInvalidMultiplierRegex = fmt.Errorf("invalid multiplier regex")
// TestModifier is the specification for a single test and the number of
// times it should be run.
type TestModifier struct {
// Name is the name of the test.
Name string `json:"name"`
// OS is the operating system in which this test must be executed. If not
// present, this multiplier will match tests from any operating system.
OS string `json:"os,omitempty"`
// TotalRuns is the number of times to run the test. If zero, testsharder
// will use historical test duration data to try to run this test along with
// other multiplied tests as many times as it can within the max allowed
// multiplied shards per environment. A negative value means to NOT designate
// this test as a multiplier test and to leave the original runs as-is.
TotalRuns int `json:"total_runs,omitempty"`
// Affected specifies whether the test is an affected test. If affected,
// it will be run in a separate shard than the unaffected tests.
Affected bool `json:"affected,omitempty"`
// MaxAttempts is the max number of times to run this test if it fails.
// This is the max attempts per run as specified by the `TotalRuns` field.
MaxAttempts int `json:"max_attempts,omitempty"`
// MaxMatches is the max number of tests which can be matched by this modifier.
// Defaults to defaultMaxMatchesPerMultiplier.
MaxMatches int `json:"max_matches,omitempty"`
}
// ModifierMatch is the calculated match of a single test in a single environment
// with the modifier that it matches. After processing all modifiers, we should
// return a ModifierMatch for each test-env combination that the modifiers apply to.
// An empty Env means it matches all environments.
type ModifierMatch struct {
Test string
Env build.Environment
Modifier TestModifier
}
// LoadTestModifiers loads a set of test modifiers from a json manifest.
func LoadTestModifiers(ctx context.Context, testSpecs []build.TestSpec, manifestPath string) ([]ModifierMatch, error) {
bytes, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
var specs []TestModifier
if err = json.Unmarshal(bytes, &specs); err != nil {
return nil, err
}
for i := range specs {
if specs[i].Name == "" {
return nil, fmt.Errorf("A test spec's target must have a non-empty name")
}
}
return matchModifiersToTests(ctx, testSpecs, specs)
}
// AffectedModifiers returns modifiers for tests that are in both testSpecs and
// affectedTestNames.
// maxAttempts will be applied to any test that is not multiplied.
// Tests will be considered for multiplication only if num affected tests <= multiplyThreshold.
func AffectedModifiers(testSpecs []build.TestSpec, affectedTestNames []string, maxAttempts, multiplyThreshold int) ([]ModifierMatch, error) {
nameToSpec := make(map[string]build.TestSpec)
for _, ts := range testSpecs {
nameToSpec[ts.Name] = ts
}
var ret []ModifierMatch
if len(affectedTestNames) > multiplyThreshold {
for _, name := range affectedTestNames {
_, found := nameToSpec[name]
if !found {
continue
}
// Since we're not multiplying the tests, apply maxAttempts to them instead.
ret = append(ret, ModifierMatch{
Test: name,
Modifier: TestModifier{
Name: name,
TotalRuns: -1,
Affected: true,
MaxAttempts: maxAttempts,
},
})
}
} else {
for _, name := range affectedTestNames {
spec, found := nameToSpec[name]
if !found {
continue
}
// Only x64 Linux VMs are plentiful, don't multiply affected tests that
// would require any other type of bot. Also, don't multiply isolated tests
// because they are expected to be the only test running in its shard and
// should only run once.
if spec.CPU != x64 || (spec.OS != fuchsia && spec.OS != linux) || spec.Isolated {
ret = append(ret, ModifierMatch{
Test: name,
Modifier: TestModifier{
Name: name,
TotalRuns: -1,
Affected: true,
MaxAttempts: maxAttempts,
},
})
continue
}
for _, env := range spec.Envs {
shouldMultiply := true
if env.Dimensions.DeviceType() != "" && spec.OS != fuchsia {
// Don't multiply host+target tests because they tend to be
// flaky already. The idea is to expose new flakiness, not
// pre-existing flakiness.
shouldMultiply = false
} else if env.Dimensions.DeviceType() != "" &&
!strings.HasSuffix(env.Dimensions.DeviceType(), "EMU") {
// Only x64 Linux VMs are plentiful, don't multiply affected
// tests that would require any other type of bot.
shouldMultiply = false
}
match := ModifierMatch{
Test: name,
Env: env,
Modifier: TestModifier{Name: name, Affected: true},
}
if !shouldMultiply {
match.Modifier.TotalRuns = -1
match.Modifier.MaxAttempts = maxAttempts
}
ret = append(ret, match)
}
}
}
return ret, nil
}
// matchModifiersToTests analyzes the given modifiers against the testSpec to return
// modifiers that match tests exactly per allowed environment.
func matchModifiersToTests(ctx context.Context, testSpecs []build.TestSpec, modifiers []TestModifier) ([]ModifierMatch, error) {
var ret []ModifierMatch
var tooManyMatchesMultipliers []string
for _, modifier := range modifiers {
if modifier.Name == "*" {
ret = append(ret, ModifierMatch{Modifier: modifier})
continue
}
nameRegex, err := regexp.Compile(modifier.Name)
var exactMatches []ModifierMatch
var regexMatches []ModifierMatch
numExactMatches := 0
numRegexMatches := 0
if err != nil {
return nil, fmt.Errorf("%w %q: %s", errInvalidMultiplierRegex, modifier.Name, err)
}
for _, ts := range testSpecs {
if nameRegex.FindString(ts.Name) == "" {
continue
}
if modifier.OS != "" && modifier.OS != ts.OS {
continue
}
isExactMatch := ts.Name == modifier.Name
if len(ts.Envs) > 0 {
if isExactMatch {
numExactMatches += 1
} else {
numRegexMatches += 1
}
}
for _, env := range ts.Envs {
match := ModifierMatch{Test: ts.Name, Env: env, Modifier: modifier}
if isExactMatch {
exactMatches = append(exactMatches, match)
} else {
regexMatches = append(regexMatches, match)
}
}
}
// We'll consider partial regex matches only when we have no exact
// matches.
matches := exactMatches
numMatches := numExactMatches
if numMatches == 0 {
matches = regexMatches
numMatches = numRegexMatches
}
maxMatches := modifier.MaxMatches
if maxMatches == 0 {
maxMatches = defaultMaxMatchesPerMultiplier
}
if numMatches > maxMatches {
tooManyMatchesMessage := fmt.Sprintf(
"%s multiplier cannot match more than %d tests, matched %d",
modifier.Name,
maxMatches,
numMatches,
)
tooManyMatchesMultipliers = append(tooManyMatchesMultipliers, tooManyMatchesMessage)
logger.Errorf(ctx, "%s", tooManyMatchesMessage)
continue
}
ret = append(ret, matches...)
}
if len(tooManyMatchesMultipliers) > 0 {
return nil, errors.New(strings.Join(tooManyMatchesMultipliers, "\n"))
}
return ret, nil
}