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

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package state
    16  
    17  import (
    18  	"strings"
    19  	"testing"
    20  	"time"
    21  
    22  	"google.golang.org/protobuf/encoding/prototext"
    23  	"google.golang.org/protobuf/types/known/timestamppb"
    24  
    25  	"go.chromium.org/luci/common/logging"
    26  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    27  	"go.chromium.org/luci/gae/service/datastore"
    28  
    29  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    30  	"go.chromium.org/luci/cv/internal/changelist"
    31  	"go.chromium.org/luci/cv/internal/common"
    32  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    33  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    34  	"go.chromium.org/luci/cv/internal/gerrit/gobmap/gobmaptest"
    35  	"go.chromium.org/luci/cv/internal/gerrit/trigger"
    36  	"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
    37  	"go.chromium.org/luci/cv/internal/run"
    38  
    39  	. "github.com/smartystreets/goconvey/convey"
    40  	. "go.chromium.org/luci/common/testing/assertions"
    41  )
    42  
    43  func TestCategorizeAndLoadActiveIntoPCLs(t *testing.T) {
    44  	t.Parallel()
    45  
    46  	Convey("loadActiveIntoPCLs and categorizeCLs work", t, func() {
    47  		ct := ctest{
    48  			lProject: "test",
    49  			gHost:    "c-review.example.com",
    50  		}
    51  		ctx, cancel := ct.SetUp(t)
    52  		defer cancel()
    53  
    54  		cfg := &cfgpb.Config{}
    55  		So(prototext.Unmarshal([]byte(cfgText1), cfg), ShouldBeNil)
    56  		prjcfgtest.Create(ctx, ct.lProject, cfg)
    57  		meta := prjcfgtest.MustExist(ctx, ct.lProject)
    58  		gobmaptest.Update(ctx, ct.lProject)
    59  
    60  		// Simulate existence of "test-b" project watching the same Gerrit host but
    61  		// diff repo.
    62  		const lProjectB = "test-b"
    63  		cfgTextB := strings.ReplaceAll(cfgText1, "repo/a", "repo/b")
    64  		cfgB := &cfgpb.Config{}
    65  		So(prototext.Unmarshal([]byte(cfgTextB), cfgB), ShouldBeNil)
    66  		prjcfgtest.Create(ctx, lProjectB, cfgB)
    67  		gobmaptest.Update(ctx, lProjectB)
    68  
    69  		cis := make(map[int]*gerritpb.ChangeInfo, 20)
    70  		makeCI := func(i int, project string, cq int, extra ...gf.CIModifier) {
    71  			mods := []gf.CIModifier{
    72  				gf.Ref("refs/heads/main"),
    73  				gf.Project(project),
    74  				gf.Updated(ct.Clock.Now()),
    75  			}
    76  			if cq > 0 {
    77  				mods = append(mods, gf.CQ(cq, ct.Clock.Now(), gf.U("user-1")))
    78  			}
    79  			mods = append(mods, extra...)
    80  			cis[i] = gf.CI(i, mods...)
    81  			ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: cis[i]})
    82  		}
    83  		makeStack := func(ids []int, project string, cq int) {
    84  			for i, child := range ids {
    85  				makeCI(child, project, cq)
    86  				for _, parent := range ids[:i] {
    87  					ct.GFake.SetDependsOn(ct.gHost, cis[child], cis[parent])
    88  				}
    89  			}
    90  		}
    91  		// Simulate the following CLs state in Gerrit:
    92  		//   In this project:
    93  		//     CQ+1
    94  		//       1 <- 2       form a stack (2 depends on 1)
    95  		//       3            depends on 2 via Cq-Depend.
    96  		//     CQ+2
    97  		//       4            standalone
    98  		//       5 <- 6       form a stack (6 depends on 5)
    99  		//       7 <- 8 <- 9  form a stack (9 depends on 7,8)
   100  		//       13           CQ-Depend on 11 (diff project) and 12 (not existing).
   101  		//   In another project:
   102  		//     CQ+1
   103  		//       10 <- 11     form a stack (11 depends on 10)
   104  		makeStack([]int{1, 2}, "repo/a", +1)
   105  		makeCI(3, "repo/a", +1, gf.Desc("T\n\nCq-Depend: 2"))
   106  		makeStack([]int{7, 8, 9}, "repo/a", +2)
   107  		makeStack([]int{5, 6}, "repo/a", +2)
   108  		makeCI(4, "repo/a", +2)
   109  		makeCI(13, "repo/a", +2, gf.Desc("T\n\nCq-Depend: 11,12"))
   110  		makeStack([]int{10, 11}, "repo/b", +1)
   111  
   112  		// Import into DS all CLs in their respective LUCI projects.
   113  		// Do this in-order such that they match auto-assigned CLIDs by fake
   114  		// Datastore as this helps test readability. Note that importing CL 13 would
   115  		// create CL entity for dep #12 before creating CL 13th own entity.
   116  		cls := make(map[int]*changelist.CL, 20)
   117  		for i := 1; i < 14; i++ {
   118  			if i == 12 {
   119  				continue // skipped. will be done after 13
   120  			}
   121  			pr := ct.lProject
   122  			if i == 10 || i == 11 {
   123  				pr = lProjectB
   124  			}
   125  			cls[i] = ct.runCLUpdaterAs(ctx, int64(i), pr)
   126  		}
   127  		// This will get 404 from Gerrit.
   128  		cls[12] = ct.runCLUpdater(ctx, 12)
   129  
   130  		for i := 1; i < 14; i++ {
   131  			// On in-memory DS fake, auto-generated IDs are 1,2, ...,
   132  			// so by construction the following would hold:
   133  			//   cls[i].ID == i
   134  			// On real DS, emit mapping to assist in test debug.
   135  			if cls[i].ID != common.CLID(i) {
   136  				logging.Debugf(ctx, "cls[%d].ID = %d", i, cls[i].ID)
   137  			}
   138  		}
   139  
   140  		run4 := &run.Run{
   141  			ID:  common.RunID(ct.lProject + "/1-a"),
   142  			CLs: common.CLIDs{cls[4].ID},
   143  		}
   144  		run56 := &run.Run{
   145  			ID:  common.RunID(ct.lProject + "/56-bb"),
   146  			CLs: common.CLIDs{cls[5].ID, cls[6].ID},
   147  		}
   148  		run789 := &run.Run{
   149  			ID:  common.RunID(ct.lProject + "/789-ccc"),
   150  			CLs: common.CLIDs{cls[9].ID, cls[7].ID, cls[8].ID},
   151  		}
   152  		So(datastore.Put(ctx, run4, run56, run789), ShouldBeNil)
   153  
   154  		state := &State{PB: &prjpb.PState{
   155  			LuciProject:         ct.lProject,
   156  			Status:              prjpb.Status_STARTED,
   157  			ConfigHash:          meta.Hash(),
   158  			ConfigGroupNames:    []string{"g0", "g1"},
   159  			RepartitionRequired: true,
   160  		}}
   161  
   162  		Convey("just categorization", func() {
   163  			state.PB.Pcls = sortPCLs([]*prjpb.PCL{
   164  				defaultPCL(cls[5]),
   165  				defaultPCL(cls[6]),
   166  				defaultPCL(cls[7]),
   167  				defaultPCL(cls[8]),
   168  				defaultPCL(cls[9]),
   169  				{Clid: int64(cls[12].ID), Eversion: 1, Status: prjpb.PCL_UNKNOWN},
   170  			})
   171  			state.PB.Components = []*prjpb.Component{
   172  				{
   173  					Clids: i64sorted(cls[5].ID, cls[6].ID),
   174  					Pruns: []*prjpb.PRun{prjpb.MakePRun(run56)},
   175  				},
   176  				// Simulate 9 previously not depending on 7, 8.
   177  				{Clids: i64sorted(cls[7].ID, cls[8].ID)},
   178  				{Clids: i64s(cls[9].ID)},
   179  			}
   180  			// 789 doesn't match any 1 component, even though 7,8,9 CLs are in PCLs.
   181  			state.PB.CreatedPruns = []*prjpb.PRun{prjpb.MakePRun(run789)}
   182  			pbBefore := backupPB(state)
   183  
   184  			cat := state.categorizeCLs(ctx)
   185  			So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   186  			So(cat, ShouldResemble, &categorizedCLs{
   187  				active:   mkClidsSet(cls, 5, 6, 7, 8, 9),
   188  				deps:     common.CLIDsSet{},
   189  				unused:   mkClidsSet(cls, 12),
   190  				unloaded: common.CLIDsSet{},
   191  			})
   192  			So(state.PB, ShouldResembleProto, pbBefore)
   193  		})
   194  
   195  		Convey("loads unloaded dependencies and active CLs without recursion", func() {
   196  			state.PB.Pcls = []*prjpb.PCL{
   197  				defaultPCL(cls[3]), // depends on 2, which in turns depends on 1.
   198  			}
   199  			state.PB.CreatedPruns = []*prjpb.PRun{prjpb.MakePRun(run56)}
   200  			pb := backupPB(state)
   201  
   202  			cat := state.categorizeCLs(ctx)
   203  			So(cat, ShouldResemble, &categorizedCLs{
   204  				active:   mkClidsSet(cls, 3, 5, 6),
   205  				deps:     mkClidsSet(cls, 2),
   206  				unused:   common.CLIDsSet{},
   207  				unloaded: mkClidsSet(cls, 2, 5, 6),
   208  			})
   209  			So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   210  			So(cat, ShouldResemble, &categorizedCLs{
   211  				active:   mkClidsSet(cls, 3, 2, 5, 6),
   212  				deps:     mkClidsSet(cls, 1),
   213  				unused:   common.CLIDsSet{},
   214  				unloaded: mkClidsSet(cls, 1),
   215  			})
   216  			pb.Pcls = sortPCLs([]*prjpb.PCL{
   217  				defaultPCL(cls[2]),
   218  				defaultPCL(cls[3]),
   219  				defaultPCL(cls[5]),
   220  				defaultPCL(cls[6]),
   221  			})
   222  			So(state.PB, ShouldResembleProto, pb)
   223  		})
   224  
   225  		Convey("loads incomplete Run with unloaded deps", func() {
   226  			// This case shouldn't normally happen in practice. This case simulates a
   227  			// runStale created a while ago of just (11, 13), presumably when current
   228  			// project had CL #11 in scope.
   229  			// Now, 11 and 13 depend on 10 and 12, respectively, and 10 and 11 are no
   230  			// longer watched by current project.
   231  			runStale := &run.Run{
   232  				ID:  common.RunID(ct.lProject + "/111-s"),
   233  				CLs: common.CLIDs{cls[13].ID, cls[11].ID},
   234  			}
   235  			So(datastore.Put(ctx, runStale), ShouldBeNil)
   236  			state.PB.CreatedPruns = []*prjpb.PRun{prjpb.MakePRun(runStale)}
   237  			pb := backupPB(state)
   238  
   239  			cat := state.categorizeCLs(ctx)
   240  			So(cat, ShouldResemble, &categorizedCLs{
   241  				active:   mkClidsSet(cls, 11, 13),
   242  				deps:     common.CLIDsSet{},
   243  				unused:   common.CLIDsSet{},
   244  				unloaded: mkClidsSet(cls, 11, 13),
   245  			})
   246  			So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   247  			So(cat, ShouldResemble, &categorizedCLs{
   248  				active: mkClidsSet(cls, 11, 13),
   249  				// 10 isn't in deps because this project has no visibility into CL 11.
   250  				deps:     mkClidsSet(cls, 12),
   251  				unused:   common.CLIDsSet{},
   252  				unloaded: mkClidsSet(cls, 12),
   253  			})
   254  			pb.Pcls = sortPCLs([]*prjpb.PCL{
   255  				defaultPCL(cls[13]),
   256  				{
   257  					Clid:     int64(cls[11].ID),
   258  					Eversion: cls[11].EVersion,
   259  					Status:   prjpb.PCL_UNWATCHED,
   260  					Deps:     nil, // not visible to this project
   261  				},
   262  			})
   263  			So(state.PB, ShouldResembleProto, pb)
   264  		})
   265  
   266  		Convey("loads incomplete Run with non-existent CLs", func() {
   267  			// This case shouldn't happen in practice, but it can't be ruled out.
   268  			// In order to incorporate just added .CreatedRun into State,
   269  			// Run's CLs must have PCL entries.
   270  			runStale := &run.Run{
   271  				ID:  common.RunID(ct.lProject + "/404-s"),
   272  				CLs: common.CLIDs{cls[4].ID, 404},
   273  			}
   274  			So(datastore.Put(ctx, runStale), ShouldBeNil)
   275  			state.PB.CreatedPruns = []*prjpb.PRun{prjpb.MakePRun(runStale)}
   276  			pb := backupPB(state)
   277  
   278  			cat := state.categorizeCLs(ctx)
   279  			So(cat, ShouldResemble, &categorizedCLs{
   280  				active:   common.CLIDsSet{cls[4].ID: struct{}{}, 404: struct{}{}},
   281  				deps:     common.CLIDsSet{},
   282  				unused:   common.CLIDsSet{},
   283  				unloaded: common.CLIDsSet{cls[4].ID: struct{}{}, 404: struct{}{}},
   284  			})
   285  			So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   286  			So(cat, ShouldResemble, &categorizedCLs{
   287  				active:   common.CLIDsSet{cls[4].ID: struct{}{}, 404: struct{}{}},
   288  				deps:     common.CLIDsSet{},
   289  				unused:   common.CLIDsSet{},
   290  				unloaded: common.CLIDsSet{},
   291  			})
   292  			pb.Pcls = sortPCLs([]*prjpb.PCL{
   293  				defaultPCL(cls[4]),
   294  				{
   295  					Clid:     404,
   296  					Eversion: 0,
   297  					Status:   prjpb.PCL_DELETED,
   298  				},
   299  			})
   300  			So(state.PB, ShouldResembleProto, pb)
   301  		})
   302  
   303  		Convey("identifies submitted PCLs as unused if possible", func() {
   304  			// Modify 1<-2 stack to have #1 submitted.
   305  			ct.Clock.Add(time.Minute)
   306  			cls[1] = ct.submitCL(ctx, 1)
   307  			cis[1] = cls[1].Snapshot.GetGerrit().GetInfo()
   308  			So(cis[1].Status, ShouldEqual, gerritpb.ChangeStatus_MERGED)
   309  
   310  			state.PB.Pcls = []*prjpb.PCL{
   311  				{
   312  					Clid:      int64(cls[1].ID),
   313  					Eversion:  cls[1].EVersion,
   314  					Status:    prjpb.PCL_OK,
   315  					Submitted: true,
   316  				},
   317  			}
   318  			Convey("standalone submitted CL without a Run is unused", func() {
   319  				cat := state.categorizeCLs(ctx)
   320  				exp := &categorizedCLs{
   321  					active:   common.CLIDsSet{},
   322  					deps:     common.CLIDsSet{},
   323  					unloaded: common.CLIDsSet{},
   324  					unused:   mkClidsSet(cls, 1),
   325  				}
   326  				So(cat, ShouldResemble, exp)
   327  				So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   328  				So(cat, ShouldResemble, exp)
   329  			})
   330  
   331  			Convey("standalone submitted CL with a Run is active", func() {
   332  				state.PB.Components = []*prjpb.Component{
   333  					{
   334  						Clids: i64s(cls[1].ID),
   335  						Pruns: []*prjpb.PRun{
   336  							{Clids: i64s(cls[1].ID), Id: "run1"},
   337  						},
   338  					},
   339  				}
   340  				cat := state.categorizeCLs(ctx)
   341  				exp := &categorizedCLs{
   342  					active:   mkClidsSet(cls, 1),
   343  					deps:     common.CLIDsSet{},
   344  					unloaded: common.CLIDsSet{},
   345  					unused:   common.CLIDsSet{},
   346  				}
   347  				So(cat, ShouldResemble, exp)
   348  				So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   349  				So(cat, ShouldResemble, exp)
   350  			})
   351  
   352  			Convey("submitted dependent is neither active nor unused, but a dep", func() {
   353  				triggers := trigger.Find(&trigger.FindInput{ChangeInfo: cis[2], ConfigGroup: cfg.ConfigGroups[0]})
   354  				So(triggers.GetCqVoteTrigger(), ShouldNotBeNil)
   355  				state.PB.Pcls = sortPCLs(append(state.PB.Pcls,
   356  					&prjpb.PCL{
   357  						Clid:               int64(cls[2].ID),
   358  						Eversion:           cls[2].EVersion,
   359  						Status:             prjpb.PCL_OK,
   360  						Triggers:           triggers,
   361  						ConfigGroupIndexes: []int32{0},
   362  						Deps:               cls[2].Snapshot.GetDeps(),
   363  					},
   364  				))
   365  				cat := state.categorizeCLs(ctx)
   366  				exp := &categorizedCLs{
   367  					active:   mkClidsSet(cls, 2),
   368  					deps:     mkClidsSet(cls, 1),
   369  					unloaded: common.CLIDsSet{},
   370  					unused:   common.CLIDsSet{},
   371  				}
   372  				So(cat, ShouldResemble, exp)
   373  				So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   374  				So(cat, ShouldResemble, exp)
   375  			})
   376  		})
   377  
   378  		Convey("prunes PCLs with expired triggers", func() {
   379  			makePCL := func(i int, t time.Time, deps ...*changelist.Dep) *prjpb.PCL {
   380  				return &prjpb.PCL{
   381  					Clid:     int64(cls[i].ID),
   382  					Eversion: 1,
   383  					Status:   prjpb.PCL_OK,
   384  					Deps:     deps,
   385  					Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   386  						GerritAccountId: 1,
   387  						Mode:            string(run.DryRun),
   388  						Time:            timestamppb.New(t),
   389  					}},
   390  				}
   391  			}
   392  			state.PB.Pcls = []*prjpb.PCL{
   393  				makePCL(1, ct.Clock.Now().Add(-time.Minute), &changelist.Dep{Clid: int64(cls[4].ID)}),
   394  				makePCL(2, ct.Clock.Now().Add(-common.MaxTriggerAge+time.Second)),
   395  				makePCL(3, ct.Clock.Now().Add(-common.MaxTriggerAge)),
   396  			}
   397  			cat := state.categorizeCLs(ctx)
   398  			So(cat, ShouldResemble, &categorizedCLs{
   399  				active:   mkClidsSet(cls, 1, 2),
   400  				deps:     mkClidsSet(cls, 4),
   401  				unloaded: mkClidsSet(cls, 4),
   402  				unused:   mkClidsSet(cls, 3),
   403  			})
   404  
   405  			Convey("and doesn't promote unloaded to active if trigger has expired", func() {
   406  				// Keep CQ+2 vote, but make it timestamp really old.
   407  				infoRef := cls[4].Snapshot.GetGerrit().GetInfo()
   408  				infoRef.Labels = nil
   409  				gf.CQ(2, ct.Clock.Now().Add(-common.MaxTriggerAge), gf.U("user-1"))(infoRef)
   410  				So(datastore.Put(ctx, cls[4]), ShouldBeNil)
   411  
   412  				So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   413  				So(cat, ShouldResemble, &categorizedCLs{
   414  					active:   mkClidsSet(cls, 1, 2),
   415  					deps:     mkClidsSet(cls, 4),
   416  					unloaded: common.CLIDsSet{},
   417  					unused:   mkClidsSet(cls, 3),
   418  				})
   419  			})
   420  		})
   421  
   422  		Convey("noop", func() {
   423  			cat := state.categorizeCLs(ctx)
   424  			So(state.loadActiveIntoPCLs(ctx, cat), ShouldBeNil)
   425  			So(cat, ShouldResemble, &categorizedCLs{
   426  				active:   common.CLIDsSet{},
   427  				deps:     common.CLIDsSet{},
   428  				unused:   common.CLIDsSet{},
   429  				unloaded: common.CLIDsSet{},
   430  			})
   431  		})
   432  	})
   433  }