blob: aed19ba262f38250e8d59df748d947d6af38df90 [file] [log] [blame]
package analyzer
// This file contains struct types and helper functions for manipulating
// in-memory representations of experiment measurement task metadata.
import (
"encoding/json"
"fmt"
"sort"
"strings"
"go.chromium.org/luci/common/api/swarming/swarming/v1"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/perf/go/perfresults"
)
// helper function for extracting buildInfo from swarming tasks.
func assignIfHasPrefix(prefix, source string, dest *string) {
if strings.HasPrefix(source, prefix) {
*dest = source[len(prefix):]
}
}
// helper function for extracting buildInfo from swarming tasks.
func appendIfHasPrefix(prefix, source string, dest *[]string) {
if strings.HasPrefix(source, prefix) {
*dest = append(*dest, source[len(prefix):])
}
}
// Pinpoint will set a "change:..." tag on the swarming tasks it runs for each arm.
// The contents are formatted here: https://source.chromium.org/chromium/chromium/src/+/main:third_party/catapult/dashboard/dashboard/pinpoint/models/change/change.py;l=52
// however we only care about the presence of the "change:" prefix in this function. The rest
// (if anything) is handled by the caller.
func pinpointChangeTagForTask(t *swarming.SwarmingRpcsTaskRequestMetadata) string {
for _, tag := range t.TaskResult.Tags {
if strings.HasPrefix(tag, "change:") {
return tag[len("change:"):]
}
}
return ""
}
// Request dimensions are key value pairs, and keys can appear more that once. This function
// groups values by key.
func requestDimensionsForTask(s *swarming.SwarmingRpcsTaskRequestMetadata) map[string][]string {
ret := make(map[string][]string)
for _, dim := range s.Request.Properties.Dimensions {
v := ret[dim.Key]
if v == nil {
v = []string{}
ret[dim.Key] = v
}
ret[dim.Key] = append(v, dim.Value)
}
return ret
}
func resultBotDimensionsForTask(s *swarming.SwarmingRpcsTaskRequestMetadata) map[string][]string {
ret := make(map[string][]string)
for _, dim := range s.TaskResult.BotDimensions {
v := ret[dim.Key]
if v == nil {
v = []string{}
ret[dim.Key] = v
}
ret[dim.Key] = append(v, dim.Value...)
}
return ret
}
// A build task has a tag like "buildbucket_build_id:8810006378346751937"
// May return nil, nil if the task is not a build task at all.
func buildInfoForTask(s *swarming.SwarmingRpcsTaskRequestMetadata) (*buildInfo, error) {
ret := &buildInfo{}
for _, tag := range s.TaskResult.Tags {
assignIfHasPrefix("buildbucket_build_id:", tag, &ret.buildbucketBuildID)
assignIfHasPrefix("buildbucket_hostname:", tag, &ret.buildbucketHostname)
assignIfHasPrefix("buildbucket_bucket:", tag, &ret.buildbucketBucket)
assignIfHasPrefix("builder:", tag, &ret.builder)
appendIfHasPrefix("buildset:", tag, &ret.buildSet)
}
if ret.buildbucketBuildID == "" {
return nil, nil
}
return ret, nil
}
func runInfoForTask(s *swarming.SwarmingRpcsTaskRequestMetadata) (*runInfo, error) {
ret := &runInfo{}
// t.request.Properties.Dimensions reflect what the user (e.g. via Pinpoint) asked swarming to
// run the task on.
for _, dim := range s.Request.Properties.Dimensions {
if dim.Key == "cpu" {
ret.cpu = dim.Value
}
if dim.Key == "os" || dim.Key == "device_os" {
ret.os = dim.Value
}
if dim.Key == "benchmark" {
ret.benchmark = dim.Value
}
if dim.Key == "storyfilter" {
ret.storyFilter = dim.Value
}
}
// t.result.BotDimensions reflect what hardware swarming actually executed the task on.
for _, d := range s.TaskResult.BotDimensions {
// I am not sure who sets this field or how, but it appears to be there and kinda fits
// the bill for a name for a specific "hardware/OS/other runtime stuff" configuration.
if d.Key == "synthetic_product_name" {
if len(d.Value) == 0 {
js, _ := json.Marshal(s.TaskResult)
sklog.Errorf("result: %s", string(js))
return nil, fmt.Errorf("task %q had empty values for synthetic_product_name: %#v", s.TaskId, s.TaskResult.BotDimensions)
}
ret.syntheticProductName = d.Value[0]
}
}
if ret.cpu == "" && ret.os == "" && ret.benchmark == "" && ret.storyFilter == "" && ret.syntheticProductName == "" {
sklog.Errorf("unable to get valid runInfo for task:\n%#v\n", s)
return nil, fmt.Errorf("unable to get valid runInfo for task (%+v): %+v", s, ret)
}
ret.botID = s.TaskResult.BotId
ret.startTimestamp = s.TaskResult.StartedTs
return ret, nil
}
// all of the data that is specific to one arm of an experiment.
type processedArmTasks struct {
// change tag is specific to the pinpoint tryjob use case, also useful for gerrit use case.
// This value comes from run tasks.
pinpointChangeTag string
// This value comes from build tasks.
buildset []string
tasks []*armTask
}
func (a *processedArmTasks) outputDigests() []*swarming.SwarmingRpcsCASReference {
ret := []*swarming.SwarmingRpcsCASReference{}
for _, t := range a.tasks {
ret = append(ret, t.resultOutput)
}
return ret
}
type buildInfo struct {
buildbucketBucket,
buildbucketBuildID,
buildbucketHostname,
builder string
buildSet []string
}
func (b *buildInfo) String() string {
if b == nil {
return ""
}
return b.builder
}
type runInfo struct {
// Example: "Macmini9,1_arm64-64-Apple_M1_16384_1_4744421.0"
syntheticProductName string
cpu, os string
// These are more for valdating against what we expect to find in the results, according to the
// requested AnalysisSpec.
benchmark, storyFilter string
// These fields are for determining pairing order.
botID, startTimestamp string
}
func (r *runInfo) String() string {
return fmt.Sprintf("%s,%s", r.os, r.cpu)
}
// all of the data that is specific to one measurement task for one arm of an experiment.
type armTask struct {
taskID string
resultOutput *swarming.SwarmingRpcsCASReference
buildConfig, runConfig string
buildInfo *buildInfo
parsedResults map[string]perfresults.PerfResults
taskInfo *swarming.SwarmingRpcsTaskRequestMetadata
}
// all of the data collected for an experiment.
type processedExperimentTasks struct {
control, treatment *processedArmTasks
}
// returns a list of task pairs, where pairs are identified by botID and start time for the task.
func (a *processedExperimentTasks) pairedTasks(excludedSwarmingTasks map[string]*SwarmingTaskDiagnostics) ([]pairedTasks, error) {
ret := []pairedTasks{}
// Sort tasks based on bot id + task start time.
sort.Sort(byPairingOrder(a.control.tasks))
sort.Sort(byPairingOrder(a.treatment.tasks))
cPointer := 0
tPointer := 0
for cPointer < len(a.control.tasks) && tPointer < len(a.treatment.tasks) {
c := a.control.tasks[cPointer]
t := a.treatment.tasks[tPointer]
if c.taskInfo.TaskResult.BotId == t.taskInfo.TaskResult.BotId && c.runConfig == t.runConfig && c.buildConfig == t.buildConfig {
if _, ok := excludedSwarmingTasks[c.taskID]; ok {
sklog.Warningf("Exclude swarming tasks: %s and %s, since the control task %s is in the exclude list", c.taskID, t.taskID, c.taskID)
} else if _, ok := excludedSwarmingTasks[t.taskID]; ok {
sklog.Warningf("Exclude swarming tasks: %s and %s, since the treatment task %s is in the exclude list", c.taskID, t.taskID, t.taskID)
} else {
ret = append(ret, pairedTasks{c, t})
}
cPointer++
tPointer++
} else if c.taskInfo.TaskResult.BotId != t.taskInfo.TaskResult.BotId {
// if bot id don't match, skip the smaller bot id
if c.taskInfo.TaskResult.BotId < t.taskInfo.TaskResult.BotId {
sklog.Warningf("Exclude control swarming tasks: %s, since the bot id don't match. c.BotId = %s, t.BotId = %s", c.taskID, c.taskInfo.TaskResult.BotId, t.taskInfo.TaskResult.BotId)
cPointer++
} else {
sklog.Warningf("Exclude treatment swarming tasks: %s, since the bot id don't match. c.BotId = %s, t.BotId = %s", t.taskID, c.taskInfo.TaskResult.BotId, t.taskInfo.TaskResult.BotId)
tPointer++
}
} else {
// if bot id match but the config doesn't match, skip both tasks.
sklog.Warningf("Exclude swarming tasks: %s and %s, since the config don't match. c.runConfig = %s, t.runConfig = %s, c.buildConfig = %s, t.buildConfig = %s", c.taskID, t.taskID, c.runConfig, t.runConfig, c.buildConfig, t.buildConfig)
cPointer++
tPointer++
}
}
return ret, nil
}
type pairedTasks struct {
control, treatment *armTask
}
func (p *pairedTasks) isControlOrderFirst() bool {
return p.control.taskInfo.TaskResult.StartedTs < p.treatment.taskInfo.TaskResult.StartedTs
}
func (p *pairedTasks) hasTaskFailures() bool {
if p.control.taskInfo.TaskResult.ExitCode != 0 || p.treatment.taskInfo.TaskResult.ExitCode != 0 {
return true
}
if p.control.parsedResults == nil || p.treatment.parsedResults == nil {
return true
}
return false
}
type byPairingOrder []*armTask
func (v byPairingOrder) Len() int { return len(v) }
func (v byPairingOrder) Swap(i, j int) { v[i], v[j] = v[j], v[i] }
// This looks fairly naive, but its assumptions should hold. Task pairs should execute on the same
// bot ID (device), and sorting tasks by start time within a given bot ID should give us the
// actual run order for tasks on that bot. Even if bots are re-used by subsequent task pairs,
// the tasks should still be in pairing order within the given bot ID.
func (v byPairingOrder) Less(i, j int) bool {
if v[i].taskInfo.TaskResult.BotId == v[j].taskInfo.TaskResult.BotId {
return v[i].taskInfo.TaskResult.StartedTs < v[j].taskInfo.TaskResult.StartedTs
}
return v[i].taskInfo.TaskResult.BotId < v[j].taskInfo.TaskResult.BotId
}