blob: 3fd6a50bbd8b21c37b3ec36c9d083d5c45506389 [file] [log] [blame]
// Copyright 2018 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 (
"fmt"
"sort"
"strings"
)
// Shard represents a set of tests with a common execution environment.
type Shard struct {
// Name is the identifier for the shard.
Name string `json:"name"`
// Tests is the set of tests to be executed in this shard.
Tests []Test `json:"tests"`
// Env is a generalized notion of the execution environment for the shard.
Env Environment `json:"environment"`
}
// MakeShards is the core algorithm to this tool. It takes a set of test specs and produces
// a set of shards which may then be converted into Swarming tasks.
// A single output Shard will contain only tests that have the same Envs.
//
// Environments that do not match all tags will be ignored.
//
// In Restricted mode, environments that don't specify a ServiceAccount will be ignored.
func MakeShards(specs []TestSpec, mode Mode, tags []string) []*Shard {
// Collect the order of the shards so our shard ordering is deterministic with
// respect to the input.
envToSuites := newEnvMap()
envs := []Environment{}
for _, spec := range specs {
for _, env := range spec.Envs {
if !stringSlicesEq(tags, env.Tags) {
continue
}
if mode == Restricted && env.ServiceAccount != "" {
continue
}
// Tags should not differ by ordering.
sortableTags := sort.StringSlice(tags)
sortableTags.Sort()
env.Tags = []string(sortableTags)
specs, ok := envToSuites.get(env)
if !ok {
envs = append(envs, env)
}
envToSuites.set(env, append(specs, spec))
}
}
shards := make([]*Shard, 0, len(envs))
for _, env := range envs {
specs, _ := envToSuites.get(env)
sort.Slice(specs, func(i, j int) bool {
return specs[i].Test.Name < specs[j].Test.Name
})
var tests []Test
for _, spec := range specs {
tests = append(tests, spec.Test)
}
shards = append(shards, &Shard{
Name: env.Name(),
Tests: tests,
Env: env,
})
}
return shards
}
// MultiplyShards appends new shards to shards where each new shard contains one test
// repeated multiple times according to the specifications in multipliers.
func MultiplyShards(shards []*Shard, multipliers []TestModifier) []*Shard {
for _, shard := range shards {
for _, multiplier := range multipliers {
for _, test := range shard.Tests {
if multiplier.Target == test.Name && multiplier.OS == test.OS {
shards = append(shards, &Shard{
Name: shard.Name + "-" + normalizeTestName(test.Name),
Tests: multiplyTest(test, multiplier.TotalRuns),
Env: shard.Env,
})
}
}
}
}
return shards
}
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
}
// WithMaxSize returns a list of shards such that each shard contains fewer than maxShardSize tests.
// If maxShardSize <= 0, just returns its input.
func WithMaxSize(shards []*Shard, maxShardSize int) []*Shard {
if maxShardSize <= 0 {
return shards
}
output := make([]*Shard, 0, len(shards))
for _, shard := range shards {
numNewShards := divRoundUp(len(shard.Tests), maxShardSize)
// Evenly distribute the tests between the new shards.
maxTestsPerNewShard := divRoundUp(len(shard.Tests), numNewShards)
for i := 0; i < numNewShards; i++ {
sliceStart := i * maxTestsPerNewShard
sliceLimit := min((i+1)*maxTestsPerNewShard, len(shard.Tests))
newName := shard.Name
if numNewShards > 1 {
newName = fmt.Sprintf("%s-(%d)", shard.Name, i+1)
}
output = append(output, &Shard{
Name: newName,
Tests: shard.Tests[sliceStart:sliceLimit],
Env: shard.Env,
})
}
}
return output
}
// 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, "/", "_")
}
// Returns a list of Tests containing the same test multiplied by the number of runs.
func multiplyTest(test Test, runs int) []Test {
var tests []Test
for i := 1; i <= runs; i++ {
testCopy := test
testCopy.Name = fmt.Sprintf("%s (%d)", test.Name, i)
tests = append(tests, testCopy)
}
return tests
}
// Abstracts a mapping Environment -> []string, as Environment contains non-comparable
// members (e.g., string slices), which makes it invalid for a map key.
type envMap struct {
m map[string][]TestSpec
}
func newEnvMap() envMap {
return envMap{m: make(map[string][]TestSpec)}
}
func (em envMap) get(e Environment) ([]TestSpec, bool) {
specs, ok := em.m[fmt.Sprintf("%v", e)]
return specs, ok
}
func (em *envMap) set(e Environment, specs []TestSpec) {
em.m[fmt.Sprintf("%v", e)] = specs
}
func stringSlicesEq(s []string, t []string) bool {
if len(s) != len(t) {
return false
}
seen := make(map[string]int)
for i := range s {
seen[s[i]]++
seen[t[i]]--
}
for _, v := range seen {
if v != 0 {
return false
}
}
return true
}