go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/impl/handler/start_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 handler
    16  
    17  import (
    18  	"fmt"
    19  	"testing"
    20  	"time"
    21  
    22  	"google.golang.org/protobuf/types/known/durationpb"
    23  	"google.golang.org/protobuf/types/known/timestamppb"
    24  
    25  	"go.chromium.org/luci/auth/identity"
    26  	bbpb "go.chromium.org/luci/buildbucket/proto"
    27  	bbutil "go.chromium.org/luci/buildbucket/protoutil"
    28  	"go.chromium.org/luci/common/clock"
    29  	. "go.chromium.org/luci/common/testing/assertions"
    30  	"go.chromium.org/luci/gae/service/datastore"
    31  	"go.chromium.org/luci/hardcoded/chromeinfra"
    32  	"go.chromium.org/luci/server/quota"
    33  	"go.chromium.org/luci/server/quota/quotapb"
    34  
    35  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    36  	"go.chromium.org/luci/cv/internal/changelist"
    37  	"go.chromium.org/luci/cv/internal/common"
    38  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    39  	"go.chromium.org/luci/cv/internal/cvtesting"
    40  	"go.chromium.org/luci/cv/internal/gerrit"
    41  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    42  	"go.chromium.org/luci/cv/internal/metrics"
    43  	"go.chromium.org/luci/cv/internal/run"
    44  	"go.chromium.org/luci/cv/internal/run/eventpb"
    45  	"go.chromium.org/luci/cv/internal/run/impl/state"
    46  	"go.chromium.org/luci/cv/internal/run/runtest"
    47  	"go.chromium.org/luci/cv/internal/tryjob"
    48  
    49  	. "github.com/smartystreets/goconvey/convey"
    50  )
    51  
    52  func TestStart(t *testing.T) {
    53  	t.Parallel()
    54  
    55  	Convey("StartRun", t, func() {
    56  		ct := cvtesting.Test{}
    57  		ctx, cancel := ct.SetUp(t)
    58  		defer cancel()
    59  
    60  		const (
    61  			lProject           = "chromium"
    62  			configGroupName    = "combinable"
    63  			gerritHost         = "chromium-review.googlesource.com"
    64  			committers         = "committer-group"
    65  			dryRunners         = "dry-runner-group"
    66  			stabilizationDelay = time.Minute
    67  			startLatency       = 2 * time.Minute
    68  		)
    69  
    70  		builder := &bbpb.BuilderID{
    71  			Project: lProject,
    72  			Bucket:  "try",
    73  			Builder: "cool_tester",
    74  		}
    75  		prjcfgtest.Create(ctx, lProject, &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{
    76  			Name: configGroupName,
    77  			CombineCls: &cfgpb.CombineCLs{
    78  				StabilizationDelay: durationpb.New(stabilizationDelay),
    79  			},
    80  			Verifiers: &cfgpb.Verifiers{
    81  				GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{
    82  					CommitterList:    []string{committers},
    83  					DryRunAccessList: []string{dryRunners},
    84  				},
    85  				Tryjob: &cfgpb.Verifiers_Tryjob{
    86  					Builders: []*cfgpb.Verifiers_Tryjob_Builder{
    87  						{
    88  							Name: bbutil.FormatBuilderID(builder),
    89  						},
    90  					},
    91  				},
    92  			},
    93  		}}})
    94  
    95  		makeIdentity := func(email string) identity.Identity {
    96  			id, err := identity.MakeIdentity(fmt.Sprintf("%s:%s", identity.User, email))
    97  			So(err, ShouldBeNil)
    98  			return id
    99  		}
   100  
   101  		const tEmail = "t@example.org"
   102  		rs := &state.RunState{
   103  			Run: run.Run{
   104  				ID:            lProject + "/1111111111111-deadbeef",
   105  				Status:        run.Status_PENDING,
   106  				CreateTime:    clock.Now(ctx).UTC().Add(-startLatency),
   107  				ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0],
   108  				Mode:          run.DryRun,
   109  				CreatedBy:     makeIdentity(tEmail),
   110  				BilledTo:      makeIdentity(tEmail),
   111  			},
   112  		}
   113  		h, deps := makeTestHandler(&ct)
   114  
   115  		var clid common.CLID
   116  		var accountID int64
   117  		addCL := func(triggerer, owner string) *changelist.CL {
   118  			clid++
   119  			accountID++
   120  			rs.CLs = append(rs.CLs, clid)
   121  			ci := gf.CI(100+int(clid),
   122  				gf.Owner(owner),
   123  				gf.CQ(+1, rs.CreateTime, gf.U(triggerer)))
   124  			cl := &changelist.CL{
   125  				ID:         clid,
   126  				ExternalID: changelist.MustGobID(gerritHost, ci.GetNumber()),
   127  				Snapshot: &changelist.Snapshot{
   128  					Kind: &changelist.Snapshot_Gerrit{
   129  						Gerrit: &changelist.Gerrit{
   130  							Host: gerritHost,
   131  							Info: ci,
   132  						},
   133  					},
   134  				},
   135  			}
   136  			rCL := &run.RunCL{
   137  				ID:         clid,
   138  				Run:        datastore.MakeKey(ctx, common.RunKind, string(rs.ID)),
   139  				ExternalID: changelist.MustGobID(gerritHost, ci.GetNumber()),
   140  				Trigger: &run.Trigger{
   141  					Email:           gf.U(triggerer).Email,
   142  					Time:            timestamppb.New(rs.CreateTime),
   143  					Mode:            string(rs.Mode),
   144  					GerritAccountId: accountID,
   145  				},
   146  			}
   147  			So(datastore.Put(ctx, cl, rCL), ShouldBeNil)
   148  			return cl
   149  		}
   150  
   151  		const (
   152  			owner     = "user-1"
   153  			triggerer = owner
   154  		)
   155  		cl := addCL(triggerer, owner)
   156  		ct.AddMember(owner, dryRunners)
   157  		ct.AddMember(owner, committers)
   158  
   159  		Convey("Starts when Run is PENDING", func() {
   160  			deps.qm.runQuotaOp = &quotapb.OpResult{
   161  				Status:          quotapb.OpResult_SUCCESS,
   162  				NewBalance:      5,
   163  				PreviousBalance: 4,
   164  			}
   165  
   166  			res, err := h.Start(ctx, rs)
   167  			So(err, ShouldBeNil)
   168  			So(res.PreserveEvents, ShouldBeFalse)
   169  			So(deps.qm.debitRunQuotaCalls, ShouldEqual, 1)
   170  
   171  			So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   172  			So(res.State.StartTime, ShouldEqual, ct.Clock.Now().UTC())
   173  			So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   174  				Requirement: &tryjob.Requirement{
   175  					Definitions: []*tryjob.Definition{
   176  						{
   177  							Backend: &tryjob.Definition_Buildbucket_{
   178  								Buildbucket: &tryjob.Definition_Buildbucket{
   179  									Host:    chromeinfra.BuildbucketHost,
   180  									Builder: builder,
   181  								},
   182  							},
   183  							Critical: true,
   184  						},
   185  					},
   186  				},
   187  				RequirementVersion:    1,
   188  				RequirementComputedAt: timestamppb.New(ct.Clock.Now().UTC()),
   189  			})
   190  			So(res.State.LogEntries, ShouldHaveLength, 2)
   191  			So(res.State.LogEntries[0].GetInfo().GetMessage(), ShouldEqual, "Run quota debited from t@example.org; balance: 5")
   192  			So(res.State.LogEntries[1].GetStarted(), ShouldNotBeNil)
   193  
   194  			So(res.State.NewLongOpIDs, ShouldHaveLength, 2)
   195  			So(res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]].GetExecuteTryjobs(), ShouldNotBeNil)
   196  			So(res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[1]].GetPostStartMessage(), ShouldBeTrue)
   197  
   198  			So(res.SideEffectFn, ShouldNotBeNil)
   199  			So(datastore.RunInTransaction(ctx, res.SideEffectFn, nil), ShouldBeNil)
   200  			So(ct.TSMonSentValue(ctx, metrics.Public.RunStarted, lProject, configGroupName, string(run.DryRun)), ShouldEqual, 1)
   201  			So(ct.TSMonSentDistr(ctx, metricPickupLatencyS, lProject).Sum(),
   202  				ShouldAlmostEqual, startLatency.Seconds())
   203  			So(ct.TSMonSentDistr(ctx, metricPickupLatencyAdjustedS, lProject).Sum(),
   204  				ShouldAlmostEqual, (startLatency - stabilizationDelay).Seconds())
   205  		})
   206  
   207  		Convey("Does not proceed if run quota is not available", func() {
   208  			deps.qm.runQuotaErr = quota.ErrQuotaApply
   209  			deps.qm.runQuotaOp = &quotapb.OpResult{
   210  				Status: quotapb.OpResult_ERR_UNDERFLOW,
   211  			}
   212  			deps.qm.userLimit = &cfgpb.UserLimit{
   213  				Run: &cfgpb.UserLimit_Run{
   214  					ReachLimitMsg: "foo bar.",
   215  				},
   216  			}
   217  
   218  			res, err := h.Start(ctx, rs)
   219  			So(err, ShouldBeNil)
   220  			So(res.PreserveEvents, ShouldBeTrue)
   221  			So(res.SideEffectFn, ShouldBeNil)
   222  			So(res.State.Status, ShouldEqual, run.Status_PENDING)
   223  			ops := res.State.OngoingLongOps.GetOps()
   224  			So(len(ops), ShouldEqual, 1)
   225  			So(ops, ShouldContainKey, "1-1")
   226  			So(ops["1-1"].GetPostGerritMessage(), ShouldResembleProto, &run.OngoingLongOps_Op_PostGerritMessage{
   227  				Message: fmt.Sprintf("User %s has exhausted their run quota. This run will start once the quota balance has recovered.\n\nfoo bar.", rs.Run.BilledTo.Email()),
   228  			})
   229  			So(ct.TSMonSentValue(
   230  				ctx,
   231  				metrics.Public.RunQuotaRejection,
   232  				lProject,
   233  				"combinable",
   234  				"chromium/1",
   235  			), ShouldEqual, 1)
   236  
   237  			Convey("Enqueue pending message only once when quota is exhausted", func() {
   238  				res, err := h.Start(ctx, rs)
   239  				So(err, ShouldBeNil)
   240  				ops := res.State.OngoingLongOps.GetOps()
   241  				So(ops, ShouldContainKey, "1-1")
   242  				So(ops["1-1"].GetPostGerritMessage(), ShouldResembleProto, &run.OngoingLongOps_Op_PostGerritMessage{
   243  					Message: fmt.Sprintf("User %s has exhausted their run quota. This run will start once the quota balance has recovered.\n\nfoo bar.", rs.Run.BilledTo.Email()),
   244  				})
   245  			})
   246  		})
   247  
   248  		Convey("Throws error when quota manager fails with an unexpected error", func() {
   249  			deps.qm.runQuotaErr = quota.ErrQuotaApply
   250  			deps.qm.runQuotaOp = &quotapb.OpResult{
   251  				Status: quotapb.OpResult_ERR_UNKNOWN,
   252  			}
   253  
   254  			res, err := h.Start(ctx, rs)
   255  			So(res, ShouldBeNil)
   256  			So(err, ShouldErrLike, "QM.DebitRunQuota: unexpected quotaOp Status ERR_UNKNOWN")
   257  		})
   258  
   259  		Convey("Does not proceed if any parent RUN is still PENDING", func() {
   260  			const parentRun = common.RunID("parent/1-cow")
   261  			So(datastore.Put(ctx,
   262  				&run.Run{
   263  					ID:     parentRun,
   264  					Status: run.Status_PENDING,
   265  				},
   266  			), ShouldBeNil)
   267  			rs.DepRuns = common.RunIDs{parentRun}
   268  			res, err := h.Start(ctx, rs)
   269  			So(err, ShouldBeNil)
   270  			So(res.SideEffectFn, ShouldBeNil)
   271  			So(res.State.Status, ShouldEqual, run.Status_PENDING)
   272  			ops := res.State.OngoingLongOps.GetOps()
   273  			So(len(ops), ShouldEqual, 0)
   274  		})
   275  
   276  		Convey("Does not proceed if parent RUN is CANCELLED/FAILED", func() {
   277  			const parentRun = common.RunID("parent/1-cow")
   278  			So(datastore.Put(ctx,
   279  				&run.Run{
   280  					ID:     parentRun,
   281  					Status: run.Status_CANCELLED,
   282  				},
   283  			), ShouldBeNil)
   284  			rs.DepRuns = common.RunIDs{parentRun}
   285  			res, err := h.Start(ctx, rs)
   286  			So(err, ShouldBeNil)
   287  			So(res.SideEffectFn, ShouldBeNil)
   288  			So(res.State.Status, ShouldEqual, run.Status_PENDING)
   289  		})
   290  
   291  		Convey("Emits Start events for PENDING children", func() {
   292  			const child1 = common.RunID("child/1-cow")
   293  			So(datastore.Put(
   294  				ctx,
   295  				&run.Run{
   296  					ID:      child1,
   297  					Status:  run.Status_PENDING,
   298  					DepRuns: common.RunIDs{rs.ID},
   299  				},
   300  			), ShouldBeNil)
   301  			res, err := h.Start(ctx, rs)
   302  			So(err, ShouldBeNil)
   303  			So(res.SideEffectFn, ShouldNotBeNil)
   304  			So(datastore.RunInTransaction(ctx, res.SideEffectFn, nil), ShouldBeNil)
   305  			runtest.AssertReceivedStart(ctx, child1)
   306  		})
   307  
   308  		Convey("Fail the Run if tryjob computation fails", func() {
   309  			if rs.Options == nil {
   310  				rs.Options = &run.Options{}
   311  			}
   312  			// included a builder that doesn't exist
   313  			rs.Options.IncludedTryjobs = append(rs.Options.IncludedTryjobs, "fooproj/ci:bar_builder")
   314  			anotherCL := addCL(triggerer, owner)
   315  			rs.CLs = common.CLIDs{cl.ID, anotherCL.ID}
   316  			Convey("Reset triggers on all CLs", func() {
   317  				res, err := h.Start(ctx, rs)
   318  				So(err, ShouldBeNil)
   319  				So(res.SideEffectFn, ShouldBeNil)
   320  				So(res.PreserveEvents, ShouldBeFalse)
   321  				So(res.State.Status, ShouldEqual, run.Status_PENDING)
   322  				So(res.State.Tryjobs, ShouldBeNil)
   323  				So(res.State.NewLongOpIDs, ShouldHaveLength, 1)
   324  				op := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]]
   325  				So(op.GetResetTriggers(), ShouldNotBeNil)
   326  				So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   327  				resetCLs := common.CLIDs{}
   328  				for _, req := range op.GetResetTriggers().GetRequests() {
   329  					resetCLs = append(resetCLs, common.CLID(req.Clid))
   330  				}
   331  				So(resetCLs, ShouldResemble, res.State.CLs)
   332  				So(res.State.LogEntries, ShouldHaveLength, 1)
   333  				So(res.State.LogEntries[0].GetInfo(), ShouldResembleProto, &run.LogEntry_Info{
   334  					Label:   "Tryjob Requirement Computation",
   335  					Message: "Failed to compute tryjob requirement. Reason: builder \"fooproj/ci/bar_builder\" is included but not defined in the LUCI project",
   336  				})
   337  			})
   338  			Convey("Only reset trigger on root CL", func() {
   339  				rs.RootCL = cl.ID
   340  				res, err := h.Start(ctx, rs)
   341  				So(err, ShouldBeNil)
   342  				So(res.SideEffectFn, ShouldBeNil)
   343  				So(res.State.NewLongOpIDs, ShouldHaveLength, 1)
   344  				op := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]]
   345  				So(op.GetResetTriggers(), ShouldNotBeNil)
   346  				So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   347  				resetCLs := common.CLIDs{}
   348  				for _, req := range op.GetResetTriggers().GetRequests() {
   349  					resetCLs = append(resetCLs, common.CLID(req.Clid))
   350  				}
   351  				So(resetCLs, ShouldResemble, common.CLIDs{rs.RootCL})
   352  			})
   353  		})
   354  
   355  		Convey("Fail the Run if acls.CheckRunCreate fails", func() {
   356  			ct.ResetMockedAuthDB(ctx)
   357  			res, err := h.Start(ctx, rs)
   358  			So(err, ShouldBeNil)
   359  			So(res.SideEffectFn, ShouldBeNil)
   360  			So(res.PreserveEvents, ShouldBeFalse)
   361  
   362  			So(res.State.Status, ShouldEqual, run.Status_PENDING)
   363  			So(res.State.LogEntries, ShouldHaveLength, 1)
   364  			So(res.State.LogEntries[0].GetInfo(), ShouldResembleProto, &run.LogEntry_Info{
   365  				Label: "Run failed",
   366  				Message: "" +
   367  					"the Run does not pass eligibility checks. See reasons at:" +
   368  					"\n  * " + cl.ExternalID.MustURL(),
   369  			})
   370  
   371  			So(res.State.NewLongOpIDs, ShouldHaveLength, 1)
   372  			longOp := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]]
   373  			resetOp := longOp.GetResetTriggers()
   374  			So(resetOp.Requests, ShouldHaveLength, 1)
   375  			So(resetOp.Requests[0], ShouldResembleProto,
   376  				&run.OngoingLongOps_Op_ResetTriggers_Request{
   377  					Clid: int64(cl.ID),
   378  					Message: fmt.Sprintf(
   379  						"CV cannot start a Run for `%s` because the user is not a dry-runner.", gf.U(triggerer).Email,
   380  					),
   381  					Notify: gerrit.Whoms{
   382  						gerrit.Whom_OWNER,
   383  						gerrit.Whom_CQ_VOTERS,
   384  					},
   385  					AddToAttention: gerrit.Whoms{
   386  						gerrit.Whom_OWNER,
   387  						gerrit.Whom_CQ_VOTERS,
   388  					},
   389  					AddToAttentionReason: "CQ/CV Run failed",
   390  				},
   391  			)
   392  			So(resetOp.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED)
   393  
   394  			Convey("Only reset trigger on root CL", func() {
   395  				anotherCL := addCL(triggerer, owner)
   396  				rs.CLs = common.CLIDs{cl.ID, anotherCL.ID}
   397  				rs.RootCL = cl.ID
   398  				res, err := h.Start(ctx, rs)
   399  				So(err, ShouldBeNil)
   400  				So(res.SideEffectFn, ShouldBeNil)
   401  				So(res.State.NewLongOpIDs, ShouldHaveLength, 1)
   402  				op := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]]
   403  				So(op.GetResetTriggers(), ShouldNotBeNil)
   404  				So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   405  				resetCLs := common.CLIDs{}
   406  				for _, req := range op.GetResetTriggers().GetRequests() {
   407  					resetCLs = append(resetCLs, common.CLID(req.Clid))
   408  				}
   409  				So(resetCLs, ShouldResemble, common.CLIDs{rs.RootCL})
   410  			})
   411  		})
   412  
   413  		statuses := []run.Status{
   414  			run.Status_RUNNING,
   415  			run.Status_WAITING_FOR_SUBMISSION,
   416  			run.Status_SUBMITTING,
   417  			run.Status_SUCCEEDED,
   418  			run.Status_FAILED,
   419  			run.Status_CANCELLED,
   420  		}
   421  		for _, status := range statuses {
   422  			Convey(fmt.Sprintf("Noop when Run is %s", status), func() {
   423  				rs.Status = status
   424  				res, err := h.Start(ctx, rs)
   425  				So(err, ShouldBeNil)
   426  				So(res.State, ShouldEqual, rs)
   427  				So(res.SideEffectFn, ShouldBeNil)
   428  				So(res.PreserveEvents, ShouldBeFalse)
   429  			})
   430  		}
   431  	})
   432  }
   433  
   434  func TestOnCompletedPostStartMessage(t *testing.T) {
   435  	t.Parallel()
   436  
   437  	Convey("onCompletedPostStartMessage works", t, func() {
   438  		ct := cvtesting.Test{}
   439  		ctx, cancel := ct.SetUp(t)
   440  		defer cancel()
   441  
   442  		const (
   443  			lProject = "chromium"
   444  			opID     = "1-1"
   445  		)
   446  
   447  		prjcfgtest.Create(ctx, lProject, &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{Name: "single"}}})
   448  
   449  		rs := &state.RunState{
   450  			Run: run.Run{
   451  				ID:            lProject + "/1111111111111-1-deadbeef",
   452  				Status:        run.Status_RUNNING,
   453  				Mode:          run.DryRun,
   454  				ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0],
   455  				OngoingLongOps: &run.OngoingLongOps{
   456  					Ops: map[string]*run.OngoingLongOps_Op{
   457  						opID: {
   458  							Work: &run.OngoingLongOps_Op_PostStartMessage{
   459  								PostStartMessage: true,
   460  							},
   461  						},
   462  					},
   463  				},
   464  			},
   465  		}
   466  		result := &eventpb.LongOpCompleted{
   467  			OperationId: opID,
   468  		}
   469  		h, _ := makeTestHandler(&ct)
   470  
   471  		Convey("if Run isn't RUNNING, just cleans up the operation", func() {
   472  			// NOTE: This should be rare. And since posting the starting message isn't
   473  			// a critical operation, it's OK to ignore its failures if the Run is
   474  			// already submitting the CL.
   475  			rs.Run.Status = run.Status_SUBMITTING
   476  			result.Status = eventpb.LongOpCompleted_FAILED
   477  			res, err := h.OnLongOpCompleted(ctx, rs, result)
   478  			So(err, ShouldBeNil)
   479  			So(res.State.Status, ShouldEqual, run.Status_SUBMITTING)
   480  			So(res.State.OngoingLongOps, ShouldBeNil)
   481  			So(res.SideEffectFn, ShouldBeNil)
   482  			So(res.PreserveEvents, ShouldBeFalse)
   483  		})
   484  
   485  		Convey("on cancellation, cleans up Run's state", func() {
   486  			// NOTE: as of this writing (Oct 2021), the only time posting start
   487  			// message is cancelled is if the Run was already finalized. Therefore,
   488  			// Run can't be in RUNNING state any more.
   489  			// However, this test aims to cover possible future logic change in CV.
   490  			result.Status = eventpb.LongOpCompleted_CANCELLED
   491  			res, err := h.OnLongOpCompleted(ctx, rs, result)
   492  			So(err, ShouldBeNil)
   493  			So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   494  			So(res.State.OngoingLongOps, ShouldBeNil)
   495  			So(res.SideEffectFn, ShouldBeNil)
   496  			So(res.PreserveEvents, ShouldBeFalse)
   497  		})
   498  
   499  		Convey("on success, cleans Run's state", func() {
   500  			result.Status = eventpb.LongOpCompleted_SUCCEEDED
   501  			postedAt := ct.Clock.Now().Add(-time.Second)
   502  			result.Result = &eventpb.LongOpCompleted_PostStartMessage_{
   503  				PostStartMessage: &eventpb.LongOpCompleted_PostStartMessage{
   504  					Time: timestamppb.New(postedAt),
   505  				},
   506  			}
   507  			res, err := h.OnLongOpCompleted(ctx, rs, result)
   508  			So(err, ShouldBeNil)
   509  			So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   510  			So(res.State.OngoingLongOps, ShouldBeNil)
   511  			So(res.SideEffectFn, ShouldBeNil)
   512  			So(res.PreserveEvents, ShouldBeFalse)
   513  			So(res.State.LogEntries[0].GetTime().AsTime(), ShouldResemble, postedAt.UTC())
   514  		})
   515  
   516  		Convey("on failure, cleans Run's state and record reasons", func() {
   517  			result.Status = eventpb.LongOpCompleted_FAILED
   518  			res, err := h.OnLongOpCompleted(ctx, rs, result)
   519  			So(err, ShouldBeNil)
   520  			So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   521  			So(res.State.OngoingLongOps, ShouldBeNil)
   522  			So(res.SideEffectFn, ShouldBeNil)
   523  			So(res.PreserveEvents, ShouldBeFalse)
   524  			So(res.State.LogEntries[0].GetInfo().GetMessage(), ShouldContainSubstring, "Failed to post the starting message")
   525  		})
   526  
   527  		Convey("on expiration,cleans Run's state and record reasons", func() {
   528  			result.Status = eventpb.LongOpCompleted_EXPIRED
   529  			res, err := h.OnLongOpCompleted(ctx, rs, result)
   530  			So(err, ShouldBeNil)
   531  			So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   532  			So(res.State.OngoingLongOps, ShouldBeNil)
   533  			So(res.SideEffectFn, ShouldBeNil)
   534  			So(res.PreserveEvents, ShouldBeFalse)
   535  			So(res.State.LogEntries[0].GetInfo().GetMessage(), ShouldContainSubstring, "Failed to post the starting message")
   536  		})
   537  	})
   538  }