blob: 19b788246c5beb0901708dca2db3aa2e70042808 [file]
package internal
import (
"github.com/google/shlex"
swarming_pb "go.chromium.org/luci/swarming/proto/api_v2"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/pinpoint/go/common"
"go.skia.org/infra/pinpoint/go/compare"
"go.skia.org/infra/pinpoint/go/sql/schema"
"go.skia.org/infra/pinpoint/go/workflows"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
pinpoint_proto "go.skia.org/infra/pinpoint/proto/v1"
)
const (
failed = "Failed"
completed = "Completed"
canceled = "Canceled"
running = "Running"
)
func PairwiseWorkflow(ctx workflow.Context, p *workflows.PairwiseParams) (
pe *pinpoint_proto.PairwiseExecution, finalError error,
) {
if p.Request.StartBuild == nil && p.Request.StartCommit == nil {
return nil, skerr.Fmt("Base build and commit are empty.")
}
if p.Request.EndBuild == nil && p.Request.EndCommit == nil {
return nil, skerr.Fmt("Experiment build and commit are empty.")
}
leftCas, err := convertCas(p.Request.StartBuild)
if err != nil {
return nil, skerr.Wrapf(err, "start build is invalid")
}
rightCas, err := convertCas(p.Request.EndBuild)
if err != nil {
return nil, skerr.Wrapf(err, "end build is invalid")
}
ctx = workflow.WithChildOptions(ctx, childWorkflowOptions)
ctx = workflow.WithActivityOptions(ctx, regularActivityOptions)
dbActivityOptions := regularActivityOptions
// For local development/testing (when Project is empty or "local"), set database retries to 1 (fail-fast)
if p.Request.Project == "" || p.Request.Project == "local" {
dbActivityOptions.RetryPolicy = &temporal.RetryPolicy{
MaximumAttempts: 1,
}
}
dbCtx := workflow.WithActivityOptions(ctx, dbActivityOptions)
jobID := workflow.GetInfo(ctx).WorkflowExecution.ID
workflowStartTime := workflow.Now(ctx)
if err := workflow.ExecuteActivity(dbCtx, AddInitialJob, p.Request, jobID).Get(dbCtx, nil); err != nil {
// TODO(b/439651386) Convert subsequent uses of sklog to full errors once Job Store activities are
// more stable and fully integrated
sklog.Errorf("failed to add initial job info to Spanner: %s", err)
}
// Benchmark runs can sometimes generate an inconsistent number of data points.
// So even if all benchmark runs were successful, the number of data values
// generated by commit A vs B will be inconsistent. This can fail the balancing
// requirement of the statistical test. It can pair up data incorrectly i.e.
// commit A: [1], [2, 3]
// commit B: [4, 5], [6]
// So the analysis will pair up [2,5] together, which are from different runs,
// violating pairwise analysis
if p.Request.AggregationMethod == "" {
p.Request.AggregationMethod = "mean"
}
leftExtraArgs, err := splitExtraArgs(p.Request.BaseExtraArgs)
if err != nil {
return nil, skerr.Wrap(err)
}
rightExtraArgs, err := splitExtraArgs(p.Request.ExperimentExtraArgs)
if err != nil {
return nil, skerr.Wrap(err)
}
pairwiseRunnerParams := PairwiseCommitsRunnerParams{
SingleCommitRunnerParams: SingleCommitRunnerParams{
PinpointJobID: jobID,
BotConfig: p.Request.Configuration,
Benchmark: p.Request.Benchmark,
Chart: p.Request.Chart,
Story: p.Request.Story,
StoryTags: p.Request.StoryTags,
AggregationMethod: p.Request.AggregationMethod,
Iterations: p.GetInitialAttempt(),
},
LeftCAS: leftCas,
RightCAS: rightCas,
LeftCommit: (*common.CombinedCommit)(p.Request.StartCommit),
RightCommit: (*common.CombinedCommit)(p.Request.EndCommit),
LeftExtraArgs: leftExtraArgs,
RightExtraArgs: rightExtraArgs,
}
mh := workflow.GetMetricsHandler(ctx).WithTags(map[string]string{
"job_id": jobID,
"benchmark": p.Request.Benchmark,
"config": p.Request.Configuration,
"story": p.Request.Story,
})
protoResults := map[string]*pinpoint_proto.PairwiseExecution_WilcoxonResult{}
defer func() {
durationTime := workflow.Now(ctx).Sub(workflowStartTime)
mh.Timer("pairwise_duration").Record(durationTime)
durationInSeconds := durationTime.Seconds()
// Create a new disconnected context for cleanup activities.
// This ensures that even if the workflow context is canceled,
// these final activities can still be scheduled and run.
disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx)
disconnectedDbCtx := workflow.WithActivityOptions(disconnectedCtx, dbActivityOptions)
// Final writebacks to Spanner before end of Pairwise workflow
if finalError != nil {
if temporal.IsCanceledError(finalError) {
if err := workflow.ExecuteActivity(disconnectedDbCtx, UpdateJobStatus, jobID, canceled, durationInSeconds).Get(disconnectedDbCtx, nil); err != nil {
sklog.Errorf("couldn't update status for canceled pairwise job with this ID: %s", jobID)
}
} else {
// Pairwise job failed for other reasons.
if err := workflow.ExecuteActivity(disconnectedDbCtx, SetErrors, jobID, finalError.Error()).Get(disconnectedDbCtx, nil); err != nil {
sklog.Errorf("couldn't add error for pairwise job with this ID: %s", jobID)
}
if err := workflow.ExecuteActivity(disconnectedDbCtx, UpdateJobStatus, jobID, failed, durationInSeconds).Get(disconnectedDbCtx, nil); err != nil {
sklog.Errorf("couldn't update status for pairwise job with this ID: %s", jobID)
}
}
} else {
if err := workflow.ExecuteActivity(disconnectedDbCtx, UpdateJobStatus, jobID, completed, durationInSeconds).Get(disconnectedDbCtx, nil); err != nil {
sklog.Errorf("couldn't update status for pairwise job with this ID: %s", jobID)
}
// Write back to database the results of the comparison through the job store object
if err := workflow.ExecuteActivity(disconnectedDbCtx, AddResults, jobID, protoResults).Get(disconnectedDbCtx, nil); err != nil {
sklog.Errorf("couldn't add results for pairwise job with this ID: %s", jobID)
}
}
}()
// Update status to running
if err := workflow.ExecuteActivity(dbCtx, UpdateJobStatus, jobID, running, int64(0)).Get(dbCtx, nil); err != nil {
sklog.Errorf("couldn't update status for running pairwise job with this ID: %s", jobID)
}
var pr *PairwiseRun
if err := workflow.ExecuteChildWorkflow(ctx, workflows.PairwiseCommitsRunner, &pairwiseRunnerParams).Get(ctx, &pr); err != nil {
return nil, skerr.Wrap(err)
}
// Store details of commit buids and test runs
if pr != nil {
leftData := &schema.CommitRunData{
Build: pr.Left.Build,
Runs: pr.Left.Runs,
}
rightData := &schema.CommitRunData{
Build: pr.Right.Build,
Runs: pr.Right.Runs,
}
if err := workflow.ExecuteActivity(dbCtx, AddCommitRuns, jobID, leftData, rightData).Get(dbCtx, nil); err != nil {
sklog.Errorf("couldn't add commit runs for pairwise job with this ID: %s", jobID)
}
}
results, err := comparePairwiseRuns(ctx, pr, compare.UnknownDir)
if err != nil {
return nil, skerr.Wrapf(err, "failed to compare pairwise runs")
}
// we only return the "culprit" if this is a culprit-verification run.
// Culprit verification workflows are run in parallel and returned via
// channels. So the parent culprit_finder workflow is unable to determine
// which completed child workflow belongs to which set of inputs. We
// explicitly return this commit to work around this limitation.
var culpritCandidate *pinpoint_proto.CombinedCommit
if p.CulpritVerify {
culpritCandidate = (*pinpoint_proto.CombinedCommit)(pairwiseRunnerParams.RightCommit)
}
for chart, res := range common.SortedRange(results) {
protoResults[chart] = &pinpoint_proto.PairwiseExecution_WilcoxonResult{
// Significant is used in CulpritFinder to determine whether to bisect.
// Significant = true means that there's indeed a regression and it should
// be investigated. If significant is not explicitly set to False, we see
// Temporal workflows with Significant and Culprit omitted because the resolve
// to nil.
Significant: res.Verdict == compare.Different,
PValue: res.PValue,
ConfidenceIntervalLower: res.LowerCi,
ConfidenceIntervalHigher: res.UpperCi,
ControlMedian: res.XMedian,
TreatmentMedian: res.YMedian,
}
}
return &pinpoint_proto.PairwiseExecution{
JobId: jobID,
CulpritCandidate: culpritCandidate,
Results: protoResults,
LeftSwarmingStatus: pr.Left.GetSwarmingStatus(),
RightSwarmingStatus: pr.Right.GetSwarmingStatus(),
}, nil
}
func convertCas(pinpointCas *pinpoint_proto.CASReference) (*swarming_pb.CASReference, error) {
if pinpointCas == nil {
return nil, nil
}
if pinpointCas.Digest == nil {
return nil, skerr.Fmt("cas digest cannot be nil: %v", pinpointCas)
}
return &swarming_pb.CASReference{
CasInstance: pinpointCas.CasInstance,
Digest: &swarming_pb.Digest{
Hash: pinpointCas.Digest.Hash,
SizeBytes: pinpointCas.Digest.SizeBytes,
},
}, nil
}
func splitExtraArgs(args string) ([]string, error) {
if args == "" {
return nil, nil
}
parsed, err := shlex.Split(args)
if err != nil {
return nil, skerr.Wrapf(err, "failed to parse extra arguments %q", args)
}
return parsed, nil
}