go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/prjmanager/state/components_test.go (about)

     1  // Copyright 2021 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 state
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sort"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/protobuf/types/known/timestamppb"
    25  
    26  	"go.chromium.org/luci/auth/identity"
    27  	"go.chromium.org/luci/common/clock/testclock"
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/gae/service/datastore"
    30  
    31  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    32  	"go.chromium.org/luci/cv/internal/changelist"
    33  	"go.chromium.org/luci/cv/internal/common"
    34  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    35  	"go.chromium.org/luci/cv/internal/cvtesting"
    36  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    37  	"go.chromium.org/luci/cv/internal/gerrit/trigger"
    38  	"go.chromium.org/luci/cv/internal/prjmanager"
    39  	"go.chromium.org/luci/cv/internal/prjmanager/itriager"
    40  	"go.chromium.org/luci/cv/internal/prjmanager/pmtest"
    41  	"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
    42  	"go.chromium.org/luci/cv/internal/run"
    43  	"go.chromium.org/luci/cv/internal/run/runcreator"
    44  	"go.chromium.org/luci/cv/internal/run/runquery"
    45  	"go.chromium.org/luci/cv/internal/tryjob"
    46  
    47  	. "github.com/smartystreets/goconvey/convey"
    48  	. "go.chromium.org/luci/common/testing/assertions"
    49  )
    50  
    51  func TestEarliestDecisionTime(t *testing.T) {
    52  	t.Parallel()
    53  
    54  	Convey("earliestDecisionTime works", t, func() {
    55  		now := testclock.TestRecentTimeUTC
    56  		t0 := now.Add(time.Hour)
    57  
    58  		earliest := func(cs []*prjpb.Component) time.Time {
    59  			t, tPB, asap := earliestDecisionTime(cs)
    60  			if asap {
    61  				return now
    62  			}
    63  			if t.IsZero() {
    64  				So(tPB, ShouldBeNil)
    65  			} else {
    66  				So(tPB.AsTime(), ShouldResemble, t)
    67  			}
    68  			return t
    69  		}
    70  
    71  		cs := []*prjpb.Component{
    72  			{DecisionTime: nil},
    73  		}
    74  		So(earliest(cs), ShouldResemble, time.Time{})
    75  
    76  		cs = append(cs, &prjpb.Component{DecisionTime: timestamppb.New(t0.Add(time.Second))})
    77  		So(earliest(cs), ShouldResemble, t0.Add(time.Second))
    78  
    79  		cs = append(cs, &prjpb.Component{})
    80  		So(earliest(cs), ShouldResemble, t0.Add(time.Second))
    81  
    82  		cs = append(cs, &prjpb.Component{DecisionTime: timestamppb.New(t0.Add(time.Hour))})
    83  		So(earliest(cs), ShouldResemble, t0.Add(time.Second))
    84  
    85  		cs = append(cs, &prjpb.Component{DecisionTime: timestamppb.New(t0)})
    86  		So(earliest(cs), ShouldResemble, t0)
    87  
    88  		cs = append(cs, &prjpb.Component{
    89  			TriageRequired: true,
    90  			// DecisionTime in this case doesn't matter.
    91  			DecisionTime: timestamppb.New(t0.Add(10 * time.Hour)),
    92  		})
    93  		So(earliest(cs), ShouldResemble, now)
    94  	})
    95  }
    96  
    97  func TestComponentsActions(t *testing.T) {
    98  	t.Parallel()
    99  
   100  	Convey("Component actions logic work in the abstract", t, func() {
   101  		ct := cvtesting.Test{}
   102  		ctx, cancel := ct.SetUp(t)
   103  		defer cancel()
   104  		now := ct.Clock.Now()
   105  
   106  		const lProject = "luci-project"
   107  
   108  		prjcfgtest.Create(ctx, lProject, &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{Name: "main"}}})
   109  		meta := prjcfgtest.MustExist(ctx, lProject)
   110  		pmNotifier := prjmanager.NewNotifier(ct.TQDispatcher)
   111  		runNotifier := run.NewNotifier(ct.TQDispatcher)
   112  		tjNotifier := tryjob.NewNotifier(ct.TQDispatcher)
   113  		h := Handler{
   114  			PMNotifier:  pmNotifier,
   115  			RunNotifier: runNotifier,
   116  			CLMutator:   changelist.NewMutator(ct.TQDispatcher, pmNotifier, runNotifier, tjNotifier),
   117  		}
   118  		state := &State{
   119  			PB: &prjpb.PState{
   120  				LuciProject: lProject,
   121  				Status:      prjpb.Status_STARTED,
   122  				ConfigHash:  meta.Hash(),
   123  				Pcls: []*prjpb.PCL{
   124  					{Clid: 1},
   125  					{Clid: 2},
   126  					{Clid: 3},
   127  					{Clid: 999},
   128  				},
   129  				Components: []*prjpb.Component{
   130  					{Clids: []int64{999}}, // never sees any action.
   131  					{Clids: []int64{1}, DecisionTime: timestamppb.New(now.Add(1 * time.Minute))},
   132  					{Clids: []int64{2}, DecisionTime: timestamppb.New(now.Add(2 * time.Minute))},
   133  					{Clids: []int64{3}, DecisionTime: timestamppb.New(now.Add(3 * time.Minute))},
   134  				},
   135  				NextEvalTime: timestamppb.New(now.Add(1 * time.Minute)),
   136  			},
   137  		}
   138  
   139  		pb := backupPB(state)
   140  
   141  		markComponentsForTriage := func(indexes ...int) {
   142  			for _, i := range indexes {
   143  				state.PB.GetComponents()[i].TriageRequired = true
   144  			}
   145  			pb = backupPB(state)
   146  		}
   147  
   148  		markTriaged := func(c *prjpb.Component) *prjpb.Component {
   149  			if !c.GetTriageRequired() {
   150  				panic(fmt.Errorf("must required triage"))
   151  			}
   152  			o := c.CloneShallow()
   153  			o.TriageRequired = false
   154  			return o
   155  		}
   156  
   157  		calledOn := make(chan *prjpb.Component, len(state.PB.Components))
   158  		collectCalledOn := func() []int {
   159  			var out []int
   160  		loop:
   161  			for {
   162  				select {
   163  				case c := <-calledOn:
   164  					out = append(out, int(c.GetClids()[0]))
   165  				default:
   166  					break loop
   167  				}
   168  			}
   169  			sort.Ints(out)
   170  			return out
   171  		}
   172  
   173  		Convey("noop at triage", func() {
   174  			h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   175  				calledOn <- c
   176  				return itriager.Result{}, nil
   177  			}
   178  			actions, saveForDebug, err := h.triageComponents(ctx, state)
   179  			So(err, ShouldBeNil)
   180  			So(saveForDebug, ShouldBeFalse)
   181  			So(actions, ShouldBeNil)
   182  			So(state.PB, ShouldResembleProto, pb)
   183  			So(collectCalledOn(), ShouldBeEmpty)
   184  
   185  			Convey("ExecDeferred", func() {
   186  				state2, sideEffect, err := h.ExecDeferred(ctx, state)
   187  				So(err, ShouldBeNil)
   188  				So(state.PB, ShouldResembleProto, pb)
   189  				So(state2, ShouldEqual, state) // pointer comparison
   190  				So(sideEffect, ShouldBeNil)
   191  				// Always creates new task iff there is NextEvalTime.
   192  				So(pmtest.ETAsOF(ct.TQ.Tasks(), lProject), ShouldNotBeEmpty)
   193  			})
   194  		})
   195  
   196  		Convey("triage called on TriageRequired components or when decision time is <= now", func() {
   197  			ct.Clock.Set(state.PB.Components[1].DecisionTime.AsTime())
   198  			c1next := state.PB.Components[1].DecisionTime.AsTime().Add(time.Hour)
   199  			markComponentsForTriage(3)
   200  			h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   201  				calledOn <- c
   202  				switch c.GetClids()[0] {
   203  				case 1:
   204  					c = c.CloneShallow()
   205  					c.DecisionTime = timestamppb.New(c1next)
   206  					return itriager.Result{NewValue: c}, nil
   207  				case 3:
   208  					return itriager.Result{NewValue: markTriaged(c)}, nil
   209  				}
   210  				panic("unreachable")
   211  			}
   212  			actions, saveForDebug, err := h.triageComponents(ctx, state)
   213  			So(err, ShouldBeNil)
   214  			So(saveForDebug, ShouldBeFalse)
   215  			So(actions, ShouldHaveLength, 2)
   216  			So(collectCalledOn(), ShouldResemble, []int{1, 3})
   217  
   218  			Convey("ExecDeferred", func() {
   219  				state2, sideEffect, err := h.ExecDeferred(ctx, state)
   220  				So(err, ShouldBeNil)
   221  				So(sideEffect, ShouldBeNil)
   222  				pb.NextEvalTime = timestamppb.New(now.Add(2 * time.Minute))
   223  				pb.Components[1].DecisionTime = timestamppb.New(c1next)
   224  				pb.Components[3].TriageRequired = false
   225  				So(state2.PB, ShouldResembleProto, pb)
   226  				So(pmtest.ETAsWithin(ct.TQ.Tasks(), lProject, time.Second, now.Add(2*time.Minute)), ShouldNotBeEmpty)
   227  			})
   228  		})
   229  
   230  		Convey("purges CLs", func() {
   231  			markComponentsForTriage(1, 2, 3)
   232  			h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   233  				switch clid := c.GetClids()[0]; clid {
   234  				case 1, 3:
   235  					return itriager.Result{CLsToPurge: []*prjpb.PurgeCLTask{{
   236  						PurgingCl: &prjpb.PurgingCL{Clid: clid,
   237  							ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
   238  						},
   239  						PurgeReasons: []*prjpb.PurgeReason{{
   240  							ClError: &changelist.CLError{
   241  								Kind: &changelist.CLError_OwnerLacksEmail{
   242  									OwnerLacksEmail: true,
   243  								},
   244  							},
   245  							ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true},
   246  						}},
   247  					}}}, nil
   248  				case 2:
   249  					return itriager.Result{}, nil
   250  				}
   251  				panic("unreachable")
   252  			}
   253  			actions, saveForDebug, err := h.triageComponents(ctx, state)
   254  			So(err, ShouldBeNil)
   255  			So(saveForDebug, ShouldBeFalse)
   256  			So(actions, ShouldHaveLength, 3)
   257  			So(state.PB, ShouldResembleProto, pb)
   258  
   259  			Convey("ExecDeferred", func() {
   260  				state2, sideEffects, err := h.ExecDeferred(ctx, state)
   261  				So(err, ShouldBeNil)
   262  				expectedDeadline := timestamppb.New(now.Add(maxPurgingCLDuration))
   263  				So(state2.PB.GetPurgingCls(), ShouldResembleProto, []*prjpb.PurgingCL{
   264  					{Clid: 1, OperationId: "1580640000-1", Deadline: expectedDeadline,
   265  						ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
   266  					},
   267  					{Clid: 3, OperationId: "1580640000-3", Deadline: expectedDeadline,
   268  						ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
   269  					},
   270  				})
   271  
   272  				sideEffect := sideEffects.(*SideEffects).items[0]
   273  				So(sideEffect, ShouldHaveSameTypeAs, &TriggerPurgeCLTasks{})
   274  				ps := sideEffect.(*TriggerPurgeCLTasks).payloads
   275  				So(ps, ShouldHaveLength, 2)
   276  				// Unlike PB.PurgingCls, the tasks aren't necessarily sorted.
   277  				sort.Slice(ps, func(i, j int) bool { return ps[i].GetPurgingCl().GetClid() < ps[j].GetPurgingCl().GetClid() })
   278  				So(ps[0].GetPurgingCl(), ShouldResembleProto, state2.PB.GetPurgingCls()[0]) // CL#1
   279  				So(ps[0].GetLuciProject(), ShouldEqual, lProject)
   280  				So(ps[1].GetPurgingCl(), ShouldResembleProto, state2.PB.GetPurgingCls()[1]) // CL#3
   281  			})
   282  		})
   283  
   284  		Convey("trigger CL Deps", func() {
   285  			markComponentsForTriage(1, 2, 3)
   286  			h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   287  				switch clid := c.GetClids()[0]; clid {
   288  				case 3:
   289  					return itriager.Result{CLsToTriggerDeps: []*prjpb.TriggeringCLDeps{{
   290  						OriginClid: 3,
   291  						DepClids:   []int64{1, 2},
   292  					}}}, nil
   293  				case 1, 2:
   294  					return itriager.Result{}, nil
   295  				}
   296  				panic("unreachable")
   297  			}
   298  			actions, saveForDebug, err := h.triageComponents(ctx, state)
   299  			So(err, ShouldBeNil)
   300  			So(saveForDebug, ShouldBeFalse)
   301  			So(actions, ShouldHaveLength, 3)
   302  			So(state.PB, ShouldResembleProto, pb)
   303  
   304  			Convey("ExecDeferred", func() {
   305  				state2, sideEffects, err := h.ExecDeferred(ctx, state)
   306  				So(err, ShouldBeNil)
   307  				expectedDeadline := timestamppb.New(now.Add(prjpb.MaxTriggeringCLDepsDuration))
   308  				So(state2.PB.GetTriggeringClDeps(), ShouldHaveLength, 1)
   309  				So(state2.PB.GetTriggeringClDeps()[0], ShouldResembleProto, &prjpb.TriggeringCLDeps{
   310  					OriginClid:  3,
   311  					DepClids:    []int64{1, 2},
   312  					OperationId: fmt.Sprintf("%d-3", expectedDeadline.AsTime().Unix()),
   313  					Deadline:    expectedDeadline,
   314  				})
   315  
   316  				sideEffect := sideEffects.(*SideEffects).items[0]
   317  				So(sideEffect, ShouldHaveSameTypeAs, &ScheduleTriggeringCLDepsTasks{})
   318  				ts := sideEffect.(*ScheduleTriggeringCLDepsTasks).payloads
   319  				So(ts, ShouldHaveLength, 1)
   320  				// Sort tasks. Tasks aren't necessarily sorted.
   321  				sort.Slice(ts, func(i, j int) bool {
   322  					lhs := ts[i].GetTriggeringClDeps().GetOriginClid()
   323  					rhs := ts[j].GetTriggeringClDeps().GetOriginClid()
   324  					return lhs < rhs
   325  				})
   326  				So(ts, ShouldHaveLength, 1)
   327  				So(ts[0].GetLuciProject(), ShouldEqual, lProject)
   328  				So(ts[0].GetTriggeringClDeps(), ShouldResembleProto,
   329  					state2.PB.GetTriggeringClDeps()[0])
   330  			})
   331  		})
   332  
   333  		Convey("partial failure in triage", func() {
   334  			markComponentsForTriage(1, 2, 3)
   335  			h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   336  				switch c.GetClids()[0] {
   337  				case 1:
   338  					return itriager.Result{}, errors.New("oops1")
   339  				case 2, 3:
   340  					return itriager.Result{NewValue: markTriaged(c)}, nil
   341  				}
   342  				panic("unreachable")
   343  			}
   344  			actions, saveForDebug, err := h.triageComponents(ctx, state)
   345  			So(err, ShouldBeNil)
   346  			So(saveForDebug, ShouldBeFalse)
   347  			So(actions, ShouldHaveLength, 2)
   348  			So(state.PB, ShouldResembleProto, pb)
   349  
   350  			Convey("ExecDeferred", func() {
   351  				// Execute slightly after #1 component decision time.
   352  				ct.Clock.Set(pb.Components[1].DecisionTime.AsTime().Add(time.Microsecond))
   353  				state2, sideEffect, err := h.ExecDeferred(ctx, state)
   354  				So(err, ShouldBeNil)
   355  				So(sideEffect, ShouldBeNil)
   356  				pb.Components[2].TriageRequired = false
   357  				pb.Components[3].TriageRequired = false
   358  				pb.NextEvalTime = timestamppb.New(ct.Clock.Now()) // re-triage ASAP.
   359  				So(state2.PB, ShouldResembleProto, pb)
   360  				// Self-poke task must be scheduled for earliest possible from now.
   361  				So(pmtest.ETAsWithin(ct.TQ.Tasks(), lProject, time.Second, ct.Clock.Now().Add(prjpb.PMTaskInterval)), ShouldNotBeEmpty)
   362  			})
   363  		})
   364  
   365  		Convey("outdated PMState detected during triage", func() {
   366  			markComponentsForTriage(1, 2, 3)
   367  			h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   368  				switch c.GetClids()[0] {
   369  				case 1:
   370  					return itriager.Result{}, errors.Annotate(itriager.ErrOutdatedPMState, "smth changed").Err()
   371  				case 2, 3:
   372  					return itriager.Result{NewValue: markTriaged(c)}, nil
   373  				}
   374  				panic("unreachable")
   375  			}
   376  			actions, saveForDebug, err := h.triageComponents(ctx, state)
   377  			So(err, ShouldBeNil)
   378  			So(saveForDebug, ShouldBeFalse)
   379  			So(actions, ShouldHaveLength, 2)
   380  			So(state.PB, ShouldResembleProto, pb)
   381  
   382  			Convey("ExecDeferred", func() {
   383  				state2, sideEffect, err := h.ExecDeferred(ctx, state)
   384  				So(err, ShouldBeNil)
   385  				So(sideEffect, ShouldBeNil)
   386  				pb.Components[2].TriageRequired = false
   387  				pb.Components[3].TriageRequired = false
   388  				pb.NextEvalTime = timestamppb.New(ct.Clock.Now()) // re-triage ASAP.
   389  				So(state2.PB, ShouldResembleProto, pb)
   390  				// Self-poke task must be scheduled for earliest possible from now.
   391  				So(pmtest.ETAsWithin(ct.TQ.Tasks(), lProject, time.Second, ct.Clock.Now().Add(prjpb.PMTaskInterval)), ShouldNotBeEmpty)
   392  			})
   393  		})
   394  
   395  		Convey("100% failure in triage", func() {
   396  			markComponentsForTriage(1, 2)
   397  			h.ComponentTriage = func(_ context.Context, _ *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   398  				return itriager.Result{}, errors.New("oops")
   399  			}
   400  			_, _, err := h.triageComponents(ctx, state)
   401  			So(err, ShouldErrLike, "failed to triage 2 components")
   402  			So(state.PB, ShouldResembleProto, pb)
   403  
   404  			Convey("ExecDeferred", func() {
   405  				state2, sideEffect, err := h.ExecDeferred(ctx, state)
   406  				So(err, ShouldNotBeNil)
   407  				So(sideEffect, ShouldBeNil)
   408  				So(state2, ShouldBeNil)
   409  			})
   410  		})
   411  
   412  		Convey("Catches panic in triage", func() {
   413  			markComponentsForTriage(1)
   414  			h.ComponentTriage = func(_ context.Context, _ *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   415  				panic(errors.New("oops"))
   416  			}
   417  			_, _, err := h.ExecDeferred(ctx, state)
   418  			So(err, ShouldErrLike, errCaughtPanic)
   419  			So(state.PB, ShouldResembleProto, pb)
   420  		})
   421  
   422  		Convey("With Run Creation", func() {
   423  			// Run creation requires ProjectStateOffload entity to exist.
   424  			So(datastore.Put(ctx, &prjmanager.ProjectStateOffload{
   425  				ConfigHash: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0].Hash(),
   426  				Project:    datastore.MakeKey(ctx, prjmanager.ProjectKind, lProject),
   427  				Status:     prjpb.Status_STARTED,
   428  			}), ShouldBeNil)
   429  
   430  			makeRunCreator := func(clid int64, fail bool) *runcreator.Creator {
   431  				cfgGroups, err := prjcfgtest.MustExist(ctx, lProject).GetConfigGroups(ctx)
   432  				if err != nil {
   433  					panic(err)
   434  				}
   435  				ci := gf.CI(int(clid), gf.PS(1), gf.Owner("user-1"), gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")))
   436  				cl := &changelist.CL{
   437  					ID:       common.CLID(clid),
   438  					EVersion: 1,
   439  					Snapshot: &changelist.Snapshot{Kind: &changelist.Snapshot_Gerrit{Gerrit: &changelist.Gerrit{
   440  						Host: "gerrit-review.example.com",
   441  						Info: ci,
   442  					}}},
   443  				}
   444  				if fail {
   445  					// Simulate EVersion mismatch to fail run creation.
   446  					cl.EVersion = 2
   447  				}
   448  				err = datastore.Put(ctx, cl)
   449  				if err != nil {
   450  					panic(err)
   451  				}
   452  				cl.EVersion = 1
   453  				return &runcreator.Creator{
   454  					LUCIProject:   lProject,
   455  					ConfigGroupID: cfgGroups[0].ID,
   456  					Mode:          run.DryRun,
   457  					OperationID:   fmt.Sprintf("op-%d-%t", clid, fail),
   458  					Owner:         identity.Identity("user:user-1@example.com"),
   459  					CreatedBy:     identity.Identity("user:user-2@example.com"),
   460  					BilledTo:      identity.Identity("user:user-2@example.com"),
   461  					Options:       &run.Options{},
   462  					InputCLs: []runcreator.CL{{
   463  						ID:               common.CLID(clid),
   464  						ExpectedEVersion: 1,
   465  						Snapshot:         cl.Snapshot,
   466  						TriggerInfo: trigger.Find(&trigger.FindInput{
   467  							ChangeInfo:  ci,
   468  							ConfigGroup: cfgGroups[0].Content,
   469  						}).GetCqVoteTrigger(),
   470  					}},
   471  				}
   472  			}
   473  
   474  			findRunOf := func(clid int) *run.Run {
   475  				switch runs, _, err := (runquery.CLQueryBuilder{CLID: common.CLID(clid)}).LoadRuns(ctx); {
   476  				case err != nil:
   477  					panic(err)
   478  				case len(runs) == 0:
   479  					return nil
   480  				case len(runs) > 1:
   481  					panic(fmt.Errorf("%d Runs for given CL", len(runs)))
   482  				default:
   483  					return runs[0]
   484  				}
   485  			}
   486  
   487  			Convey("100% success", func() {
   488  				markComponentsForTriage(1)
   489  				h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   490  					rc := makeRunCreator(1, false /* succeed */)
   491  					return itriager.Result{NewValue: markTriaged(c), RunsToCreate: []*runcreator.Creator{rc}}, nil
   492  				}
   493  
   494  				state2, sideEffect, err := h.ExecDeferred(ctx, state)
   495  				So(err, ShouldBeNil)
   496  				So(sideEffect, ShouldBeNil)
   497  				pb.Components[1].TriageRequired = false // must be saved, since Run Creation succeeded.
   498  				So(state2.PB, ShouldResembleProto, pb)
   499  				So(findRunOf(1), ShouldNotBeNil)
   500  			})
   501  
   502  			Convey("100% failure", func() {
   503  				markComponentsForTriage(1)
   504  				h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   505  					rc := makeRunCreator(1, true /* fail */)
   506  					return itriager.Result{NewValue: markTriaged(c), RunsToCreate: []*runcreator.Creator{rc}}, nil
   507  				}
   508  
   509  				_, sideEffect, err := h.ExecDeferred(ctx, state)
   510  				So(err, ShouldErrLike, "failed to actOnComponents")
   511  				So(sideEffect, ShouldBeNil)
   512  				So(findRunOf(1), ShouldBeNil)
   513  			})
   514  
   515  			Convey("Partial failure", func() {
   516  				markComponentsForTriage(1, 2, 3)
   517  				h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   518  					clid := c.GetClids()[0]
   519  					// Set up each component trying to create a Run,
   520  					// and #2 and #3 additionally purging a CL,
   521  					// but #2 failing to create a Run.
   522  					failIf := clid == 2
   523  					rc := makeRunCreator(clid, failIf)
   524  					res := itriager.Result{NewValue: markTriaged(c), RunsToCreate: []*runcreator.Creator{rc}}
   525  					if clid != 1 {
   526  						// Contrived example, since in practice purging a CL concurrently
   527  						// with Run creation in the same component ought to happen only iff
   528  						// there are several CLs and presumably on different CLs.
   529  						res.CLsToPurge = []*prjpb.PurgeCLTask{
   530  							{
   531  								PurgingCl: &prjpb.PurgingCL{
   532  									Clid:    clid,
   533  									ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
   534  								},
   535  								PurgeReasons: []*prjpb.PurgeReason{{
   536  									ClError: &changelist.CLError{
   537  										Kind: &changelist.CLError_OwnerLacksEmail{OwnerLacksEmail: true},
   538  									},
   539  									ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true},
   540  								}},
   541  							},
   542  						}
   543  					}
   544  					return res, nil
   545  				}
   546  
   547  				state2, sideEffects, err := h.ExecDeferred(ctx, state)
   548  				So(err, ShouldBeNil)
   549  				// Only #3 component purge must be a SideEffect.
   550  				sideEffect := sideEffects.(*SideEffects).items[0]
   551  				So(sideEffect, ShouldHaveSameTypeAs, &TriggerPurgeCLTasks{})
   552  				ps := sideEffect.(*TriggerPurgeCLTasks).payloads
   553  				So(ps, ShouldHaveLength, 1)
   554  				So(ps[0].GetPurgingCl().GetClid(), ShouldEqual, 3)
   555  
   556  				So(findRunOf(1), ShouldNotBeNil)
   557  				pb.Components[1].TriageRequired = false
   558  				// Component #2 must remain unchanged.
   559  				So(findRunOf(3), ShouldNotBeNil)
   560  				pb.Components[3].TriageRequired = false
   561  				pb.PurgingCls = []*prjpb.PurgingCL{
   562  					{
   563  						Clid: 3, OperationId: "1580640000-3",
   564  						Deadline: timestamppb.New(ct.Clock.Now().Add(maxPurgingCLDuration)),
   565  						ApplyTo:  &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
   566  					},
   567  				}
   568  				pb.NextEvalTime = timestamppb.New(ct.Clock.Now()) // re-triage ASAP.
   569  				So(state2.PB, ShouldResembleProto, pb)
   570  			})
   571  
   572  			Convey("Catches panic", func() {
   573  				markComponentsForTriage(1)
   574  				h.ComponentTriage = func(_ context.Context, c *prjpb.Component, _ itriager.PMState) (itriager.Result, error) {
   575  					rc := makeRunCreator(1, false)
   576  					rc.LUCIProject = "" // causes panic because of incorrect usage.
   577  					return itriager.Result{NewValue: markTriaged(c), RunsToCreate: []*runcreator.Creator{rc}}, nil
   578  				}
   579  
   580  				_, _, err := h.ExecDeferred(ctx, state)
   581  				So(err, ShouldErrLike, errCaughtPanic)
   582  				So(state.PB, ShouldResembleProto, pb)
   583  			})
   584  		})
   585  	})
   586  }