go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/changelist/updater_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  	"sort"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/protobuf/proto"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/common/clock/testclock"
    28  	"go.chromium.org/luci/common/errors"
    29  	gerrit "go.chromium.org/luci/common/proto/gerrit"
    30  	"go.chromium.org/luci/common/retry/transient"
    31  	"go.chromium.org/luci/gae/service/datastore"
    32  	"go.chromium.org/luci/server/tq"
    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  	"go.chromium.org/luci/cv/internal/metrics"
    38  
    39  	. "github.com/smartystreets/goconvey/convey"
    40  	. "go.chromium.org/luci/common/testing/assertions"
    41  )
    42  
    43  func externalTime(t time.Time) *UpdateCLTask_Hint {
    44  	return &UpdateCLTask_Hint{ExternalUpdateTime: timestamppb.New(t)}
    45  }
    46  
    47  func TestUpdaterSchedule(t *testing.T) {
    48  	t.Parallel()
    49  
    50  	Convey("Correctly generate dedup keys for Updater TQ tasks", t, func() {
    51  		ct := cvtesting.Test{}
    52  		ctx, cancel := ct.SetUp(t)
    53  		defer cancel()
    54  
    55  		Convey("Correctly generate dedup keys for Updater TQ tasks", func() {
    56  			Convey("Diff CLIDs have diff dedup keys", func() {
    57  				t := &UpdateCLTask{LuciProject: "proj", Id: 7}
    58  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
    59  				t.Id = 8
    60  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
    61  				So(k1, ShouldNotResemble, k2)
    62  			})
    63  
    64  			Convey("Diff ExternalID have diff dedup keys", func() {
    65  				t := &UpdateCLTask{LuciProject: "proj"}
    66  				t.ExternalId = "kind1/foo/23"
    67  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
    68  				t.ExternalId = "kind4/foo/56"
    69  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
    70  				So(k1, ShouldNotResemble, k2)
    71  			})
    72  
    73  			Convey("Even if ExternalID and internal ID refer to the same CL, they have diff dedup keys", func() {
    74  				t1 := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
    75  				t2 := &UpdateCLTask{LuciProject: "proj", Id: 2}
    76  				k1 := makeTaskDeduplicationKey(ctx, t1, 0)
    77  				k2 := makeTaskDeduplicationKey(ctx, t2, 0)
    78  				So(k1, ShouldNotResemble, k2)
    79  			})
    80  
    81  			Convey("Diff updatedHint have diff dedup keys", func() {
    82  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
    83  				t.Hint = externalTime(ct.Clock.Now())
    84  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
    85  				t.Hint = externalTime(ct.Clock.Now().Add(time.Second))
    86  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
    87  				So(k1, ShouldNotResemble, k2)
    88  			})
    89  
    90  			Convey("Same CLs but diff LUCI projects have diff dedup keys", func() {
    91  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
    92  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
    93  				t.LuciProject += "-diff"
    94  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
    95  				So(k1, ShouldNotResemble, k2)
    96  			})
    97  
    98  			Convey("Same CL at the same time is de-duped", func() {
    99  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
   100  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
   101  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
   102  				So(k1, ShouldResemble, k2)
   103  
   104  				Convey("Internal ID doesn't affect dedup based on ExternalID", func() {
   105  					t.Id = 123
   106  					k3 := makeTaskDeduplicationKey(ctx, t, 0)
   107  					So(k3, ShouldResemble, k1)
   108  				})
   109  			})
   110  
   111  			Convey("Same CL with a delay or after the same delay is de-duped", func() {
   112  				t := &UpdateCLTask{LuciProject: "proj", Id: 123}
   113  				k1 := makeTaskDeduplicationKey(ctx, t, time.Second)
   114  				ct.Clock.Add(time.Second)
   115  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
   116  				So(k1, ShouldResemble, k2)
   117  			})
   118  
   119  			Convey("Same CL at mostly same time is also de-duped", func() {
   120  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
   121  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
   122  				// NOTE: this check may fail if common.DistributeOffset is changed,
   123  				// making new timestamp in the next epoch. If so, adjust the increment.
   124  				ct.Clock.Add(time.Second)
   125  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
   126  				So(k1, ShouldResemble, k2)
   127  			})
   128  
   129  			Convey("Same CL after sufficient time is no longer de-duped", func() {
   130  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
   131  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
   132  				k2 := makeTaskDeduplicationKey(ctx, t, blindRefreshInterval)
   133  				So(k1, ShouldNotResemble, k2)
   134  			})
   135  
   136  			Convey("Same CL with the same MetaRevId is de-duped", func() {
   137  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
   138  				t.Hint = &UpdateCLTask_Hint{MetaRevId: "foo"}
   139  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
   140  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
   141  				So(k1, ShouldResemble, k2)
   142  			})
   143  
   144  			Convey("Same CL with the different MetaRevId is not de-duped", func() {
   145  				t := &UpdateCLTask{LuciProject: "proj", ExternalId: "kind1/foo/23"}
   146  				t.Hint = &UpdateCLTask_Hint{MetaRevId: "foo"}
   147  				k1 := makeTaskDeduplicationKey(ctx, t, 0)
   148  				t.Hint = &UpdateCLTask_Hint{MetaRevId: "bar"}
   149  				k2 := makeTaskDeduplicationKey(ctx, t, 0)
   150  				So(k1, ShouldNotResemble, k2)
   151  			})
   152  		})
   153  
   154  		Convey("makeTQTitleForHumans works", func() {
   155  			So(makeTQTitleForHumans(&UpdateCLTask{
   156  				LuciProject: "proj",
   157  				Id:          123,
   158  			}), ShouldResemble, "proj/123")
   159  			So(makeTQTitleForHumans(&UpdateCLTask{
   160  				LuciProject: "proj",
   161  				ExternalId:  "kind/xyz/44",
   162  				Id:          123,
   163  			}), ShouldResemble, "proj/123/kind/xyz/44")
   164  			So(makeTQTitleForHumans(&UpdateCLTask{
   165  				LuciProject: "proj",
   166  				ExternalId:  "gerrit/chromium-review.googlesource.com/1111111",
   167  				Id:          123,
   168  			}), ShouldResemble, "proj/123/gerrit/chromium/1111111")
   169  			So(makeTQTitleForHumans(&UpdateCLTask{
   170  				LuciProject: "proj",
   171  				ExternalId:  "gerrit/chromium-review.googlesource.com/1111111",
   172  				Hint:        externalTime(testclock.TestRecentTimeUTC),
   173  			}), ShouldResemble, "proj/gerrit/chromium/1111111/u2016-02-03T04:05:06Z")
   174  		})
   175  
   176  		Convey("Works overall", func() {
   177  			u := NewUpdater(ct.TQDispatcher, nil)
   178  			t := &UpdateCLTask{
   179  				LuciProject: "proj",
   180  				Id:          123,
   181  				Hint:        externalTime(ct.Clock.Now().Add(-time.Second)),
   182  				Requester:   UpdateCLTask_RUN_POKE,
   183  			}
   184  			delay := time.Minute
   185  			So(u.ScheduleDelayed(ctx, t, delay), ShouldBeNil)
   186  			So(ct.TQ.Tasks().Payloads(), ShouldResembleProto, []proto.Message{t})
   187  
   188  			_, _ = Println("Dedup works")
   189  			ct.Clock.Add(delay)
   190  			So(u.Schedule(ctx, t), ShouldBeNil)
   191  			So(ct.TQ.Tasks().Payloads(), ShouldHaveLength, 1)
   192  
   193  			_, _ = Println("But not within the transaction")
   194  			err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   195  				return u.Schedule(ctx, t)
   196  			}, nil)
   197  			So(err, ShouldBeNil)
   198  			So(ct.TQ.Tasks().Payloads(), ShouldResembleProto, []proto.Message{t, t})
   199  
   200  			_, _ = Println("Once out of dedup window, schedules a new task")
   201  			ct.Clock.Add(knownRefreshInterval)
   202  			So(u.Schedule(ctx, t), ShouldBeNil)
   203  			So(ct.TQ.Tasks().Payloads(), ShouldResembleProto, []proto.Message{t, t, t})
   204  		})
   205  	})
   206  }
   207  
   208  func TestUpdaterBatch(t *testing.T) {
   209  	t.Parallel()
   210  
   211  	Convey("Correctly handle batches", t, func() {
   212  		ct := cvtesting.Test{}
   213  		ctx, cancel := ct.SetUp(t)
   214  		defer cancel()
   215  
   216  		sortedTQPayloads := func() []proto.Message {
   217  			payloads := ct.TQ.Tasks().Payloads()
   218  			sort.Slice(payloads, func(i, j int) bool {
   219  				return payloads[i].(*UpdateCLTask).GetExternalId() < payloads[j].(*UpdateCLTask).GetExternalId()
   220  			})
   221  			return payloads
   222  		}
   223  
   224  		u := NewUpdater(ct.TQDispatcher, nil)
   225  		clA := ExternalID("foo/a/1").MustCreateIfNotExists(ctx)
   226  		clB := ExternalID("foo/b/2").MustCreateIfNotExists(ctx)
   227  
   228  		expectedPayloads := []proto.Message{
   229  			&UpdateCLTask{
   230  				LuciProject: "proj",
   231  				ExternalId:  "foo/a/1",
   232  				Id:          int64(clA.ID),
   233  				Requester:   UpdateCLTask_RUN_POKE,
   234  			},
   235  			&UpdateCLTask{
   236  				LuciProject: "proj",
   237  				ExternalId:  "foo/b/2",
   238  				Id:          int64(clB.ID),
   239  				Requester:   UpdateCLTask_RUN_POKE,
   240  			},
   241  		}
   242  
   243  		Convey("outside of a transaction, enqueues individual tasks", func() {
   244  			Convey("special case of just one task", func() {
   245  				err := u.ScheduleBatch(ctx, "proj", []*CL{clA}, UpdateCLTask_RUN_POKE)
   246  				So(err, ShouldBeNil)
   247  				So(sortedTQPayloads(), ShouldResembleProto, expectedPayloads[:1])
   248  			})
   249  			Convey("multiple", func() {
   250  				err := u.ScheduleBatch(ctx, "proj", []*CL{clA, clB}, UpdateCLTask_RUN_POKE)
   251  				So(err, ShouldBeNil)
   252  				So(sortedTQPayloads(), ShouldResembleProto, expectedPayloads)
   253  			})
   254  		})
   255  
   256  		Convey("inside of a transaction, enqueues just one task", func() {
   257  			err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   258  				return u.ScheduleBatch(ctx, "proj", []*CL{clA, clB}, UpdateCLTask_RUN_POKE)
   259  			}, nil)
   260  			So(err, ShouldBeNil)
   261  			So(ct.TQ.Tasks(), ShouldHaveLength, 1)
   262  			// Run just the batch task.
   263  			ct.TQ.Run(ctx, tqtesting.StopAfterTask(BatchUpdateCLTaskClass))
   264  			So(sortedTQPayloads(), ShouldResembleProto, expectedPayloads)
   265  		})
   266  	})
   267  }
   268  
   269  // TestUpdaterWorkingHappyPath is the simplest test for an updater, which also
   270  // illustrates the simplest UpdaterBackend.
   271  func TestUpdaterHappyPath(t *testing.T) {
   272  	t.Parallel()
   273  
   274  	Convey("Updater's happy path with simplest possible backend", t, func() {
   275  		ct := cvtesting.Test{}
   276  		ctx, cancel := ct.SetUp(t)
   277  		defer cancel()
   278  
   279  		pm, rm, tj := pmMock{}, rmMock{}, tjMock{}
   280  		u := NewUpdater(ct.TQDispatcher, NewMutator(ct.TQDispatcher, &pm, &rm, &tj))
   281  		b := &fakeUpdaterBackend{}
   282  		u.RegisterBackend(b)
   283  
   284  		////////////////////////////////////////////
   285  		// Phase 1: import CL for the first time. //
   286  		////////////////////////////////////////////
   287  
   288  		b.fetchResult = UpdateFields{
   289  			Snapshot: &Snapshot{
   290  				ExternalUpdateTime:    timestamppb.New(ct.Clock.Now().Add(-1 * time.Second)),
   291  				Patchset:              2,
   292  				MinEquivalentPatchset: 1,
   293  				LuciProject:           "luci-project",
   294  				Kind:                  nil, // but should be set in practice,
   295  			},
   296  			ApplicableConfig: &ApplicableConfig{
   297  				Projects: []*ApplicableConfig_Project{
   298  					{
   299  						Name:           "luci-project",
   300  						ConfigGroupIds: []string{"hash/name"},
   301  					},
   302  				},
   303  			},
   304  		}
   305  		// Actually run the Updater.
   306  		So(u.handleCL(ctx, &UpdateCLTask{
   307  			LuciProject: "luci-project",
   308  			ExternalId:  "fake/123",
   309  			Requester:   UpdateCLTask_PUBSUB_POLL,
   310  		}), ShouldBeNil)
   311  
   312  		// Ensure that it reported metrics for the CL fetch events.
   313  		So(ct.TSMonSentValue(ctx, metrics.Internal.CLIngestionAttempted,
   314  			UpdateCLTask_PUBSUB_POLL.String(), // metric:requester,
   315  			true,                              // metric:changed == true
   316  			false,                             // metric:dep
   317  			"luci-project",                    // metric:project,
   318  			true,                              // metric:changed_snapshot == true
   319  		), ShouldEqual, 1)
   320  		So(ct.TSMonSentDistr(ctx, metrics.Internal.CLIngestionLatency,
   321  			UpdateCLTask_PUBSUB_POLL.String(), // metric:requester,
   322  			false,                             // metric:dep
   323  			"luci-project",                    // metric:project,
   324  			true,                              // metric:changed_snapshot == true
   325  		).Sum(), ShouldAlmostEqual, 1)
   326  		So(ct.TSMonSentDistr(ctx, metrics.Internal.CLIngestionLatencyWithoutFetch,
   327  			UpdateCLTask_PUBSUB_POLL.String(), // metric:requester,
   328  			false,                             // metric:dep
   329  			"luci-project",                    // metric:project,
   330  			true,                              // metric:changed_snapshot == true
   331  		).Sum(), ShouldNotBeNil)
   332  
   333  		// Ensure CL is created with correct data.
   334  		cl, err := ExternalID("fake/123").Load(ctx)
   335  		So(err, ShouldBeNil)
   336  		So(cl.Snapshot, ShouldResembleProto, b.fetchResult.Snapshot)
   337  		So(cl.ApplicableConfig, ShouldResembleProto, b.fetchResult.ApplicableConfig)
   338  		So(cl.UpdateTime, ShouldHappenWithin, time.Microsecond /*see DS.RoundTime()*/, ct.Clock.Now())
   339  
   340  		// Since there are no Runs associated with the CL, the outstanding TQ task
   341  		// should ultimately notify the Project Manager.
   342  		ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   343  		So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   344  			"luci-project": {cl.ID: cl.EVersion},
   345  		})
   346  
   347  		// Later, a Run will start on this CL.
   348  		const runID = "luci-project/123-1-beef"
   349  		cl.IncompleteRuns = common.RunIDs{runID}
   350  		cl.EVersion++
   351  		So(datastore.Put(ctx, cl), ShouldBeNil)
   352  
   353  		///////////////////////////////////////////////////
   354  		// Phase 2: update the CL with the new patchset. //
   355  		///////////////////////////////////////////////////
   356  
   357  		ct.Clock.Add(time.Hour)
   358  		b.reset()
   359  		b.fetchResult.Snapshot = proto.Clone(cl.Snapshot).(*Snapshot)
   360  		b.fetchResult.Snapshot.ExternalUpdateTime = timestamppb.New(ct.Clock.Now())
   361  		b.fetchResult.Snapshot.Patchset++
   362  		b.lookupACfgResult = cl.ApplicableConfig // unchanged
   363  
   364  		// Actually run the Updater.
   365  		So(u.handleCL(ctx, &UpdateCLTask{
   366  			LuciProject: "luci-project",
   367  			ExternalId:  "fake/123",
   368  		}), ShouldBeNil)
   369  		cl2 := reloadCL(ctx, cl)
   370  
   371  		// The CL entity should have a new patchset and PM/RM should be notified.
   372  		So(cl2.Snapshot.GetPatchset(), ShouldEqual, 3)
   373  		ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   374  		So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   375  			"luci-project": {cl.ID: cl2.EVersion},
   376  		})
   377  		So(rm.byRun, ShouldResemble, map[common.RunID]map[common.CLID]int64{
   378  			runID: {cl.ID: cl2.EVersion},
   379  		})
   380  
   381  		///////////////////////////////////////////////////
   382  		// Phase 3: update if backend detect a change    //
   383  		///////////////////////////////////////////////////
   384  		b.reset()
   385  		b.fetchResult.Snapshot = proto.Clone(cl.Snapshot).(*Snapshot) // unchanged
   386  		b.lookupACfgResult = cl.ApplicableConfig                      // unchanged
   387  		b.backendSnapshotUpdated = true
   388  
   389  		// Actually run the Updater.
   390  		So(u.handleCL(ctx, &UpdateCLTask{
   391  			LuciProject: "luci-project",
   392  			ExternalId:  "fake/123",
   393  		}), ShouldBeNil)
   394  		cl3 := reloadCL(ctx, cl)
   395  
   396  		// The CL entity have been updated
   397  		So(cl3.EVersion, ShouldBeGreaterThan, cl2.EVersion)
   398  		ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   399  		So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   400  			"luci-project": {cl.ID: cl3.EVersion},
   401  		})
   402  		So(rm.byRun, ShouldResemble, map[common.RunID]map[common.CLID]int64{
   403  			runID: {cl.ID: cl3.EVersion},
   404  		})
   405  	})
   406  }
   407  
   408  func TestUpdaterFetchedNoNewData(t *testing.T) {
   409  	t.Parallel()
   410  
   411  	Convey("Updater skips updating the CL when no new data is fetched", t, func() {
   412  		ct := cvtesting.Test{}
   413  		ctx, cancel := ct.SetUp(t)
   414  		defer cancel()
   415  
   416  		pm, rm, tj := pmMock{}, rmMock{}, tjMock{}
   417  		u := NewUpdater(ct.TQDispatcher, NewMutator(ct.TQDispatcher, &pm, &rm, &tj))
   418  		b := &fakeUpdaterBackend{}
   419  		u.RegisterBackend(b)
   420  
   421  		snap := &Snapshot{
   422  			ExternalUpdateTime:    timestamppb.New(ct.Clock.Now()),
   423  			Patchset:              2,
   424  			MinEquivalentPatchset: 1,
   425  			LuciProject:           "luci-project",
   426  			Kind:                  nil, // but should be set in practice
   427  		}
   428  		acfg := &ApplicableConfig{Projects: []*ApplicableConfig_Project{
   429  			{
   430  				Name:           "luci-projectj",
   431  				ConfigGroupIds: []string{"hash/old"},
   432  			},
   433  		}}
   434  		// Put an existing CL.
   435  		cl := ExternalID("fake/1").MustCreateIfNotExists(ctx)
   436  		cl.ApplicableConfig = acfg
   437  		cl.Snapshot = snap
   438  		cl.EVersion++
   439  		So(datastore.Put(ctx, cl), ShouldBeNil)
   440  
   441  		Convey("updaterBackend is aware that there is no new data", func() {
   442  			b.fetchResult = UpdateFields{}
   443  		})
   444  		Convey("updaterBackend is not aware that it fetched the exact same data", func() {
   445  			b.fetchResult = UpdateFields{
   446  				Snapshot:         snap,
   447  				ApplicableConfig: acfg,
   448  			}
   449  		})
   450  
   451  		err := u.handleCL(ctx, &UpdateCLTask{
   452  			LuciProject: "luci-project",
   453  			ExternalId:  "fake/1",
   454  			Requester:   UpdateCLTask_PUBSUB_POLL})
   455  
   456  		So(err, ShouldBeNil)
   457  
   458  		// Check the monitoring data
   459  		if b.fetchResult.IsEmpty() {
   460  			// Empty fetched result implies that it didn't even perform
   461  			// a fetch. Hence, no metrics should have been reported.
   462  			So(ct.TSMonStore.GetAll(ctx), ShouldBeNil)
   463  		} else {
   464  			// This is the case where a fetch was performed but
   465  			// the data was actually the same as the existing snapshot.
   466  			So(ct.TSMonSentValue(ctx, metrics.Internal.CLIngestionAttempted,
   467  				UpdateCLTask_PUBSUB_POLL.String(), // metric:requester,
   468  				false,                             // metric:changed == false
   469  				false,                             // metric:dep
   470  				"luci-project",                    // metric:project,
   471  				false,                             // metric:changed_snapshot == false
   472  			), ShouldEqual, 1)
   473  			So(ct.TSMonSentDistr(ctx, metrics.Internal.CLIngestionLatency,
   474  				UpdateCLTask_PUBSUB_POLL.String(), // metric:requester,
   475  				false,                             // metric:dep
   476  				"luci-project",                    // metric:project,
   477  				false,                             // metric:changed_snapshot == false,
   478  			), ShouldBeNil)
   479  			So(ct.TSMonSentDistr(ctx, metrics.Internal.CLIngestionLatencyWithoutFetch,
   480  				UpdateCLTask_PUBSUB_POLL.String(), // metric:requester,
   481  				false,                             // metric:dep
   482  				"luci-project",                    // metric:project,
   483  				false,                             // metric:changed_snapshot == false
   484  			), ShouldBeNil)
   485  		}
   486  
   487  		// CL entity shouldn't change and notifications should not be emitted.
   488  		cl2 := reloadCL(ctx, cl)
   489  		So(cl2.EVersion, ShouldEqual, cl.EVersion)
   490  		// CL Mutator guarantees that EVersion is bumped on every write, but check
   491  		// the entire CL contents anyway in case there is a buggy by-pass of
   492  		// Mutator somewhere.
   493  		So(cl2, cvtesting.SafeShouldResemble, cl)
   494  		So(ct.TQ.Tasks(), ShouldBeEmpty)
   495  	})
   496  }
   497  
   498  func TestUpdaterAccessRestriction(t *testing.T) {
   499  	t.Parallel()
   500  
   501  	Convey("Updater works correctly when backend denies access to the CL", t, func() {
   502  		// This is a long test, don't debug it first if other TestUpdater* tests are
   503  		// also failing.
   504  		ct := cvtesting.Test{}
   505  		ctx, cancel := ct.SetUp(t)
   506  		defer cancel()
   507  
   508  		pm, rm, tj := pmMock{}, rmMock{}, tjMock{}
   509  		u := NewUpdater(ct.TQDispatcher, NewMutator(ct.TQDispatcher, &pm, &rm, &tj))
   510  		b := &fakeUpdaterBackend{}
   511  		u.RegisterBackend(b)
   512  
   513  		//////////////////////////////////////////////////////////////////////////
   514  		// Phase 1: prepare an old CL previously fetched for another LUCI project.
   515  		//////////////////////////////////////////////////////////////////////////
   516  
   517  		longTimeAgo := ct.Clock.Now().Add(-180 * 24 * time.Hour)
   518  		cl := ExternalID("fake/1").MustCreateIfNotExists(ctx)
   519  		cl.Snapshot = &Snapshot{
   520  			ExternalUpdateTime:    timestamppb.New(longTimeAgo),
   521  			Patchset:              2,
   522  			MinEquivalentPatchset: 1,
   523  			LuciProject:           "previously-existing-project",
   524  			Kind:                  nil, // but should be set in practice,
   525  		}
   526  		cl.ApplicableConfig = &ApplicableConfig{Projects: []*ApplicableConfig_Project{
   527  			{
   528  				Name:           "previously-existing-project",
   529  				ConfigGroupIds: []string{"old-hash/old-name"},
   530  			},
   531  		}}
   532  		alsoLongTimeAgo := longTimeAgo.Add(time.Minute)
   533  		cl.Access = &Access{ByProject: map[string]*Access_Project{
   534  			// One possibility is user makes a typo in the free-from Cq-Depend
   535  			// footer and accidentally referenced a CL from totally different
   536  			// project.
   537  			"another-project-with-invalid-cl-deps": {NoAccessTime: timestamppb.New(alsoLongTimeAgo)},
   538  		}}
   539  		cl.EVersion++
   540  		So(datastore.Put(ctx, cl), ShouldBeNil)
   541  
   542  		//////////////////////////////////////////////////////////////////////////
   543  		// Phase 2: simulate a Fetch which got access denied from backend.
   544  		//////////////////////////////////////////////////////////////////////////
   545  
   546  		b.fetchResult = UpdateFields{
   547  			ApplicableConfig: &ApplicableConfig{Projects: []*ApplicableConfig_Project{
   548  				// Note that the old project is no longer watching this CL.
   549  				{
   550  					Name:           "luci-project",
   551  					ConfigGroupIds: []string{"hash/name"},
   552  				},
   553  			}},
   554  			Snapshot: nil, // nothing was actually fetched.
   555  			AddDependentMeta: &Access{ByProject: map[string]*Access_Project{
   556  				"luci-project": {NoAccessTime: timestamppb.New(ct.Clock.Now())},
   557  			}},
   558  		}
   559  
   560  		err := u.handleCL(ctx, &UpdateCLTask{LuciProject: "luci-project", ExternalId: "fake/1"})
   561  		So(err, ShouldBeNil)
   562  
   563  		// Resulting CL entity should keep the Snapshot, rewrite ApplicableConfig,
   564  		// and merge Access.
   565  		cl2 := reloadCL(ctx, cl)
   566  		So(cl2.Snapshot, ShouldResembleProto, cl.Snapshot)
   567  		So(cl2.ApplicableConfig, ShouldResembleProto, b.fetchResult.ApplicableConfig)
   568  		So(cl2.Access, ShouldResembleProto, &Access{ByProject: map[string]*Access_Project{
   569  			"another-project-with-invalid-cl-deps": {NoAccessTime: timestamppb.New(alsoLongTimeAgo)},
   570  			"luci-project":                         {NoAccessTime: timestamppb.New(ct.Clock.Now())},
   571  		}})
   572  		// Notifications doesn't have to be sent to the project with invalid deps,
   573  		// as this update is irrelevant to the project.
   574  		ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   575  		So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   576  			"luci-project":                {cl.ID: cl2.EVersion},
   577  			"previously-existing-project": {cl.ID: cl2.EVersion},
   578  		})
   579  
   580  		//////////////////////////////////////////////////////////////////////////
   581  		// Phase 3: backend ACLs are fixed.
   582  		//////////////////////////////////////////////////////////////////////////
   583  		ct.Clock.Add(time.Hour)
   584  		b.reset()
   585  		b.fetchResult = UpdateFields{
   586  			Snapshot: &Snapshot{
   587  				ExternalUpdateTime:    timestamppb.New(ct.Clock.Now()),
   588  				Patchset:              4,
   589  				MinEquivalentPatchset: 1,
   590  				LuciProject:           "luci-project",
   591  				Kind:                  nil, // but should be set in practice
   592  			},
   593  			ApplicableConfig: cl2.ApplicableConfig, // same as before
   594  			DelAccess:        []string{"luci-project"},
   595  		}
   596  		err = u.handleCL(ctx, &UpdateCLTask{LuciProject: "luci-project", ExternalId: "fake/1"})
   597  		So(err, ShouldBeNil)
   598  		cl3 := reloadCL(ctx, cl)
   599  		So(cl3.Snapshot, ShouldResembleProto, b.fetchResult.Snapshot)                      // replaced
   600  		So(cl3.ApplicableConfig, ShouldResembleProto, cl2.ApplicableConfig)                // same
   601  		So(cl3.Access, ShouldResembleProto, &Access{ByProject: map[string]*Access_Project{ // updated
   602  			// No more "luci-project" entry.
   603  			"another-project-with-invalid-cl-deps": {NoAccessTime: timestamppb.New(alsoLongTimeAgo)},
   604  		}})
   605  		// Notifications are still not sent to the project with invalid deps.
   606  		ct.TQ.Run(ctx, tqtesting.StopWhenDrained())
   607  		So(pm.byProject, ShouldResemble, map[string]map[common.CLID]int64{
   608  			"luci-project":                {cl.ID: cl3.EVersion},
   609  			"previously-existing-project": {cl.ID: cl3.EVersion},
   610  		})
   611  	})
   612  }
   613  
   614  func TestUpdaterHandlesErrors(t *testing.T) {
   615  	t.Parallel()
   616  
   617  	Convey("Updater handles errors", t, func() {
   618  		ct := cvtesting.Test{}
   619  		ctx, cancel := ct.SetUp(t)
   620  		defer cancel()
   621  
   622  		u := NewUpdater(ct.TQDispatcher, nil)
   623  
   624  		Convey("bails permanently in cases which should not happen", func() {
   625  			Convey("No ID given", func() {
   626  				err := u.handleCL(ctx, &UpdateCLTask{
   627  					LuciProject: "luci-project",
   628  				})
   629  				So(err, ShouldErrLike, "invalid task input")
   630  				So(tq.Fatal.In(err), ShouldBeTrue)
   631  			})
   632  			Convey("No LUCI project given", func() {
   633  				err := u.handleCL(ctx, &UpdateCLTask{
   634  					ExternalId: "fake/1",
   635  				})
   636  				So(err, ShouldErrLike, "invalid task input")
   637  				So(tq.Fatal.In(err), ShouldBeTrue)
   638  			})
   639  			Convey("Contradicting external and internal IDs", func() {
   640  				cl1 := ExternalID("fake/1").MustCreateIfNotExists(ctx)
   641  				cl2 := ExternalID("fake/2").MustCreateIfNotExists(ctx)
   642  				err := u.handleCL(ctx, &UpdateCLTask{
   643  					LuciProject: "luci-project",
   644  					Id:          int64(cl1.ID),
   645  					ExternalId:  string(cl2.ExternalID),
   646  				})
   647  				So(err, ShouldErrLike, "invalid task")
   648  				So(tq.Fatal.In(err), ShouldBeTrue)
   649  			})
   650  			Convey("Internal ID doesn't actually exist", func() {
   651  				// While in most cases this is a bug, it can happen in prod
   652  				// if an old CL is being deleted due to data retention policy at the
   653  				// same time as something else inside the CV is requesting a refresh of
   654  				// the CL against external system. One example of this is if a new CL
   655  				// mistakenly marked a very old CL as a dependency.
   656  				err := u.handleCL(ctx, &UpdateCLTask{
   657  					Id:          404,
   658  					LuciProject: "luci-project",
   659  				})
   660  				So(err, ShouldErrLike, datastore.ErrNoSuchEntity)
   661  				So(tq.Fatal.In(err), ShouldBeTrue)
   662  			})
   663  			Convey("CL from unregistered backend", func() {
   664  				err := u.handleCL(ctx, &UpdateCLTask{
   665  					ExternalId:  "unknown/404",
   666  					LuciProject: "luci-project",
   667  				})
   668  				So(err, ShouldErrLike, "backend is not supported")
   669  				So(tq.Fatal.In(err), ShouldBeTrue)
   670  			})
   671  		})
   672  
   673  		Convey("Respects TQErrorSpec", func() {
   674  			ignoreMe := errors.New("ignore-me")
   675  			b := &fakeUpdaterBackend{
   676  				tqErrorSpec: common.TQIfy{
   677  					KnownIgnore: []error{ignoreMe},
   678  				},
   679  				fetchError: errors.Annotate(ignoreMe, "something went wrong").Err(),
   680  			}
   681  			u.RegisterBackend(b)
   682  			err := u.handleCL(ctx, &UpdateCLTask{LuciProject: "lp", ExternalId: "fake/1"})
   683  			So(tq.Ignore.In(err), ShouldBeTrue)
   684  			So(err, ShouldErrLike, "ignore-me")
   685  		})
   686  	})
   687  }
   688  
   689  func TestUpdaterAvoidsFetchWhenPossible(t *testing.T) {
   690  	t.Parallel()
   691  
   692  	Convey("Updater skips fetching when possible", t, func() {
   693  		ct := cvtesting.Test{}
   694  		ctx, cancel := ct.SetUp(t)
   695  		defer cancel()
   696  
   697  		u := NewUpdater(ct.TQDispatcher, NewMutator(ct.TQDispatcher, &pmMock{}, &rmMock{}, &tjMock{}))
   698  		b := &fakeUpdaterBackend{}
   699  		u.RegisterBackend(b)
   700  
   701  		// Simulate a perfect case for avoiding the snapshot.
   702  		cl := ExternalID("fake/123").MustCreateIfNotExists(ctx)
   703  		cl.Snapshot = &Snapshot{
   704  			ExternalUpdateTime:    timestamppb.New(ct.Clock.Now()),
   705  			Patchset:              2,
   706  			MinEquivalentPatchset: 1,
   707  			LuciProject:           "luci-project",
   708  			Kind:                  nil, // but should be set in practice,
   709  		}
   710  		cl.ApplicableConfig = &ApplicableConfig{
   711  			Projects: []*ApplicableConfig_Project{
   712  				{
   713  					Name:           "luci-project",
   714  					ConfigGroupIds: []string{"hash/name"},
   715  				},
   716  			},
   717  		}
   718  		cl.EVersion++
   719  		So(datastore.Put(ctx, cl), ShouldBeNil)
   720  
   721  		task := &UpdateCLTask{
   722  			LuciProject: "luci-project",
   723  			ExternalId:  string(cl.ExternalID),
   724  			Hint:        externalTime(ct.Clock.Now()),
   725  		}
   726  		// Typically, ApplicableConfig config (i.e. which LUCI project watch this
   727  		// CL) doesn't change, too.
   728  		b.lookupACfgResult = cl.ApplicableConfig
   729  
   730  		Convey("skips Fetch", func() {
   731  			Convey("happy path: everything is up to date", func() {
   732  				So(u.handleCL(ctx, task), ShouldBeNil)
   733  
   734  				cl2 := reloadCL(ctx, cl)
   735  				// Quick-fail if EVersion changes.
   736  				So(cl2.EVersion, ShouldEqual, cl.EVersion)
   737  				// Ensure nothing about the CL actually changed.
   738  				So(cl2, cvtesting.SafeShouldResemble, cl)
   739  
   740  				So(b.wasLookupApplicableConfigCalled(), ShouldBeTrue)
   741  				So(b.wasFetchCalled(), ShouldBeFalse)
   742  			})
   743  
   744  			Convey("special path: changed ApplicableConfig is saved", func() {
   745  				Convey("CL is not watched by any project", func(c C) {
   746  					b.lookupACfgResult = &ApplicableConfig{}
   747  				})
   748  				Convey("CL is watched by another project", func(c C) {
   749  					b.lookupACfgResult = &ApplicableConfig{Projects: []*ApplicableConfig_Project{
   750  						{
   751  							Name:           "other-project",
   752  							ConfigGroupIds: []string{"ohter-hash/other-name"},
   753  						},
   754  					}}
   755  				})
   756  				Convey("CL is additionally watched by another project", func(c C) {
   757  					b.lookupACfgResult.Projects = append(b.lookupACfgResult.Projects, &ApplicableConfig_Project{
   758  						Name:           "other-project",
   759  						ConfigGroupIds: []string{"ohter-hash/other-name"},
   760  					})
   761  				})
   762  				// Either way, fetch can be skipped & Snapshot can be preserved, but the
   763  				// ApplicableConfig must be updated.
   764  				So(u.handleCL(ctx, task), ShouldBeNil)
   765  				So(b.wasFetchCalled(), ShouldBeFalse)
   766  				cl2 := reloadCL(ctx, cl)
   767  				So(cl2.Snapshot, ShouldResembleProto, cl.Snapshot)
   768  				So(cl2.ApplicableConfig, ShouldResembleProto, b.lookupACfgResult)
   769  			})
   770  
   771  			Convey("meta_rev_id is the same", func() {
   772  				Convey("even if the CL entity is really old", func(c C) {
   773  					ct.Clock.Add(autoRefreshAfter + time.Minute)
   774  				})
   775  
   776  				cl.Snapshot.Kind = &Snapshot_Gerrit{Gerrit: &Gerrit{
   777  					Info: &gerrit.ChangeInfo{MetaRevId: "deadbeef"},
   778  				}}
   779  				So(datastore.Put(ctx, cl), ShouldBeNil)
   780  				task.Hint.MetaRevId = "deadbeef"
   781  				So(u.handleCL(ctx, task), ShouldBeNil)
   782  				So(b.wasFetchCalled(), ShouldBeFalse)
   783  			})
   784  		})
   785  
   786  		Convey("doesn't skip Fetch because ...", func() {
   787  			saveCLAndRun := func() {
   788  				cl.EVersion++
   789  				So(datastore.Put(ctx, cl), ShouldBeNil)
   790  				So(u.handleCL(ctx, task), ShouldBeNil)
   791  			}
   792  			Convey("no snapshot", func(c C) {
   793  				cl.Snapshot = nil
   794  				saveCLAndRun()
   795  				So(b.wasFetchCalled(), ShouldBeTrue)
   796  			})
   797  			Convey("snapshot marked outdated", func(c C) {
   798  				cl.Snapshot.Outdated = &Snapshot_Outdated{}
   799  				saveCLAndRun()
   800  				So(b.wasFetchCalled(), ShouldBeTrue)
   801  			})
   802  			Convey("snapshot is definitely old", func(c C) {
   803  				cl.Snapshot.ExternalUpdateTime.Seconds -= 3600
   804  				saveCLAndRun()
   805  				So(b.wasFetchCalled(), ShouldBeTrue)
   806  			})
   807  			Convey("snapshot might be old", func(c C) {
   808  				task.Hint = nil
   809  				saveCLAndRun()
   810  				So(b.wasFetchCalled(), ShouldBeTrue)
   811  			})
   812  			Convey("CL entity is really old", func(c C) {
   813  				ct.Clock.Add(autoRefreshAfter + time.Minute)
   814  				saveCLAndRun()
   815  				So(b.wasFetchCalled(), ShouldBeTrue)
   816  			})
   817  			Convey("snapshot is for a different project", func(c C) {
   818  				cl.Snapshot.LuciProject = "other"
   819  				saveCLAndRun()
   820  				So(b.wasFetchCalled(), ShouldBeTrue)
   821  			})
   822  			Convey("backend isn't sure about applicable config", func(c C) {
   823  				b.lookupACfgResult = nil
   824  				saveCLAndRun()
   825  				So(b.wasFetchCalled(), ShouldBeTrue)
   826  			})
   827  			Convey("CL entity has record of prior access restriction", func(c C) {
   828  				cl.Access = &Access{
   829  					ByProject: map[string]*Access_Project{
   830  						"luci-project": {
   831  							// In practice, actual fields are set, but they aren't important
   832  							// for this test.
   833  						},
   834  					},
   835  				}
   836  				saveCLAndRun()
   837  				So(b.wasFetchCalled(), ShouldBeTrue)
   838  			})
   839  			Convey("meta_rev_id is different", func() {
   840  				cl.Snapshot.Kind = &Snapshot_Gerrit{Gerrit: &Gerrit{
   841  					Info: &gerrit.ChangeInfo{MetaRevId: "deadbeef"},
   842  				}}
   843  				So(datastore.Put(ctx, cl), ShouldBeNil)
   844  				task.Hint.MetaRevId = "foo"
   845  				So(u.handleCL(ctx, task), ShouldBeNil)
   846  				So(b.wasFetchCalled(), ShouldBeTrue)
   847  			})
   848  		})
   849  
   850  		Convey("aborts before the Fetch because LookupApplicableConfig failed", func() {
   851  			b.lookupACfgError = errors.New("boo", transient.Tag)
   852  			err := u.handleCL(ctx, task)
   853  			So(err, ShouldErrLike, b.lookupACfgError)
   854  			So(b.wasFetchCalled(), ShouldBeFalse)
   855  		})
   856  	})
   857  }
   858  
   859  func TestUpdaterResolveAndScheduleDepsUpdate(t *testing.T) {
   860  	t.Parallel()
   861  
   862  	Convey("ResolveAndScheduleDepsUpdate correctly resolves deps", t, func() {
   863  		ct := cvtesting.Test{}
   864  		ctx, cancel := ct.SetUp(t)
   865  		defer cancel()
   866  
   867  		u := NewUpdater(ct.TQDispatcher, NewMutator(ct.TQDispatcher, &pmMock{}, &rmMock{}, &tjMock{}))
   868  
   869  		scheduledUpdates := func() (out []string) {
   870  			for _, p := range ct.TQ.Tasks().Payloads() {
   871  				if task, ok := p.(*UpdateCLTask); ok {
   872  					// Each scheduled task should have ID set, as it is known,
   873  					// to save on future lookup.
   874  					So(task.GetId(), ShouldNotEqual, 0)
   875  					e := task.GetExternalId()
   876  					// But also ExternalID, primarily for debugging.
   877  					So(e, ShouldNotBeEmpty)
   878  					out = append(out, e)
   879  				}
   880  			}
   881  			sort.Strings(out)
   882  			return out
   883  		}
   884  		eids := func(cls ...*CL) []string {
   885  			out := make([]string, len(cls))
   886  			for i, cl := range cls {
   887  				out[i] = string(cl.ExternalID)
   888  			}
   889  			sort.Strings(out)
   890  			return out
   891  		}
   892  
   893  		// Setup 4 existing CLs in various states.
   894  		const lProject = "luci-project"
   895  		// Various backend IDs are used here for test readability and debug-ability
   896  		// only. In practice, all deps likely come from the same backend.
   897  		var (
   898  			clBareBones           = ExternalID("bare-bones/10").MustCreateIfNotExists(ctx)
   899  			clUpToDate            = ExternalID("up-to-date/12").MustCreateIfNotExists(ctx)
   900  			clUpToDateDiffProject = ExternalID("up-to-date-diff-project/13").MustCreateIfNotExists(ctx)
   901  		)
   902  		clUpToDate.Snapshot = &Snapshot{
   903  			ExternalUpdateTime:    timestamppb.New(ct.Clock.Now()),
   904  			Patchset:              1,
   905  			MinEquivalentPatchset: 1,
   906  			LuciProject:           lProject,
   907  		}
   908  		clUpToDateDiffProject.Snapshot = proto.Clone(clUpToDate.Snapshot).(*Snapshot)
   909  		clUpToDateDiffProject.Snapshot.LuciProject = "other-project"
   910  		So(datastore.Put(ctx, clUpToDate, clUpToDateDiffProject), ShouldBeNil)
   911  
   912  		Convey("no deps", func() {
   913  			deps, err := u.ResolveAndScheduleDepsUpdate(ctx, lProject, nil, UpdateCLTask_RUN_POKE)
   914  			So(err, ShouldBeNil)
   915  			So(deps, ShouldBeEmpty)
   916  		})
   917  
   918  		Convey("only existing CLs", func() {
   919  			deps, err := u.ResolveAndScheduleDepsUpdate(ctx, lProject, map[ExternalID]DepKind{
   920  				clBareBones.ExternalID:           DepKind_SOFT,
   921  				clUpToDate.ExternalID:            DepKind_HARD,
   922  				clUpToDateDiffProject.ExternalID: DepKind_SOFT,
   923  			}, UpdateCLTask_RUN_POKE)
   924  			So(err, ShouldBeNil)
   925  			So(deps, ShouldResembleProto, sortDeps([]*Dep{
   926  				{Clid: int64(clBareBones.ID), Kind: DepKind_SOFT},
   927  				{Clid: int64(clUpToDate.ID), Kind: DepKind_HARD},
   928  				{Clid: int64(clUpToDateDiffProject.ID), Kind: DepKind_SOFT},
   929  			}))
   930  			// Update for the `clUpToDate` is not necessary.
   931  			So(scheduledUpdates(), ShouldResemble, eids(clBareBones, clUpToDateDiffProject))
   932  		})
   933  
   934  		Convey("only new CLs", func() {
   935  			deps, err := u.ResolveAndScheduleDepsUpdate(ctx, lProject, map[ExternalID]DepKind{
   936  				"new/1": DepKind_SOFT,
   937  				"new/2": DepKind_HARD,
   938  			}, UpdateCLTask_RUN_POKE)
   939  			So(err, ShouldBeNil)
   940  			cl1 := ExternalID("new/1").MustCreateIfNotExists(ctx)
   941  			cl2 := ExternalID("new/2").MustCreateIfNotExists(ctx)
   942  			So(deps, ShouldResembleProto, sortDeps([]*Dep{
   943  				{Clid: int64(cl1.ID), Kind: DepKind_SOFT},
   944  				{Clid: int64(cl2.ID), Kind: DepKind_HARD},
   945  			}))
   946  			So(scheduledUpdates(), ShouldResemble, eids(cl1, cl2))
   947  		})
   948  
   949  		Convey("mix old and new CLs", func() {
   950  			deps, err := u.ResolveAndScheduleDepsUpdate(ctx, lProject, map[ExternalID]DepKind{
   951  				"new/1":                DepKind_SOFT,
   952  				"new/2":                DepKind_HARD,
   953  				clBareBones.ExternalID: DepKind_HARD,
   954  				clUpToDate.ExternalID:  DepKind_SOFT,
   955  			}, UpdateCLTask_RUN_POKE)
   956  			So(err, ShouldBeNil)
   957  			cl1 := ExternalID("new/1").MustCreateIfNotExists(ctx)
   958  			cl2 := ExternalID("new/2").MustCreateIfNotExists(ctx)
   959  			So(deps, ShouldResembleProto, sortDeps([]*Dep{
   960  				{Clid: int64(cl1.ID), Kind: DepKind_SOFT},
   961  				{Clid: int64(cl2.ID), Kind: DepKind_HARD},
   962  				{Clid: int64(clBareBones.ID), Kind: DepKind_HARD},
   963  				{Clid: int64(clUpToDate.ID), Kind: DepKind_SOFT},
   964  			}))
   965  			// Update for the `clUpToDate` is not necessary.
   966  			So(scheduledUpdates(), ShouldResemble, eids(cl1, cl2, clBareBones))
   967  		})
   968  
   969  		Convey("high number of dependency CLs", func() {
   970  			const clCount = 1024
   971  			depCLMap := make(map[ExternalID]DepKind, clCount)
   972  			depCLs := make([]*CL, clCount)
   973  			for i := 0; i < clCount; i++ {
   974  				depCLs[i] = ExternalID(fmt.Sprintf("high-dep-cl/%04d", i)).MustCreateIfNotExists(ctx)
   975  				depCLMap[depCLs[i].ExternalID] = DepKind_HARD
   976  			}
   977  
   978  			deps, err := u.ResolveAndScheduleDepsUpdate(ctx, lProject, depCLMap, UpdateCLTask_RUN_POKE)
   979  			So(err, ShouldBeNil)
   980  			expectedDeps := make([]*Dep, clCount)
   981  			for i, depCL := range depCLs {
   982  				expectedDeps[i] = &Dep{
   983  					Clid: int64(depCL.ID),
   984  					Kind: DepKind_HARD,
   985  				}
   986  			}
   987  			So(deps, ShouldResembleProto, expectedDeps)
   988  			So(scheduledUpdates(), ShouldResemble, eids(depCLs...))
   989  		})
   990  	})
   991  }
   992  
   993  // fakeUpdaterBackend is a fake UpdaterBackend.
   994  //
   995  // It provides functionality which is a subset of what gomock would generate,
   996  // but with additional assertions to validate the contract between Updater and
   997  // its backend.
   998  type fakeUpdaterBackend struct {
   999  	tqErrorSpec common.TQIfy
  1000  
  1001  	// LookupApplicableConfig related fields:
  1002  
  1003  	lookupACfgCL     *CL // copy
  1004  	lookupACfgResult *ApplicableConfig
  1005  	lookupACfgError  error
  1006  
  1007  	// Fetch related fields:
  1008  	fetchCL          *CL // copy
  1009  	fetchProject     string
  1010  	fetchUpdatedHint time.Time
  1011  	fetchResult      UpdateFields
  1012  	fetchError       error
  1013  
  1014  	backendSnapshotUpdated bool
  1015  }
  1016  
  1017  func (f *fakeUpdaterBackend) Kind() string {
  1018  	return "fake"
  1019  }
  1020  
  1021  func (f *fakeUpdaterBackend) TQErrorSpec() common.TQIfy {
  1022  	return f.tqErrorSpec
  1023  }
  1024  
  1025  func (f *fakeUpdaterBackend) wasLookupApplicableConfigCalled() bool {
  1026  	return f.lookupACfgCL != nil
  1027  }
  1028  
  1029  func (f *fakeUpdaterBackend) LookupApplicableConfig(ctx context.Context, saved *CL) (*ApplicableConfig, error) {
  1030  	So(f.wasLookupApplicableConfigCalled(), ShouldBeFalse)
  1031  
  1032  	// Check contract with a backend:
  1033  	So(saved, ShouldNotBeNil)
  1034  	So(saved.ID, ShouldNotEqual, 0)
  1035  	So(saved.ExternalID, ShouldNotBeEmpty)
  1036  	So(saved.Snapshot, ShouldNotBeNil)
  1037  
  1038  	// Shallow-copy to catch some mistakes in test.
  1039  	f.lookupACfgCL = &CL{}
  1040  	*f.lookupACfgCL = *saved
  1041  	return f.lookupACfgResult, f.lookupACfgError
  1042  }
  1043  
  1044  func (f *fakeUpdaterBackend) wasFetchCalled() bool {
  1045  	return f.fetchCL != nil
  1046  }
  1047  
  1048  func (f *fakeUpdaterBackend) Fetch(ctx context.Context, in *FetchInput) (UpdateFields, error) {
  1049  	So(f.wasFetchCalled(), ShouldBeFalse)
  1050  
  1051  	// Check contract with a backend:
  1052  	So(in.CL, ShouldNotBeNil)
  1053  	So(in.CL.ExternalID, ShouldNotBeEmpty)
  1054  
  1055  	// Shallow-copy to catch some mistakes in test.
  1056  	f.fetchCL = &CL{}
  1057  	*f.fetchCL = *in.CL
  1058  	f.fetchProject = in.Project
  1059  	f.fetchUpdatedHint = in.UpdatedHint
  1060  	return f.fetchResult, f.fetchError
  1061  }
  1062  
  1063  func (f *fakeUpdaterBackend) HasChanged(cvCurrent, backendCurrent *Snapshot) bool {
  1064  	switch {
  1065  	case backendCurrent == nil:
  1066  		panic("impossible. Backend must have a non-nil snapshot")
  1067  	case cvCurrent == nil:
  1068  		return true
  1069  	case backendCurrent.GetExternalUpdateTime().AsTime().After(cvCurrent.GetExternalUpdateTime().AsTime()):
  1070  		return true
  1071  	case backendCurrent.GetPatchset() > cvCurrent.GetPatchset():
  1072  		return true
  1073  	case f.backendSnapshotUpdated:
  1074  		return true
  1075  	default:
  1076  		return false
  1077  	}
  1078  }
  1079  
  1080  // reset resets the fake for the next use.
  1081  func (f *fakeUpdaterBackend) reset() {
  1082  	*f = fakeUpdaterBackend{}
  1083  }
  1084  
  1085  // reloadCL loads a new CL from Datastore.
  1086  //
  1087  // Doesn't re-use the object.
  1088  func reloadCL(ctx context.Context, cl *CL) *CL {
  1089  	ret := &CL{ID: cl.ID}
  1090  	if err := datastore.Get(ctx, ret); err != nil {
  1091  		panic(err)
  1092  	}
  1093  	return ret
  1094  }