go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/tryjob/execute/execute_test.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package execute
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"math"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/protobuf/proto"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	bbpb "go.chromium.org/luci/buildbucket/proto"
    28  	"go.chromium.org/luci/common/clock"
    29  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    30  	"go.chromium.org/luci/gae/service/datastore"
    31  
    32  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    33  	"go.chromium.org/luci/cv/api/recipe/v1"
    34  	bbfacade "go.chromium.org/luci/cv/internal/buildbucket/facade"
    35  	"go.chromium.org/luci/cv/internal/changelist"
    36  	"go.chromium.org/luci/cv/internal/common"
    37  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    38  	"go.chromium.org/luci/cv/internal/cvtesting"
    39  	"go.chromium.org/luci/cv/internal/metrics"
    40  	"go.chromium.org/luci/cv/internal/run"
    41  	"go.chromium.org/luci/cv/internal/tryjob"
    42  
    43  	. "github.com/smartystreets/goconvey/convey"
    44  	. "go.chromium.org/luci/common/testing/assertions"
    45  )
    46  
    47  func TestPrepExecutionPlan(t *testing.T) {
    48  	t.Parallel()
    49  	Convey("PrepExecutionPlan", t, func() {
    50  		ct := cvtesting.Test{}
    51  		ctx, cancel := ct.SetUp(t)
    52  		defer cancel()
    53  
    54  		executor := &Executor{
    55  			Env: ct.Env,
    56  		}
    57  
    58  		Convey("Tryjobs Updated", func() {
    59  			const builderFoo = "foo"
    60  			r := &run.Run{
    61  				Mode: run.FullRun,
    62  			}
    63  			prepPlan := func(execState *tryjob.ExecutionState, updatedTryjobs ...int64) *plan {
    64  				_, p, err := executor.prepExecutionPlan(ctx, execState, r, updatedTryjobs, false)
    65  				So(err, ShouldBeNil)
    66  				return p
    67  			}
    68  			Convey("Updates", func() {
    69  				const prevTryjobID = 101
    70  				const curTryjobID = 102
    71  				builderFooDef := makeDefinition(builderFoo, true)
    72  				execState := newExecStateBuilder().
    73  					appendDefinition(builderFooDef).
    74  					appendAttempt(builderFoo, makeAttempt(prevTryjobID, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN)).
    75  					appendAttempt(builderFoo, makeAttempt(curTryjobID, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN)).
    76  					build()
    77  				Convey("Updates previous attempts", func() {
    78  					ensureTryjob(ctx, prevTryjobID, builderFooDef, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY)
    79  					plan := prepPlan(execState, prevTryjobID)
    80  					So(plan.isEmpty(), ShouldBeTrue)
    81  					So(execState, ShouldResembleProto, newExecStateBuilder().
    82  						appendDefinition(builderFooDef).
    83  						appendAttempt(builderFoo, makeAttempt(prevTryjobID, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY)).
    84  						appendAttempt(builderFoo, makeAttempt(curTryjobID, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN)).
    85  						build())
    86  				})
    87  				Convey("Ignore For not relevant Tryjob", func() {
    88  					original := proto.Clone(execState).(*tryjob.ExecutionState)
    89  					const randomTryjob int64 = 567
    90  					ensureTryjob(ctx, randomTryjob, builderFooDef, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY)
    91  					plan := prepPlan(execState, randomTryjob)
    92  					So(plan.isEmpty(), ShouldBeTrue)
    93  					So(execState, ShouldResembleProto, original)
    94  				})
    95  				So(executor.logEntries, ShouldBeEmpty)
    96  			})
    97  
    98  			Convey("Single Tryjob", func() {
    99  				const tjID = 101
   100  				def := makeDefinition(builderFoo, true)
   101  				execState := newExecStateBuilder().
   102  					appendDefinition(def).
   103  					appendAttempt(builderFoo, makeAttempt(tjID, tryjob.Status_PENDING, tryjob.Result_UNKNOWN)).
   104  					withRetryConfig(&cfgpb.Verifiers_Tryjob_RetryConfig{
   105  						SingleQuota:            2,
   106  						GlobalQuota:            10,
   107  						FailureWeight:          5,
   108  						TransientFailureWeight: 1,
   109  					}).
   110  					build()
   111  
   112  				Convey("Succeeded", func() {
   113  					tj := ensureTryjob(ctx, tjID, def, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   114  					plan := prepPlan(execState, tjID)
   115  					So(plan.isEmpty(), ShouldBeTrue)
   116  					So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{
   117  						makeAttempt(tjID, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED),
   118  					})
   119  					So(execState.Status, ShouldEqual, tryjob.ExecutionState_SUCCEEDED)
   120  					So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   121  						{
   122  							Time: timestamppb.New(ct.Clock.Now().UTC()),
   123  							Kind: &tryjob.ExecutionLogEntry_TryjobsEnded_{
   124  								TryjobsEnded: &tryjob.ExecutionLogEntry_TryjobsEnded{
   125  									Tryjobs: []*tryjob.ExecutionLogEntry_TryjobSnapshot{
   126  										makeLogTryjobSnapshot(def, tj, false),
   127  									},
   128  								},
   129  							},
   130  						},
   131  					})
   132  				})
   133  
   134  				Convey("Succeeded but not reusable", func() {
   135  					ro := &recipe.Output{
   136  						Reusability: &recipe.Output_Reusability{
   137  							ModeAllowlist: []string{string(run.DryRun)},
   138  						},
   139  					}
   140  					So(isModeAllowed(r.Mode, ro.GetReusability().GetModeAllowlist()), ShouldBeFalse)
   141  					tj := ensureTryjob(ctx, tjID, def, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   142  					tj.Result.Output = ro
   143  					So(datastore.Put(ctx, tj), ShouldBeNil)
   144  					tryjob.LatestAttempt(execState.GetExecutions()[0]).Reused = true
   145  					plan := prepPlan(execState, tjID)
   146  					So(plan.isEmpty(), ShouldBeFalse)
   147  					So(plan.triggerNewAttempt, ShouldHaveLength, 1)
   148  					So(plan.triggerNewAttempt[0].definition, ShouldResembleProto, def)
   149  					attempt := makeAttempt(tjID, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   150  					attempt.Reused = true
   151  					attempt.Result.Output = ro
   152  					So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{attempt})
   153  					So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   154  					So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   155  						{
   156  							Time: timestamppb.New(ct.Clock.Now().UTC()),
   157  							Kind: &tryjob.ExecutionLogEntry_TryjobsEnded_{
   158  								TryjobsEnded: &tryjob.ExecutionLogEntry_TryjobsEnded{
   159  									Tryjobs: []*tryjob.ExecutionLogEntry_TryjobSnapshot{
   160  										makeLogTryjobSnapshot(def, tj, true),
   161  									},
   162  								},
   163  							},
   164  						},
   165  					})
   166  				})
   167  
   168  				Convey("Still Running", func() {
   169  					tj := ensureTryjob(ctx, tjID, def, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN)
   170  					Convey("Tryjob is critical", func() {
   171  						plan := prepPlan(execState, tjID)
   172  						So(plan.isEmpty(), ShouldBeTrue)
   173  						So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   174  						So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{
   175  							makeAttempt(tjID, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN),
   176  						})
   177  						Convey("but no longer reusable", func() {
   178  							ro := &recipe.Output{
   179  								Reusability: &recipe.Output_Reusability{
   180  									ModeAllowlist: []string{string(run.DryRun)},
   181  								},
   182  							}
   183  							So(isModeAllowed(r.Mode, ro.GetReusability().GetModeAllowlist()), ShouldBeFalse)
   184  							tj.Result.Output = ro
   185  							So(datastore.Put(ctx, tj), ShouldBeNil)
   186  							tryjob.LatestAttempt(execState.GetExecutions()[0]).Reused = true
   187  							plan := prepPlan(execState, tjID)
   188  							So(plan.isEmpty(), ShouldBeFalse)
   189  							So(plan.triggerNewAttempt, ShouldHaveLength, 1)
   190  							So(plan.triggerNewAttempt[0].definition, ShouldResembleProto, def)
   191  							attempt := makeAttempt(tjID, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN)
   192  							attempt.Reused = true
   193  							attempt.Result.Output = ro
   194  							So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{attempt})
   195  							So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   196  						})
   197  					})
   198  					Convey("Tryjob is not critical", func() {
   199  						execState.GetRequirement().GetDefinitions()[0].Critical = false
   200  						plan := prepPlan(execState, tjID)
   201  						So(plan.isEmpty(), ShouldBeTrue)
   202  						So(execState.Status, ShouldEqual, tryjob.ExecutionState_SUCCEEDED)
   203  						So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{
   204  							makeAttempt(tjID, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN),
   205  						})
   206  					})
   207  					So(executor.logEntries, ShouldBeEmpty)
   208  				})
   209  
   210  				Convey("Failed", func() {
   211  					var tj *tryjob.Tryjob
   212  					Convey("Critical and can retry", func() {
   213  						// Quota allows retrying transient failure.
   214  						tj = ensureTryjob(ctx, tjID, def, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY)
   215  						plan := prepPlan(execState, tjID)
   216  						So(plan.isEmpty(), ShouldBeFalse)
   217  						So(plan.triggerNewAttempt, ShouldHaveLength, 1)
   218  						So(plan.triggerNewAttempt[0].definition, ShouldResembleProto, execState.GetRequirement().GetDefinitions()[0])
   219  						So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{
   220  							makeAttempt(tjID, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY),
   221  						})
   222  						So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   223  						So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   224  							{
   225  								Time: timestamppb.New(ct.Clock.Now().UTC()),
   226  								Kind: &tryjob.ExecutionLogEntry_TryjobsEnded_{
   227  									TryjobsEnded: &tryjob.ExecutionLogEntry_TryjobsEnded{
   228  										Tryjobs: []*tryjob.ExecutionLogEntry_TryjobSnapshot{
   229  											makeLogTryjobSnapshot(def, tj, false),
   230  										},
   231  									},
   232  								},
   233  							},
   234  						})
   235  					})
   236  					Convey("Critical and can NOT retry", func() {
   237  						// Quota doesn't allow retrying permanent failure.
   238  						tj = ensureTryjob(ctx, tjID, def, tryjob.Status_ENDED, tryjob.Result_FAILED_PERMANENTLY)
   239  						plan := prepPlan(execState, tjID)
   240  						So(plan.isEmpty(), ShouldBeTrue)
   241  						So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{
   242  							makeAttempt(tjID, tryjob.Status_ENDED, tryjob.Result_FAILED_PERMANENTLY),
   243  						})
   244  						So(execState.Status, ShouldEqual, tryjob.ExecutionState_FAILED)
   245  						So(execState.Failures, ShouldResembleProto, &tryjob.ExecutionState_Failures{
   246  							UnsuccessfulResults: []*tryjob.ExecutionState_Failures_UnsuccessfulResult{
   247  								{TryjobId: tjID},
   248  							},
   249  						})
   250  						So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   251  							{
   252  								Time: timestamppb.New(ct.Clock.Now().UTC()),
   253  								Kind: &tryjob.ExecutionLogEntry_TryjobsEnded_{
   254  									TryjobsEnded: &tryjob.ExecutionLogEntry_TryjobsEnded{
   255  										Tryjobs: []*tryjob.ExecutionLogEntry_TryjobSnapshot{
   256  											makeLogTryjobSnapshot(def, tj, false),
   257  										},
   258  									},
   259  								},
   260  							},
   261  							{
   262  								Time: timestamppb.New(ct.Clock.Now().UTC()),
   263  								Kind: &tryjob.ExecutionLogEntry_RetryDenied_{
   264  									RetryDenied: &tryjob.ExecutionLogEntry_RetryDenied{
   265  										Tryjobs: []*tryjob.ExecutionLogEntry_TryjobSnapshot{
   266  											makeLogTryjobSnapshot(def, tj, false),
   267  										},
   268  										Reason: "insufficient quota",
   269  									},
   270  								},
   271  							},
   272  						})
   273  					})
   274  					Convey("Tryjob is not critical", func() {
   275  						tj = ensureTryjob(ctx, tjID, def, tryjob.Status_ENDED, tryjob.Result_FAILED_PERMANENTLY)
   276  						execState.GetRequirement().GetDefinitions()[0].Critical = false
   277  						plan := prepPlan(execState, tjID)
   278  						So(plan.isEmpty(), ShouldBeTrue)
   279  						So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{
   280  							makeAttempt(tjID, tryjob.Status_ENDED, tryjob.Result_FAILED_PERMANENTLY),
   281  						})
   282  						// Still consider execution as succeeded even though non-critical
   283  						// tryjob has failed.
   284  						So(execState.Status, ShouldEqual, tryjob.ExecutionState_SUCCEEDED)
   285  						So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   286  							{
   287  								Time: timestamppb.New(ct.Clock.Now().UTC()),
   288  								Kind: &tryjob.ExecutionLogEntry_TryjobsEnded_{
   289  									TryjobsEnded: &tryjob.ExecutionLogEntry_TryjobsEnded{
   290  										Tryjobs: []*tryjob.ExecutionLogEntry_TryjobSnapshot{
   291  											makeLogTryjobSnapshot(def, tj, false),
   292  										},
   293  									},
   294  								},
   295  							},
   296  						})
   297  					})
   298  				})
   299  
   300  				Convey("Untriggered", func() {
   301  					ensureTryjob(ctx, tjID, def, tryjob.Status_UNTRIGGERED, tryjob.Result_UNKNOWN)
   302  					Convey("Reused", func() {
   303  						tryjob.LatestAttempt(execState.GetExecutions()[0]).Reused = true
   304  						plan := prepPlan(execState, tjID)
   305  						So(plan.isEmpty(), ShouldBeFalse)
   306  						So(plan.triggerNewAttempt, ShouldHaveLength, 1)
   307  						So(plan.triggerNewAttempt[0].definition, ShouldResembleProto, execState.GetRequirement().GetDefinitions()[0])
   308  						attempt := makeAttempt(tjID, tryjob.Status_UNTRIGGERED, tryjob.Result_UNKNOWN)
   309  						attempt.Reused = true
   310  						So(execState.Executions[0].Attempts, ShouldResembleProto, []*tryjob.ExecutionState_Execution_Attempt{attempt})
   311  						So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   312  					})
   313  					Convey("Not critical", func() {
   314  						execState.GetRequirement().GetDefinitions()[0].Critical = false
   315  						plan := prepPlan(execState, tjID)
   316  						So(plan.isEmpty(), ShouldBeTrue)
   317  					})
   318  					So(executor.logEntries, ShouldBeEmpty)
   319  				})
   320  			})
   321  
   322  			Convey("Multiple Tryjobs", func() {
   323  				builder1Def := makeDefinition("builder1", true)
   324  				builder2Def := makeDefinition("builder2", false)
   325  				builder3Def := makeDefinition("builder3", true)
   326  				execState := newExecStateBuilder().
   327  					appendDefinition(builder1Def).
   328  					appendAttempt("builder1", makeAttempt(101, tryjob.Status_PENDING, tryjob.Result_UNKNOWN)).
   329  					appendDefinition(builder2Def).
   330  					appendAttempt("builder2", makeAttempt(201, tryjob.Status_PENDING, tryjob.Result_UNKNOWN)).
   331  					appendDefinition(builder3Def).
   332  					appendAttempt("builder3", makeAttempt(301, tryjob.Status_PENDING, tryjob.Result_UNKNOWN)).
   333  					withRetryConfig(&cfgpb.Verifiers_Tryjob_RetryConfig{
   334  						SingleQuota:            2,
   335  						GlobalQuota:            10,
   336  						FailureWeight:          5,
   337  						TransientFailureWeight: 1,
   338  					}).
   339  					build()
   340  
   341  				Convey("Has non-ended critical Tryjobs", func() {
   342  					ensureTryjob(ctx, 101, builder1Def, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   343  					ensureTryjob(ctx, 201, builder2Def, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   344  					// 301 has not completed.
   345  					plan := prepPlan(execState, 101, 201)
   346  					So(plan.isEmpty(), ShouldBeTrue)
   347  					So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   348  				})
   349  				Convey("Execution ends when all critical tryjob ended", func() {
   350  					ensureTryjob(ctx, 101, builder1Def, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   351  					// 201 (non-critical) has not completed.
   352  					ensureTryjob(ctx, 301, builder3Def, tryjob.Status_ENDED, tryjob.Result_SUCCEEDED)
   353  					plan := prepPlan(execState, 101, 301)
   354  					So(plan.isEmpty(), ShouldBeTrue)
   355  					So(execState.Status, ShouldEqual, tryjob.ExecutionState_SUCCEEDED)
   356  				})
   357  				Convey("Failed critical Tryjob fails the execution", func() {
   358  					ensureTryjob(ctx, 101, builder1Def, tryjob.Status_ENDED, tryjob.Result_FAILED_PERMANENTLY)
   359  					// One critical tryjob is still running.
   360  					ensureTryjob(ctx, 301, builder3Def, tryjob.Status_TRIGGERED, tryjob.Result_UNKNOWN)
   361  					plan := prepPlan(execState, 101, 301)
   362  					So(plan.isEmpty(), ShouldBeTrue)
   363  					So(execState.Status, ShouldEqual, tryjob.ExecutionState_FAILED)
   364  					So(execState.Failures, ShouldResembleProto, &tryjob.ExecutionState_Failures{
   365  						UnsuccessfulResults: []*tryjob.ExecutionState_Failures_UnsuccessfulResult{
   366  							{TryjobId: 101},
   367  						},
   368  					})
   369  				})
   370  				Convey("Can retry multiple", func() {
   371  					ensureTryjob(ctx, 101, builder1Def, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY)
   372  					// 201 has not completed.
   373  					ensureTryjob(ctx, 301, builder3Def, tryjob.Status_ENDED, tryjob.Result_FAILED_TRANSIENTLY)
   374  					plan := prepPlan(execState, 101, 301)
   375  					So(plan.isEmpty(), ShouldBeFalse)
   376  					So(plan.triggerNewAttempt, ShouldHaveLength, 2)
   377  					So(execState.Status, ShouldEqual, tryjob.ExecutionState_RUNNING)
   378  				})
   379  			})
   380  		})
   381  
   382  		Convey("Requirement Changed", func() {
   383  			var builderFooDef = makeDefinition("builderFoo", true)
   384  			latestReqmt := &tryjob.Requirement{
   385  				Definitions: []*tryjob.Definition{
   386  					builderFooDef,
   387  				},
   388  			}
   389  			r := &run.Run{
   390  				Tryjobs: &run.Tryjobs{
   391  					Requirement:           latestReqmt,
   392  					RequirementVersion:    2,
   393  					RequirementComputedAt: timestamppb.New(ct.Clock.Now()),
   394  				},
   395  			}
   396  
   397  			Convey("No-op if current version newer than target version", func() {
   398  				execState := newExecStateBuilder().
   399  					withRequirementVersion(int(r.Tryjobs.GetRequirementVersion()) + 1).
   400  					build()
   401  				_, plan, err := executor.prepExecutionPlan(ctx, execState, r, nil, true)
   402  				So(err, ShouldBeNil)
   403  				So(plan.isEmpty(), ShouldBeTrue)
   404  				So(executor.logEntries, ShouldBeEmpty)
   405  			})
   406  
   407  			Convey("New Requirement", func() {
   408  				execState := newExecStateBuilder().build()
   409  				execState, plan, err := executor.prepExecutionPlan(ctx, execState, r, nil, true)
   410  				So(err, ShouldBeNil)
   411  				So(execState.Requirement, ShouldResembleProto, latestReqmt)
   412  				So(plan.triggerNewAttempt, ShouldHaveLength, 1)
   413  				So(plan.triggerNewAttempt[0].definition, ShouldResembleProto, builderFooDef)
   414  				So(executor.logEntries, ShouldBeEmpty)
   415  			})
   416  
   417  			Convey("Removed Tryjob", func() {
   418  				removedDef := makeDefinition("removed", true)
   419  				execState := newExecStateBuilder().
   420  					appendDefinition(removedDef).
   421  					appendDefinition(builderFooDef).
   422  					build()
   423  				execState, plan, err := executor.prepExecutionPlan(ctx, execState, r, nil, true)
   424  				So(err, ShouldBeNil)
   425  				So(execState.Requirement, ShouldResembleProto, latestReqmt)
   426  				So(plan.triggerNewAttempt, ShouldBeEmpty)
   427  				So(plan.discard, ShouldHaveLength, 1)
   428  				So(plan.discard[0].definition, ShouldResembleProto, removedDef)
   429  				So(execState.Executions, ShouldHaveLength, 1)
   430  				So(plan.discard[0].discardReason, ShouldEqual, noLongerRequiredInConfig)
   431  				So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   432  					{
   433  						Time: timestamppb.New(ct.Clock.Now().UTC()),
   434  						Kind: &tryjob.ExecutionLogEntry_RequirementChanged_{
   435  							RequirementChanged: &tryjob.ExecutionLogEntry_RequirementChanged{},
   436  						},
   437  					},
   438  				})
   439  			})
   440  
   441  			Convey("Changed Tryjob", func() {
   442  				builderFooOriginal := proto.Clone(builderFooDef).(*tryjob.Definition)
   443  				builderFooOriginal.DisableReuse = !builderFooDef.DisableReuse
   444  				execState := newExecStateBuilder().
   445  					appendDefinition(builderFooOriginal).
   446  					build()
   447  				execState, plan, err := executor.prepExecutionPlan(ctx, execState, r, nil, true)
   448  				So(err, ShouldBeNil)
   449  				So(execState.Requirement, ShouldResembleProto, latestReqmt)
   450  				So(plan.isEmpty(), ShouldBeTrue)
   451  				So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   452  					{
   453  						Time: timestamppb.New(ct.Clock.Now().UTC()),
   454  						Kind: &tryjob.ExecutionLogEntry_RequirementChanged_{
   455  							RequirementChanged: &tryjob.ExecutionLogEntry_RequirementChanged{},
   456  						},
   457  					},
   458  				})
   459  			})
   460  
   461  			Convey("Change in retry config", func() {
   462  				execState := newExecStateBuilder().
   463  					appendDefinition(builderFooDef).
   464  					withRetryConfig(&cfgpb.Verifiers_Tryjob_RetryConfig{
   465  						SingleQuota: 2,
   466  						GlobalQuota: 10,
   467  					}).
   468  					build()
   469  				execState, plan, err := executor.prepExecutionPlan(ctx, execState, r, nil, true)
   470  				So(err, ShouldBeNil)
   471  				So(execState.Requirement, ShouldResembleProto, latestReqmt)
   472  				So(plan.isEmpty(), ShouldBeTrue)
   473  				So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   474  					{
   475  						Time: timestamppb.New(ct.Clock.Now().UTC()),
   476  						Kind: &tryjob.ExecutionLogEntry_RequirementChanged_{
   477  							RequirementChanged: &tryjob.ExecutionLogEntry_RequirementChanged{},
   478  						},
   479  					},
   480  				})
   481  			})
   482  
   483  			Convey("Empty definitions", func() {
   484  				latestReqmt.Definitions = nil
   485  				execState := newExecStateBuilder().
   486  					appendDefinition(builderFooDef).
   487  					build()
   488  				execState, plan, err := executor.prepExecutionPlan(ctx, execState, r, nil, true)
   489  				So(err, ShouldBeNil)
   490  				So(execState.Requirement, ShouldResembleProto, latestReqmt)
   491  				So(plan.discard, ShouldHaveLength, 1)
   492  				So(execState.Status, ShouldEqual, tryjob.ExecutionState_SUCCEEDED)
   493  			})
   494  		})
   495  	})
   496  }
   497  
   498  func TestExecutePlan(t *testing.T) {
   499  	t.Parallel()
   500  	Convey("ExecutePlan", t, func() {
   501  		ct := cvtesting.Test{}
   502  		ctx, cancel := ct.SetUp(t)
   503  		defer cancel()
   504  
   505  		Convey("Discard tryjobs", func() {
   506  			executor := &Executor{
   507  				Env: ct.Env,
   508  			}
   509  			const bbHost = "buildbucket.example.com"
   510  			def := &tryjob.Definition{
   511  				Backend: &tryjob.Definition_Buildbucket_{
   512  					Buildbucket: &tryjob.Definition_Buildbucket{
   513  						Host: bbHost,
   514  						Builder: &bbpb.BuilderID{
   515  							Project: "some_proj",
   516  							Bucket:  "some_bucket",
   517  							Builder: "some_builder",
   518  						},
   519  					},
   520  				},
   521  			}
   522  			_, err := executor.executePlan(ctx, &plan{
   523  				discard: []planItem{
   524  					{
   525  						definition: def,
   526  						execution: &tryjob.ExecutionState_Execution{
   527  							Attempts: []*tryjob.ExecutionState_Execution_Attempt{
   528  								{
   529  									TryjobId:   67,
   530  									ExternalId: string(tryjob.MustBuildbucketID(bbHost, 6767)),
   531  								},
   532  								{
   533  									TryjobId:   58,
   534  									ExternalId: string(tryjob.MustBuildbucketID(bbHost, 5858)),
   535  								},
   536  							},
   537  						},
   538  						discardReason: "no longer needed",
   539  					},
   540  				},
   541  			}, nil, nil)
   542  			So(err, ShouldBeNil)
   543  			So(executor.logEntries, ShouldResembleProto, []*tryjob.ExecutionLogEntry{
   544  				{
   545  					Time: timestamppb.New(ct.Clock.Now().UTC()),
   546  					Kind: &tryjob.ExecutionLogEntry_TryjobDiscarded_{
   547  						TryjobDiscarded: &tryjob.ExecutionLogEntry_TryjobDiscarded{
   548  							Snapshot: &tryjob.ExecutionLogEntry_TryjobSnapshot{
   549  								Definition: def,
   550  								Id:         58,
   551  								ExternalId: string(tryjob.MustBuildbucketID(bbHost, 5858)),
   552  							},
   553  							Reason: "no longer needed",
   554  						},
   555  					},
   556  				},
   557  			})
   558  		})
   559  
   560  		Convey("Trigger new attempt", func() {
   561  			const (
   562  				lProject        = "test_proj"
   563  				configGroupName = "test_config_group"
   564  				bbHost          = "buildbucket.example.com"
   565  				buildID         = 9524107902457
   566  				clid            = 34586452134
   567  				gHost           = "example-review.com"
   568  				gRepo           = "repo/a"
   569  				gChange         = 123
   570  				gPatchset       = 5
   571  				gMinPatchset    = 4
   572  			)
   573  			now := ct.Clock.Now().UTC()
   574  			var runID = common.MakeRunID(lProject, now.Add(-1*time.Hour), 1, []byte("abcd"))
   575  			r := &run.Run{
   576  				ID:            runID,
   577  				ConfigGroupID: prjcfg.MakeConfigGroupID("deedbeef", configGroupName),
   578  				Mode:          run.DryRun,
   579  				CLs:           common.CLIDs{clid},
   580  				Status:        run.Status_RUNNING,
   581  			}
   582  			runCL := &run.RunCL{
   583  				ID:  clid,
   584  				Run: datastore.NewKey(ctx, common.RunKind, string(runID), 0, nil),
   585  				Detail: &changelist.Snapshot{
   586  					Patchset:              gPatchset,
   587  					MinEquivalentPatchset: gMinPatchset,
   588  					Kind: &changelist.Snapshot_Gerrit{
   589  						Gerrit: &changelist.Gerrit{
   590  							Host: gHost,
   591  							Info: &gerritpb.ChangeInfo{
   592  								Project: gRepo,
   593  								Number:  gChange,
   594  								Owner: &gerritpb.AccountInfo{
   595  									Email: "owner@example.com",
   596  								},
   597  							},
   598  						},
   599  					},
   600  				},
   601  				Trigger: &run.Trigger{
   602  					Mode:  string(run.DryRun),
   603  					Email: "triggerer@example.com",
   604  				},
   605  			}
   606  			So(datastore.Put(ctx, runCL), ShouldBeNil)
   607  			builder := &bbpb.BuilderID{
   608  				Project: lProject,
   609  				Bucket:  "some_bucket",
   610  				Builder: "some_builder",
   611  			}
   612  			def := &tryjob.Definition{
   613  				Backend: &tryjob.Definition_Buildbucket_{
   614  					Buildbucket: &tryjob.Definition_Buildbucket{
   615  						Host:    bbHost,
   616  						Builder: builder,
   617  					},
   618  				},
   619  				Critical: true,
   620  			}
   621  			ct.BuildbucketFake.AddBuilder(bbHost, builder, nil)
   622  			executor := &Executor{
   623  				Env: ct.Env,
   624  				Backend: &bbfacade.Facade{
   625  					ClientFactory: ct.BuildbucketFake.NewClientFactory(),
   626  				},
   627  				RM: run.NewNotifier(ct.TQDispatcher),
   628  			}
   629  			execution := &tryjob.ExecutionState_Execution{}
   630  			execState := &tryjob.ExecutionState{
   631  				Executions: []*tryjob.ExecutionState_Execution{execution},
   632  				Requirement: &tryjob.Requirement{
   633  					Definitions: []*tryjob.Definition{def},
   634  				},
   635  				Status: tryjob.ExecutionState_RUNNING,
   636  			}
   637  
   638  			Convey("New Tryjob is not launched when tryjob can be reused", func() {
   639  				result := &tryjob.Result{
   640  					CreateTime: timestamppb.New(now.Add(-staleTryjobAge / 2)),
   641  					Backend: &tryjob.Result_Buildbucket_{
   642  						Buildbucket: &tryjob.Result_Buildbucket{
   643  							Id:      buildID,
   644  							Builder: builder,
   645  						},
   646  					},
   647  					Status: tryjob.Result_SUCCEEDED,
   648  				}
   649  				reuseTryjob := &tryjob.Tryjob{
   650  					ExternalID:       tryjob.MustBuildbucketID(bbHost, buildID),
   651  					EVersion:         1,
   652  					EntityCreateTime: now.Add(-staleTryjobAge / 2),
   653  					EntityUpdateTime: now.Add(-1 * time.Minute),
   654  					ReuseKey:         computeReuseKey([]*run.RunCL{runCL}),
   655  					CLPatchsets:      tryjob.CLPatchsets{tryjob.MakeCLPatchset(runCL.ID, gPatchset)},
   656  					Definition:       def,
   657  					Status:           tryjob.Status_ENDED,
   658  					LaunchedBy:       common.MakeRunID(lProject, now.Add(-2*time.Hour), 1, []byte("efgh")),
   659  					Result:           result,
   660  				}
   661  				So(datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   662  					return tryjob.SaveTryjobs(ctx, []*tryjob.Tryjob{reuseTryjob}, nil)
   663  				}, nil), ShouldBeNil)
   664  				execState, err := executor.executePlan(ctx, &plan{
   665  					triggerNewAttempt: []planItem{
   666  						{
   667  							definition: def,
   668  							execution:  execution,
   669  						},
   670  					},
   671  				}, r, execState)
   672  				So(err, ShouldBeNil)
   673  				So(execState.GetStatus(), ShouldEqual, tryjob.ExecutionState_RUNNING)
   674  				So(execState.GetExecutions()[0].GetAttempts(), ShouldResembleProto,
   675  					[]*tryjob.ExecutionState_Execution_Attempt{
   676  						{
   677  							TryjobId:   int64(reuseTryjob.ID),
   678  							ExternalId: string(tryjob.MustBuildbucketID(bbHost, buildID)),
   679  							Status:     tryjob.Status_ENDED,
   680  							Result:     result,
   681  							Reused:     true,
   682  						},
   683  					})
   684  				So(executor.stagedMetricReportFns, ShouldBeEmpty)
   685  			})
   686  
   687  			Convey("When Tryjob can't be reused, thus launch a new Tryjob", func() {
   688  				execState, err := executor.executePlan(ctx, &plan{
   689  					triggerNewAttempt: []planItem{
   690  						{
   691  							definition: def,
   692  							execution:  execution,
   693  						},
   694  					},
   695  				}, r, execState)
   696  				So(err, ShouldBeNil)
   697  				So(execState.GetStatus(), ShouldEqual, tryjob.ExecutionState_RUNNING)
   698  				So(execState.GetExecutions()[0].GetAttempts(), ShouldHaveLength, 1)
   699  				attempt := execState.GetExecutions()[0].GetAttempts()[0]
   700  				So(attempt.GetTryjobId(), ShouldNotEqual, 0)
   701  				So(attempt.GetExternalId(), ShouldNotBeEmpty)
   702  				So(attempt.GetStatus(), ShouldEqual, tryjob.Status_TRIGGERED)
   703  				So(attempt.GetReused(), ShouldBeFalse)
   704  				So(executor.stagedMetricReportFns, ShouldHaveLength, 1)
   705  				executor.stagedMetricReportFns[0](ctx)
   706  				tryjob.RunWithBuilderMetricsTarget(ctx, ct.Env, def, func(ctx context.Context) {
   707  					So(ct.TSMonSentValue(ctx, metrics.Public.TryjobLaunched, lProject, configGroupName, true, false), ShouldEqual, 1)
   708  				})
   709  			})
   710  
   711  			Convey("Fail the execution if encounter launch failure", func() {
   712  				def.GetBuildbucket().GetBuilder().Project = "another_proj"
   713  				execState, err := executor.executePlan(ctx, &plan{
   714  					triggerNewAttempt: []planItem{
   715  						{
   716  							definition: def,
   717  							execution:  execution,
   718  						},
   719  					},
   720  				}, r, execState)
   721  				So(err, ShouldBeNil)
   722  				So(execState.GetExecutions()[0].GetAttempts(), ShouldHaveLength, 1)
   723  				attempt := execState.GetExecutions()[0].GetAttempts()[0]
   724  				So(attempt.GetTryjobId(), ShouldNotEqual, 0)
   725  				So(attempt.GetExternalId(), ShouldBeEmpty)
   726  				So(attempt.GetStatus(), ShouldEqual, tryjob.Status_UNTRIGGERED)
   727  				So(execState.Status, ShouldEqual, tryjob.ExecutionState_FAILED)
   728  				So(execState.Failures, ShouldResembleProto, &tryjob.ExecutionState_Failures{
   729  					LaunchFailures: []*tryjob.ExecutionState_Failures_LaunchFailure{
   730  						{
   731  							Definition: def,
   732  							Reason:     "received NotFound from buildbucket. message: builder another_proj/some_bucket/some_builder not found",
   733  						},
   734  					},
   735  				})
   736  				So(executor.stagedMetricReportFns, ShouldBeEmpty)
   737  			})
   738  		})
   739  	})
   740  }
   741  
   742  type execStateBuilder struct {
   743  	state *tryjob.ExecutionState
   744  }
   745  
   746  func newExecStateBuilder(original ...*tryjob.ExecutionState) *execStateBuilder {
   747  	switch len(original) {
   748  	case 0:
   749  		return &execStateBuilder{
   750  			state: initExecutionState(),
   751  		}
   752  	case 1:
   753  		return &execStateBuilder{
   754  			state: original[0],
   755  		}
   756  	default:
   757  		panic(fmt.Errorf("more than one original execState provided"))
   758  	}
   759  }
   760  
   761  func (esb *execStateBuilder) withStatus(status tryjob.ExecutionState_Status) *execStateBuilder {
   762  	esb.state.Status = status
   763  	return esb
   764  }
   765  
   766  func (esb *execStateBuilder) appendDefinition(def *tryjob.Definition) *execStateBuilder {
   767  	esb.ensureRequirement()
   768  	esb.state.Requirement.Definitions = append(esb.state.Requirement.Definitions, def)
   769  	esb.state.Executions = append(esb.state.Executions, &tryjob.ExecutionState_Execution{})
   770  	return esb
   771  }
   772  
   773  func (esb *execStateBuilder) withRetryConfig(retryConfig *cfgpb.Verifiers_Tryjob_RetryConfig) *execStateBuilder {
   774  	esb.ensureRequirement()
   775  	esb.state.Requirement.RetryConfig = proto.Clone(retryConfig).(*cfgpb.Verifiers_Tryjob_RetryConfig)
   776  	return esb
   777  }
   778  
   779  func (esb *execStateBuilder) withRequirementVersion(ver int) *execStateBuilder {
   780  	esb.state.RequirementVersion = int32(ver)
   781  	return esb
   782  }
   783  
   784  func (esb *execStateBuilder) appendAttempt(builderName string, attempt *tryjob.ExecutionState_Execution_Attempt) *execStateBuilder {
   785  	esb.ensureRequirement()
   786  	for i, def := range esb.state.Requirement.GetDefinitions() {
   787  		if builderName == def.GetBuildbucket().GetBuilder().GetBuilder() {
   788  			esb.state.Executions[i].Attempts = append(esb.state.Executions[i].Attempts, attempt)
   789  			return esb
   790  		}
   791  	}
   792  	panic(fmt.Errorf("can't find builder %s", builderName))
   793  }
   794  
   795  func (esb *execStateBuilder) ensureRequirement() {
   796  	if esb.state.Requirement == nil {
   797  		esb.state.Requirement = &tryjob.Requirement{}
   798  	}
   799  }
   800  
   801  func (esb *execStateBuilder) build() *tryjob.ExecutionState {
   802  	return esb.state
   803  }
   804  
   805  func makeDefinition(builder string, critical bool) *tryjob.Definition {
   806  	return &tryjob.Definition{
   807  		Backend: &tryjob.Definition_Buildbucket_{
   808  			Buildbucket: &tryjob.Definition_Buildbucket{
   809  				Host: "buildbucket.example.com",
   810  				Builder: &bbpb.BuilderID{
   811  					Project: "test",
   812  					Bucket:  "bucket",
   813  					Builder: builder,
   814  				},
   815  			},
   816  		},
   817  		Critical: critical,
   818  	}
   819  }
   820  
   821  func makeAttempt(tjID int64, status tryjob.Status, resultStatus tryjob.Result_Status) *tryjob.ExecutionState_Execution_Attempt {
   822  	return &tryjob.ExecutionState_Execution_Attempt{
   823  		TryjobId:   int64(tjID),
   824  		ExternalId: string(tryjob.MustBuildbucketID("buildbucket.example.com", math.MaxInt64-tjID)),
   825  		Status:     status,
   826  		Result: &tryjob.Result{
   827  			Status: resultStatus,
   828  		},
   829  	}
   830  }
   831  
   832  func ensureTryjob(ctx context.Context, id int64, def *tryjob.Definition, status tryjob.Status, resultStatus tryjob.Result_Status) *tryjob.Tryjob {
   833  	now := datastore.RoundTime(clock.Now(ctx).UTC())
   834  	tj := &tryjob.Tryjob{
   835  		ID:               common.TryjobID(id),
   836  		ExternalID:       tryjob.MustBuildbucketID("buildbucket.example.com", math.MaxInt64-id),
   837  		Definition:       def,
   838  		Status:           status,
   839  		EntityCreateTime: now,
   840  		EntityUpdateTime: now,
   841  		Result: &tryjob.Result{
   842  			Status: resultStatus,
   843  		},
   844  	}
   845  	So(datastore.Put(ctx, tj), ShouldBeNil)
   846  	return tj
   847  }