go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/changelist/mutator_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 changelist
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"math/rand"
    21  	"sync"
    22  	"testing"
    23  	"time"
    24  
    25  	"golang.org/x/sync/errgroup"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/common/retry/transient"
    30  	"go.chromium.org/luci/gae/filter/featureBreaker"
    31  	"go.chromium.org/luci/gae/filter/featureBreaker/flaky"
    32  	"go.chromium.org/luci/gae/service/datastore"
    33  	"go.chromium.org/luci/server/tq/tqtesting"
    34  
    35  	"go.chromium.org/luci/cv/internal/common"
    36  	"go.chromium.org/luci/cv/internal/cvtesting"
    37  
    38  	. "github.com/smartystreets/goconvey/convey"
    39  
    40  	. "go.chromium.org/luci/common/testing/assertions"
    41  )
    42  
    43  func TestMutatorSingleCL(t *testing.T) {
    44  	t.Parallel()
    45  
    46  	Convey("Mutator works on a single CL", t, func() {
    47  		ct := cvtesting.Test{}
    48  		ctx, cancel := ct.SetUp(t)
    49  		defer cancel()
    50  
    51  		const lProject = "infra"
    52  		const run1 = lProject + "/1"
    53  		const run2 = lProject + "/2"
    54  		const gHost = "x-review.example.com"
    55  		const gChange = 44
    56  		eid := MustGobID(gHost, gChange)
    57  
    58  		pm := pmMock{}
    59  		rm := rmMock{}
    60  		tj := tjMock{}
    61  		m := NewMutator(ct.TQDispatcher, &pm, &rm, &tj)
    62  
    63  		execBatchOnCLUpdatedTask := func() {
    64  			So(ct.TQ.Tasks(), ShouldHaveLength, 1)
    65  			So(ct.TQ.Tasks()[0].Class, ShouldResemble, BatchOnCLUpdatedTaskClass)
    66  			ct.TQ.Run(ctx, tqtesting.StopAfterTask(BatchOnCLUpdatedTaskClass))
    67  		}
    68  		expectNoNotifications := func() {
    69  			So(ct.TQ.Tasks(), ShouldHaveLength, 0)
    70  			So(pm.byProject, ShouldBeEmpty)
    71  			So(rm.byRun, ShouldBeEmpty)
    72  			So(tj.clsNotified, ShouldBeEmpty)
    73  		}
    74  
    75  		Convey("Upsert method", func() {
    76  			Convey("creates", func() {
    77  				s := makeSnapshot(lProject, ct.Clock.Now())
    78  				cl, err := m.Upsert(ctx, lProject, eid, func(cl *CL) error {
    79  					cl.Snapshot = s
    80  					return nil
    81  				})
    82  				So(err, ShouldBeNil)
    83  				So(cl.ExternalID, ShouldResemble, eid)
    84  				So(cl.EVersion, ShouldEqual, 1)
    85  				So(cl.UpdateTime, ShouldEqual, ct.Clock.Now())
    86  				So(cl.RetentionKey, ShouldNotBeEmpty)
    87  				So(cl.Snapshot, ShouldResembleProto, s)
    88  
    89  				execBatchOnCLUpdatedTask()
    90  				So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
    91  					lProject: {cl.ID: cl.EVersion},
    92  				})
    93  				So(rm.byRun, ShouldBeEmpty)
    94  				So(tj.clsNotified, ShouldBeEmpty)
    95  			})
    96  
    97  			Convey("skips creation", func() {
    98  				// This is a special case which isn't supposed to be needed,
    99  				// but it's kept here for completeness.
   100  				cl, err := m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   101  					return ErrStopMutation
   102  				})
   103  				So(err, ShouldBeNil)
   104  				So(cl, ShouldBeNil)
   105  				expectNoNotifications()
   106  			})
   107  
   108  			Convey("updates", func() {
   109  				s1 := makeSnapshot(lProject, ct.Clock.Now())
   110  				cl := eid.MustCreateIfNotExists(ctx)
   111  				cl.Snapshot = s1
   112  				cl.IncompleteRuns = common.MakeRunIDs(run1)
   113  				So(datastore.Put(ctx, cl), ShouldBeNil)
   114  
   115  				ct.Clock.Add(time.Second)
   116  				s2 := makeSnapshot(lProject, ct.Clock.Now())
   117  				s2.MinEquivalentPatchset++
   118  				var priorSnapshot *Snapshot
   119  				cl, err := m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   120  					if priorSnapshot == nil {
   121  						priorSnapshot = cl.Snapshot
   122  					}
   123  					cl.Snapshot = s2
   124  					cl.IncompleteRuns.InsertSorted(run2) // idempotent
   125  					return nil
   126  				})
   127  				So(err, ShouldBeNil)
   128  
   129  				So(priorSnapshot, ShouldResembleProto, s1)
   130  				So(cl.ExternalID, ShouldResemble, eid)
   131  				So(cl.EVersion, ShouldEqual, 2)
   132  				So(cl.UpdateTime, ShouldEqual, ct.Clock.Now().UTC())
   133  				So(cl.RetentionKey, ShouldNotBeEmpty)
   134  				So(cl.Snapshot, ShouldResembleProto, s2)
   135  				So(tj.clsNotified[0], ShouldEqual, cl.ID)
   136  
   137  				execBatchOnCLUpdatedTask()
   138  				So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   139  					lProject: {cl.ID: cl.EVersion},
   140  				})
   141  				So(rm.byRun, ShouldResemble, map[common.RunID]map[common.CLID]int64{
   142  					run1: {cl.ID: cl.EVersion},
   143  					run2: {cl.ID: cl.EVersion},
   144  				})
   145  			})
   146  
   147  			Convey("skips an update", func() {
   148  				priorCL := eid.MustCreateIfNotExists(ctx)
   149  
   150  				ct.Clock.Add(time.Second)
   151  				cl, err := m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   152  					return ErrStopMutation
   153  				})
   154  				So(err, ShouldBeNil)
   155  
   156  				So(cl.ExternalID, ShouldResemble, eid)
   157  				So(cl.EVersion, ShouldEqual, priorCL.EVersion)
   158  				So(cl.UpdateTime, ShouldEqual, priorCL.UpdateTime)
   159  				So(cl.RetentionKey, ShouldEqual, priorCL.RetentionKey)
   160  
   161  				So(pm.byProject, ShouldBeEmpty)
   162  				So(rm.byRun, ShouldBeEmpty)
   163  				So(tj.clsNotified, ShouldBeEmpty)
   164  			})
   165  
   166  			Convey("propagates error without wrapping", func() {
   167  				myErr := errors.New("my error")
   168  				_, err := m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   169  					return myErr
   170  				})
   171  				So(myErr, ShouldEqual, err)
   172  			})
   173  		})
   174  
   175  		Convey("Update method", func() {
   176  			Convey("updates", func() {
   177  				s1 := makeSnapshot("prior-project", ct.Clock.Now())
   178  				priorCL := eid.MustCreateIfNotExists(ctx)
   179  				priorCL.Snapshot = s1
   180  				So(datastore.Put(ctx, priorCL), ShouldBeNil)
   181  
   182  				ct.Clock.Add(time.Second)
   183  				s2 := makeSnapshot(lProject, ct.Clock.Now())
   184  				s2.MinEquivalentPatchset++
   185  				cl, err := m.Update(ctx, lProject, priorCL.ID, func(cl *CL) error {
   186  					cl.Snapshot = s2
   187  					return nil
   188  				})
   189  				So(err, ShouldBeNil)
   190  
   191  				So(cl.ID, ShouldResemble, priorCL.ID)
   192  				So(cl.ExternalID, ShouldResemble, eid)
   193  				So(cl.EVersion, ShouldEqual, 2)
   194  				So(cl.UpdateTime, ShouldEqual, ct.Clock.Now().UTC())
   195  				So(cl.RetentionKey, ShouldNotBeEmpty)
   196  				So(cl.Snapshot, ShouldResembleProto, s2)
   197  
   198  				execBatchOnCLUpdatedTask()
   199  				So(pm.byProject[lProject][cl.ID], ShouldEqual, cl.EVersion)
   200  				So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   201  					"prior-project": {cl.ID: cl.EVersion},
   202  					lProject:        {cl.ID: cl.EVersion},
   203  				})
   204  				So(rm.byRun, ShouldBeEmpty)
   205  				So(tj.clsNotified[0], ShouldEqual, cl.ID)
   206  			})
   207  
   208  			Convey("skips an actual update", func() {
   209  				priorCL := eid.MustCreateIfNotExists(ctx)
   210  
   211  				ct.Clock.Add(time.Second)
   212  				cl, err := m.Update(ctx, lProject, priorCL.ID, func(cl *CL) error {
   213  					return ErrStopMutation
   214  				})
   215  				So(err, ShouldBeNil)
   216  
   217  				So(cl.ID, ShouldResemble, priorCL.ID)
   218  				So(cl.ExternalID, ShouldResemble, eid)
   219  				So(cl.EVersion, ShouldEqual, priorCL.EVersion)
   220  				So(cl.UpdateTime, ShouldEqual, priorCL.UpdateTime)
   221  				So(cl.RetentionKey, ShouldEqual, priorCL.RetentionKey)
   222  
   223  				expectNoNotifications()
   224  			})
   225  
   226  			Convey("propagates error without wrapping", func() {
   227  				priorCL := eid.MustCreateIfNotExists(ctx)
   228  
   229  				myErr := errors.New("my error")
   230  				_, err := m.Update(ctx, lProject, priorCL.ID, func(cl *CL) error {
   231  					return myErr
   232  				})
   233  				So(myErr, ShouldEqual, err)
   234  			})
   235  
   236  			Convey("errors out non-transiently if CL doesn't exist", func() {
   237  				_, err := m.Update(ctx, lProject, 123, func(cl *CL) error {
   238  					panic("must not be called")
   239  				})
   240  				So(errors.Unwrap(err), ShouldEqual, datastore.ErrNoSuchEntity)
   241  				So(transient.Tag.In(err), ShouldBeFalse)
   242  			})
   243  		})
   244  
   245  		Convey("Invalid MutationCallback", func() {
   246  			type badCallback func(cl *CL)
   247  			cases := func(kind string, repro func(bad badCallback)) {
   248  				Convey(kind, func() {
   249  					So(func() { repro(func(cl *CL) { cl.EVersion = 2 }) }, ShouldPanicLike, "CL.EVersion")
   250  					So(func() { repro(func(cl *CL) { cl.UpdateTime = ct.Clock.Now() }) }, ShouldPanicLike, "CL.UpdateTime")
   251  					So(func() { repro(func(cl *CL) { cl.ID++ }) }, ShouldPanicLike, "CL.ID")
   252  					So(func() { repro(func(cl *CL) { cl.ExternalID = "don't do this" }) }, ShouldPanicLike, "CL.ExternalID")
   253  				})
   254  			}
   255  			cases("Upsert creation", func(bad badCallback) {
   256  				_, _ = m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   257  					bad(cl)
   258  					return nil
   259  				})
   260  			})
   261  			cases("Upsert update", func(bad badCallback) {
   262  				eid.MustCreateIfNotExists(ctx)
   263  				ct.Clock.Add(time.Second)
   264  				_, _ = m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   265  					bad(cl)
   266  					return nil
   267  				})
   268  			})
   269  			cases("Update", func(bad badCallback) {
   270  				cl := eid.MustCreateIfNotExists(ctx)
   271  				ct.Clock.Add(time.Second)
   272  				_, _ = m.Update(ctx, lProject, cl.ID, func(cl *CL) error {
   273  					bad(cl)
   274  					return nil
   275  				})
   276  			})
   277  		})
   278  	})
   279  }
   280  
   281  func TestMutatorBatch(t *testing.T) {
   282  	t.Parallel()
   283  
   284  	Convey("Mutator works on batch of CLs", t, func() {
   285  		ct := cvtesting.Test{}
   286  		ctx, cancel := ct.SetUp(t)
   287  		defer cancel()
   288  
   289  		const lProjectAlt = "alt"
   290  		const lProject = "infra"
   291  		const run1 = lProject + "/1"
   292  		const run2 = lProject + "/2"
   293  		const run3 = lProject + "/3"
   294  		const gHost = "x-review.example.com"
   295  		const gChangeFirst = 100000
   296  		const N = 12
   297  
   298  		pm := pmMock{}
   299  		rm := rmMock{}
   300  		tj := tjMock{}
   301  		m := NewMutator(ct.TQDispatcher, &pm, &rm, &tj)
   302  
   303  		Convey(fmt.Sprintf("with %d CLs already in Datastore", N), func() {
   304  			var clids common.CLIDs
   305  			var expectedAltProject, expectedRun1, expectedRun2 common.CLIDs
   306  			for gChange := gChangeFirst; gChange < gChangeFirst+N; gChange++ {
   307  				cl := MustGobID(gHost, int64(gChange)).MustCreateIfNotExists(ctx)
   308  				clids = append(clids, cl.ID)
   309  				if gChange%2 == 0 {
   310  					cl.Snapshot = makeSnapshot(lProjectAlt, ct.Clock.Now())
   311  					expectedAltProject = append(expectedAltProject, cl.ID)
   312  				} else {
   313  					cl.Snapshot = makeSnapshot(lProject, ct.Clock.Now())
   314  				}
   315  				if gChange%3 == 0 {
   316  					cl.IncompleteRuns = append(cl.IncompleteRuns, run1)
   317  					expectedRun1 = append(expectedRun1, cl.ID)
   318  
   319  				}
   320  				if gChange%5 == 0 {
   321  					cl.IncompleteRuns = append(cl.IncompleteRuns, run2)
   322  					expectedRun2 = append(expectedRun2, cl.ID)
   323  				}
   324  				// Ensure each CL has unique EVersion later on.
   325  				cl.EVersion = int64(10 * gChange)
   326  				So(datastore.Put(ctx, cl), ShouldBeNil)
   327  			}
   328  			ct.Clock.Add(time.Minute)
   329  
   330  			// In all cases below, run3 is added to the list of incomplete CLs.
   331  			verify := func(resCLs []*CL) {
   332  				// Ensure the returned CLs are exactly what was stored in Datastore,
   333  				// and compute eversion map at the same time.
   334  				dsCLs, err := LoadCLsByIDs(ctx, clids)
   335  				So(err, ShouldBeNil)
   336  				eversions := make(map[common.CLID]int64, len(dsCLs))
   337  				for i := range dsCLs {
   338  					So(dsCLs[i].IncompleteRuns.ContainsSorted(run3), ShouldBeTrue)
   339  					So(dsCLs[i].ID, ShouldEqual, resCLs[i].ID)
   340  					So(dsCLs[i].EVersion, ShouldEqual, resCLs[i].EVersion)
   341  					So(dsCLs[i].UpdateTime, ShouldEqual, resCLs[i].UpdateTime)
   342  					So(dsCLs[i].RetentionKey, ShouldEqual, resCLs[i].RetentionKey)
   343  					So(dsCLs[i].IncompleteRuns, ShouldResemble, resCLs[i].IncompleteRuns)
   344  					eversions[dsCLs[i].ID] = dsCLs[i].EVersion
   345  				}
   346  
   347  				// Ensure Project and Run managers were notified correctly.
   348  				assertNotified := func(actual map[common.CLID]int64, expectedIDs common.CLIDs) {
   349  					expected := make(map[common.CLID]int64, len(expectedIDs))
   350  					for _, id := range expectedIDs {
   351  						expected[id] = eversions[id]
   352  					}
   353  					So(actual, ShouldResemble, expected)
   354  				}
   355  				// The project in the context of which CLs were mutated must be notified
   356  				// on all CLs.
   357  				assertNotified(pm.byProject[lProject], clids)
   358  				// Ditto for the run3, which was added to all CLs.
   359  				assertNotified(rm.byRun[run3], clids)
   360  				// Others must be notified on relevant CLs, only.
   361  				assertNotified(pm.byProject[lProjectAlt], expectedAltProject)
   362  				assertNotified(rm.byRun[run1], expectedRun1)
   363  				assertNotified(rm.byRun[run2], expectedRun2)
   364  				So(tj.clsNotified.Set(), ShouldBeEmpty)
   365  			}
   366  
   367  			Convey("BeginBatch + FinalizeBatch", func() {
   368  				var resCLs []*CL
   369  				transErr := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   370  					resCLs = nil // reset in case of retries
   371  					muts, err := m.BeginBatch(ctx, lProject, clids)
   372  					So(err, ShouldBeNil)
   373  					eg, _ := errgroup.WithContext(ctx)
   374  					for i := range muts {
   375  						mut := muts[i]
   376  						eg.Go(func() error {
   377  							mut.CL.IncompleteRuns = append(mut.CL.IncompleteRuns, run3)
   378  							return nil
   379  						})
   380  					}
   381  					So(eg.Wait(), ShouldBeNil)
   382  					resCLs, err = m.FinalizeBatch(ctx, muts)
   383  					return err
   384  				}, nil)
   385  				So(transErr, ShouldBeNil)
   386  
   387  				// Execute the expected BatchOnCLUpdatedTask.
   388  				So(ct.TQ.Tasks(), ShouldHaveLength, 1)
   389  				ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   390  
   391  				verify(resCLs)
   392  			})
   393  
   394  			Convey("Manual Adopt + FinalizeBatch", func() {
   395  				var resCLs []*CL
   396  				transErr := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   397  					resCLs = nil // reset in case of retries
   398  					muts := make([]*CLMutation, len(clids))
   399  					eg, egCtx := errgroup.WithContext(ctx)
   400  					for i, id := range clids {
   401  						i, id := i, id
   402  						eg.Go(func() error {
   403  							cl := &CL{ID: id}
   404  							if err := datastore.Get(egCtx, cl); err != nil {
   405  								return err
   406  							}
   407  							muts[i] = m.Adopt(ctx, lProject, cl)
   408  							muts[i].CL.IncompleteRuns = append(muts[i].CL.IncompleteRuns, run3)
   409  							return nil
   410  						})
   411  					}
   412  					So(eg.Wait(), ShouldBeNil)
   413  					var err error
   414  					resCLs, err = m.FinalizeBatch(ctx, muts)
   415  					return err
   416  				}, nil)
   417  				So(transErr, ShouldBeNil)
   418  
   419  				// Execute the expected BatchOnCLUpdatedTask.
   420  				So(ct.TQ.Tasks(), ShouldHaveLength, 1)
   421  				ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   422  
   423  				verify(resCLs)
   424  			})
   425  
   426  			Convey("BeginBatch + manual finalization", func() {
   427  				// This is inefficient and really shouldn't be done in production.
   428  				var resCLs []*CL
   429  				transErr := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   430  					resCLs = make([]*CL, len(clids)) // reset in case of retries
   431  					muts, err := m.BeginBatch(ctx, lProject, clids)
   432  					So(err, ShouldBeNil)
   433  					eg, egCtx := errgroup.WithContext(ctx)
   434  					for i, mut := range muts {
   435  						i, mut := i, mut
   436  						eg.Go(func() error {
   437  							mut.CL.IncompleteRuns = append(mut.CL.IncompleteRuns, run3)
   438  							var err error
   439  							resCLs[i], err = mut.Finalize(egCtx)
   440  							return err
   441  						})
   442  					}
   443  					return eg.Wait()
   444  				}, nil)
   445  				So(transErr, ShouldBeNil)
   446  
   447  				tasks := ct.TQ.Tasks()
   448  				So(tasks, ShouldHaveLength, N)
   449  				for _, t := range tasks {
   450  					So(t.Class, ShouldResemble, BatchOnCLUpdatedTaskClass)
   451  					ct.TQ.Run(ctx, tqtesting.StopAfterTask(BatchOnCLUpdatedTaskClass))
   452  				}
   453  
   454  				verify(resCLs)
   455  			})
   456  		})
   457  	})
   458  }
   459  
   460  func TestMutatorConcurrent(t *testing.T) {
   461  	t.Parallel()
   462  
   463  	Convey("Mutator works on single CL when called concurrently with flaky datastore", t, func() {
   464  		ct := cvtesting.Test{}
   465  		ctx, cancel := ct.SetUp(t)
   466  		defer cancel()
   467  		// Truncate to seconds to reduce noise in diffs of proto timestamps.
   468  		// use Seconds with lots of 0s at the end for easy grasp of assertion
   469  		// failures since they are done on protos.
   470  		epoch := (&timestamppb.Timestamp{Seconds: 14500000000}).AsTime()
   471  		ct.Clock.Set(epoch)
   472  
   473  		const lProject = "infra"
   474  		const gHost = "x-review.example.com"
   475  		const gChange = 44
   476  		eid := MustGobID(gHost, gChange)
   477  
   478  		pm := pmMock{}
   479  		rm := rmMock{}
   480  		tj := tjMock{}
   481  		m := NewMutator(ct.TQDispatcher, &pm, &rm, &tj)
   482  
   483  		ctx, fb := featureBreaker.FilterRDS(ctx, nil)
   484  		// Use a single random source for all flaky.Errors(...) instances. Otherwise
   485  		// they repeat the same random pattern each time withBrokenDS is called.
   486  		rnd := rand.NewSource(0)
   487  
   488  		// Make datastore very faulty.
   489  		fb.BreakFeaturesWithCallback(
   490  			flaky.Errors(flaky.Params{
   491  				Rand:                             rnd,
   492  				DeadlineProbability:              0.4,
   493  				ConcurrentTransactionProbability: 0.4,
   494  			}),
   495  			featureBreaker.DatastoreFeatures...,
   496  		)
   497  		// Number of tries per worker.
   498  		// With probabilities above, it typically takes <60 tries.
   499  		//
   500  		// This value was set to 300 before 2024-01-16 and it flaked once, so
   501  		// let's increase it to 30000.
   502  		const R = 30000
   503  		// Number of workers.
   504  		const N = 20
   505  
   506  		wg := sync.WaitGroup{}
   507  		wg.Add(N)
   508  		for d := 0; d < N; d++ {
   509  			// Simulate each worker trying to update Snapshot and DependentMeta to
   510  			// at least pre-determined timestamp.
   511  			// For extra coverage, use different timestamps for them.
   512  
   513  			// For a co-prime p,N:
   514  			//   assert sorted(set([((p*d)%N) for d in xrange(N)])) == range(N)
   515  			// 47, 59 are actual primes.
   516  			snapTS := epoch.Add(time.Second * time.Duration((47*d)%N))
   517  			accTS := epoch.Add(time.Second * time.Duration((73*d)%N))
   518  			go func() {
   519  				defer wg.Done()
   520  				snap := makeSnapshot(lProject, snapTS)
   521  				acc := makeAccess(lProject, accTS)
   522  				var err error
   523  				for i := 0; i < R; i++ {
   524  					// Make this thing a little more robust against flakiness and sleep for a millisecond
   525  					// every so often.
   526  					if i%1000 == 0 {
   527  						time.Sleep(1 * time.Millisecond)
   528  					}
   529  					_, err = m.Upsert(ctx, lProject, eid, func(cl *CL) error {
   530  						ret := ErrStopMutation
   531  						if t := cl.Snapshot.GetExternalUpdateTime(); t == nil || t.AsTime().Before(snapTS) {
   532  							cl.Snapshot = snap
   533  							ret = nil
   534  						}
   535  						if t := cl.Access.GetByProject()[lProject].GetUpdateTime(); t == nil || t.AsTime().Before(accTS) {
   536  							cl.Access = acc
   537  							ret = nil
   538  						}
   539  						return ret
   540  					})
   541  					if err == nil {
   542  						t.Logf("succeeded after %d tries", i)
   543  						return
   544  					}
   545  				}
   546  				panic(errors.Annotate(err, "all %d tries exhausted", R).Err())
   547  			}()
   548  		}
   549  		wg.Wait()
   550  
   551  		// "Fix" datastore, letting us examine it.
   552  		fb.BreakFeaturesWithCallback(
   553  			func(context.Context, string) error { return nil },
   554  			featureBreaker.DatastoreFeatures...,
   555  		)
   556  		cl, err := eid.Load(ctx)
   557  		So(err, ShouldBeNil)
   558  		So(cl, ShouldNotBeNil)
   559  		// Since all workers have succeeded, the latest snapshot
   560  		// (by ExternalUpdateTime) must be the current snapshot in datastore.
   561  		latestTS := epoch.Add((N - 1) * time.Second)
   562  		So(cl.Snapshot.GetExternalUpdateTime().AsTime(), ShouldEqual, latestTS)
   563  		So(cl.Access.GetByProject()[lProject].GetUpdateTime().AsTime(), ShouldResemble, latestTS)
   564  		// Furthermore, there must have been at most N non-noop UpdateSnapshot calls
   565  		// (one per worker, iff they did it exactly in the increasing order of
   566  		// timestamps.
   567  		t.Logf("%d updates done", cl.EVersion)
   568  		So(cl.EVersion, ShouldBeLessThan, N+1)
   569  	})
   570  }
   571  
   572  func makeAccess(luciProject string, updatedTime time.Time) *Access {
   573  	return &Access{ByProject: map[string]*Access_Project{
   574  		luciProject: {
   575  			NoAccess:     true,
   576  			NoAccessTime: timestamppb.New(updatedTime),
   577  			UpdateTime:   timestamppb.New(updatedTime),
   578  		},
   579  	}}
   580  }
   581  
   582  type pmMock struct {
   583  	m         sync.Mutex
   584  	byProject map[string]map[common.CLID]int64 // latest max EVersion
   585  }
   586  
   587  func (p *pmMock) NotifyCLsUpdated(ctx context.Context, project string, events *CLUpdatedEvents) error {
   588  	p.m.Lock()
   589  	defer p.m.Unlock()
   590  	if p.byProject == nil {
   591  		p.byProject = make(map[string]map[common.CLID]int64, 1)
   592  	}
   593  	m := p.byProject[project]
   594  	if m == nil {
   595  		m = make(map[common.CLID]int64, len(events.GetEvents()))
   596  		p.byProject[project] = m
   597  	}
   598  	for _, e := range events.GetEvents() {
   599  		clid := common.CLID(e.GetClid())
   600  		m[clid] = max(m[clid], e.GetEversion())
   601  	}
   602  	return nil
   603  }
   604  
   605  type rmMock struct {
   606  	m     sync.Mutex
   607  	byRun map[common.RunID]map[common.CLID]int64 // latest max EVersion
   608  }
   609  
   610  func (r *rmMock) NotifyCLsUpdated(ctx context.Context, rid common.RunID, events *CLUpdatedEvents) error {
   611  	r.m.Lock()
   612  	defer r.m.Unlock()
   613  	if r.byRun == nil {
   614  		r.byRun = make(map[common.RunID]map[common.CLID]int64, 1)
   615  	}
   616  	m := r.byRun[rid]
   617  	if m == nil {
   618  		m = make(map[common.CLID]int64, 1)
   619  		r.byRun[rid] = m
   620  	}
   621  	for _, e := range events.GetEvents() {
   622  		clid := common.CLID(e.GetClid())
   623  		m[clid] = max(m[clid], e.GetEversion())
   624  	}
   625  	return nil
   626  }
   627  
   628  type tjMock struct {
   629  	clsNotified common.CLIDs
   630  	mutex       sync.Mutex
   631  }
   632  
   633  func (t *tjMock) ScheduleCancelStale(ctx context.Context, clid common.CLID, prevMinEquivalentPatchset, currentMinEquivalentPatchset int32, eta time.Time) error {
   634  	t.mutex.Lock()
   635  	defer t.mutex.Unlock()
   636  	t.clsNotified = append(t.clsNotified, clid)
   637  	return nil
   638  }