go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/gobmap/map_test.go (about)

     1  // Copyright 2020 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 gobmap
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"math/rand"
    21  	"strings"
    22  	"testing"
    23  
    24  	"go.chromium.org/luci/common/data/rand/mathrand"
    25  	"go.chromium.org/luci/common/data/stringset"
    26  	"go.chromium.org/luci/common/logging"
    27  	"go.chromium.org/luci/common/logging/gologger"
    28  	"go.chromium.org/luci/gae/filter/featureBreaker"
    29  	"go.chromium.org/luci/gae/filter/featureBreaker/flaky"
    30  	"go.chromium.org/luci/gae/impl/memory"
    31  	"go.chromium.org/luci/gae/service/datastore"
    32  	"golang.org/x/sync/errgroup"
    33  
    34  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    35  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    36  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    37  	"go.chromium.org/luci/cv/internal/configs/srvcfg"
    38  	"go.chromium.org/luci/cv/internal/cvtesting"
    39  	listenerpb "go.chromium.org/luci/cv/settings/listener"
    40  
    41  	. "github.com/smartystreets/goconvey/convey"
    42  )
    43  
    44  func TestGobMapUpdateAndLookup(t *testing.T) {
    45  	t.Parallel()
    46  
    47  	// TODO(yiwzhang): use cvtesting.Test{}, instead.
    48  	ctx := memory.Use(context.Background())
    49  	datastore.GetTestable(ctx).AutoIndex(true)
    50  	datastore.GetTestable(ctx).Consistent(true)
    51  
    52  	if err := srvcfg.SetTestListenerConfig(ctx, &listenerpb.Settings{}, nil); err != nil {
    53  		panic(err)
    54  	}
    55  	if testing.Verbose() {
    56  		ctx = logging.SetLevel(gologger.StdConfig.Use(ctx), logging.Debug)
    57  	}
    58  
    59  	// First set up an example project with two config groups to show basic
    60  	// regular usage; there is a "main" group which matches a main ref, and
    61  	// another fallback group that matches many other refs, but not all.
    62  	prjcfgtest.Create(ctx, "chromium", &cfgpb.Config{
    63  		ConfigGroups: []*cfgpb.ConfigGroup{
    64  			{
    65  				Name: "group_main",
    66  				Gerrit: []*cfgpb.ConfigGroup_Gerrit{
    67  					{
    68  						Url: "https://cr-review.gs.com/",
    69  						Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
    70  							{
    71  								Name:      "cr/src",
    72  								RefRegexp: []string{"refs/heads/main"},
    73  							},
    74  						},
    75  					},
    76  				},
    77  			},
    78  			{
    79  				// This is the fallback group, so "refs/heads/main" should be
    80  				// handled by the main group but not this one, even though it
    81  				// matches the include regexp list.
    82  				Name:     "group_other",
    83  				Fallback: cfgpb.Toggle_YES,
    84  				Gerrit: []*cfgpb.ConfigGroup_Gerrit{
    85  					{
    86  						Url: "https://cr-review.gs.com/",
    87  						Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
    88  							{
    89  								Name:             "cr/src",
    90  								RefRegexp:        []string{"refs/heads/.*"},
    91  								RefRegexpExclude: []string{"refs/heads/123"},
    92  							},
    93  						},
    94  					},
    95  				},
    96  			},
    97  		},
    98  	})
    99  
   100  	update := func(lProject string) error {
   101  		meta := prjcfgtest.MustExist(ctx, lProject)
   102  		cgs, err := meta.GetConfigGroups(ctx)
   103  		if err != nil {
   104  			panic(err)
   105  		}
   106  		return Update(ctx, &meta, cgs)
   107  	}
   108  
   109  	Convey("Update with nonexistent project stores nothing", t, func() {
   110  		So(Update(ctx, &prjcfg.Meta{Project: "bogus", Status: prjcfg.StatusNotExists}, nil), ShouldBeNil)
   111  		mps := []*mapPart{}
   112  		q := datastore.NewQuery(mapKind)
   113  		So(datastore.GetAll(ctx, q, &mps), ShouldBeNil)
   114  		So(mps, ShouldBeEmpty)
   115  	})
   116  
   117  	Convey("Lookup nonexistent project returns empty result", t, func() {
   118  		So(
   119  			lookup(ctx, "foo-review.gs.com", "repo", "refs/heads/main"),
   120  			ShouldBeEmpty)
   121  	})
   122  
   123  	Convey("Basic behavior with one project", t, func() {
   124  		So(update("chromium"), ShouldBeNil)
   125  
   126  		Convey("Lookup with main ref returns main group", func() {
   127  			// Note that even though the other config group also matches,
   128  			// only the main config group is applicable since the other one
   129  			// is the fallback config group.
   130  			So(
   131  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/main"),
   132  				ShouldResemble,
   133  				map[string][]string{
   134  					"chromium": {"group_main"},
   135  				})
   136  		})
   137  
   138  		Convey("Lookup with other ref returns other group", func() {
   139  			// refs/heads/something matches other group, but not main group.
   140  			So(
   141  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/something"),
   142  				ShouldResemble,
   143  				map[string][]string{
   144  					"chromium": {"group_other"},
   145  				})
   146  		})
   147  
   148  		Convey("Lookup excluded ref returns nothing", func() {
   149  			// refs/heads/123 is specifically excluded from the "other" group,
   150  			// and also not included in main group.
   151  			So(
   152  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/123"),
   153  				ShouldBeEmpty)
   154  		})
   155  
   156  		Convey("For a ref with no matching groups the result is empty", func() {
   157  			// If a ref doesn't match any include patterns then no groups
   158  			// match.
   159  			So(
   160  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/branch-heads/beta"),
   161  				ShouldBeEmpty)
   162  		})
   163  
   164  		Convey("LookupProjects with the matched repo", func() {
   165  			prjs, err := LookupProjects(ctx, "cr-review.gs.com", "cr/src")
   166  			So(err, ShouldBeNil)
   167  			So(prjs, ShouldResemble, []string{"chromium"})
   168  		})
   169  
   170  		Convey("LookupProjects with an unmated repo", func() {
   171  			prjs, err := LookupProjects(ctx, "cr-review.gs.com", "cr2/src")
   172  			So(err, ShouldBeNil)
   173  			So(prjs, ShouldBeEmpty)
   174  		})
   175  	})
   176  
   177  	Convey("Lookup again returns nothing for disabled project", t, func() {
   178  		// Simulate deleting project. Projects that are deleted are first disabled
   179  		// in practice.
   180  		prjcfgtest.Disable(ctx, "chromium")
   181  		So(update("chromium"), ShouldBeNil)
   182  		So(lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/main"), ShouldBeEmpty)
   183  	})
   184  
   185  	Convey("With two matches and no fallback...", t, func() {
   186  		// Simulate the project being updated so that the "other" group is no
   187  		// longer a fallback group. Now some refs will match both groups.
   188  		prjcfgtest.Enable(ctx, "chromium")
   189  		prjcfgtest.Update(ctx, "chromium", &cfgpb.Config{
   190  			ConfigGroups: []*cfgpb.ConfigGroup{
   191  				{
   192  					Name: "group_main",
   193  					Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   194  						{
   195  							Url: "https://cr-review.gs.com/",
   196  							Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
   197  								{
   198  									Name:      "cr/src",
   199  									RefRegexp: []string{"refs/heads/main"},
   200  								},
   201  							},
   202  						},
   203  					},
   204  				},
   205  				{
   206  					Name: "group_other",
   207  					Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   208  						{
   209  							Url: "https://cr-review.gs.com/",
   210  							Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
   211  								{
   212  									Name:             "cr/src",
   213  									RefRegexp:        []string{"refs/heads/.*"},
   214  									RefRegexpExclude: []string{"refs/heads/123"},
   215  								},
   216  							},
   217  						},
   218  					},
   219  					Fallback: cfgpb.Toggle_NO,
   220  				},
   221  			},
   222  		})
   223  
   224  		Convey("Lookup main ref matching two refs", func() {
   225  			// This adds coverage for matching two groups.
   226  			So(update("chromium"), ShouldBeNil)
   227  			So(
   228  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/main"),
   229  				ShouldResemble,
   230  				map[string][]string{"chromium": {"group_main", "group_other"}})
   231  		})
   232  	})
   233  
   234  	Convey("With two repos in main group and no other group...", t, func() {
   235  		// This update includes both additions and removals,
   236  		// and also tests multiple hosts.
   237  		prjcfgtest.Update(ctx, "chromium", &cfgpb.Config{
   238  			ConfigGroups: []*cfgpb.ConfigGroup{
   239  				{
   240  					Name: "group_main",
   241  					Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   242  						{
   243  							Url: "https://cr-review.gs.com/",
   244  							Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
   245  								{
   246  									Name:      "cr/src",
   247  									RefRegexp: []string{"refs/heads/main"},
   248  								},
   249  							},
   250  						},
   251  						{
   252  							Url: "https://cr2-review.gs.com/",
   253  							Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
   254  								{
   255  									Name:      "cr2/src",
   256  									RefRegexp: []string{"refs/heads/main"},
   257  								},
   258  							},
   259  						},
   260  					},
   261  				},
   262  			},
   263  		})
   264  		So(update("chromium"), ShouldBeNil)
   265  
   266  		Convey("main group matches two different hosts", func() {
   267  
   268  			So(
   269  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/main"),
   270  				ShouldResemble,
   271  				map[string][]string{"chromium": {"group_main"}})
   272  			So(
   273  				lookup(ctx, "cr2-review.gs.com", "cr2/src", "refs/heads/main"),
   274  				ShouldResemble,
   275  				map[string][]string{"chromium": {"group_main"}})
   276  		})
   277  
   278  		Convey("other group no longer exists", func() {
   279  			So(
   280  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/something"),
   281  				ShouldBeEmpty)
   282  		})
   283  	})
   284  
   285  	Convey("With another project matching the same ref...", t, func() {
   286  		// Below another project is created that watches the same repo and ref.
   287  		// This tests multiple projects matching for one Lookup.
   288  		prjcfgtest.Create(ctx, "foo", &cfgpb.Config{
   289  			ConfigGroups: []*cfgpb.ConfigGroup{
   290  				{
   291  					Name: "group_foo",
   292  					Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   293  						{
   294  							Url: "https://cr-review.gs.com/",
   295  							Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
   296  								{
   297  									Name:      "cr/src",
   298  									RefRegexp: []string{"refs/heads/main"},
   299  								},
   300  							},
   301  						},
   302  					},
   303  				},
   304  			},
   305  		})
   306  		So(update("foo"), ShouldBeNil)
   307  
   308  		Convey("main group matches two different projects", func() {
   309  			So(
   310  				lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/main"),
   311  				ShouldResemble,
   312  				map[string][]string{
   313  					"chromium": {"group_main"},
   314  					"foo":      {"group_foo"},
   315  				})
   316  		})
   317  	})
   318  
   319  	Convey("Lookup again after correcting the config mistake by deleting the second project", t, func() {
   320  		prjcfgtest.Delete(ctx, "foo")
   321  		meta, err := prjcfg.GetLatestMeta(ctx, "foo")
   322  		So(err, ShouldBeNil)
   323  		So(Update(ctx, &meta, nil), ShouldBeNil)
   324  		So(
   325  			lookup(ctx, "cr-review.gs.com", "cr/src", "refs/heads/main"),
   326  			ShouldResemble,
   327  			map[string][]string{
   328  				"chromium": {"group_main"},
   329  			})
   330  	})
   331  }
   332  
   333  func TestGobMapConcurrentUpdates(t *testing.T) {
   334  	t.Parallel()
   335  
   336  	Convey("Update() works under flaky Datastore and lots of concurrent tries", t, func() {
   337  		ct := cvtesting.Test{}
   338  		ctx, cancel := ct.SetUp(t)
   339  		defer cancel()
   340  
   341  		const (
   342  			projects         = 2
   343  			versions         = 20
   344  			repos            = 20
   345  			repoPresenceProb = 0.05
   346  			workers          = 10
   347  			taskRedundancy   = 3 // # of workers doing the same Update() task.
   348  		)
   349  
   350  		const (
   351  			gHost = "cr-review.gs.com"
   352  			gRef  = "refs/heads/main"
   353  		)
   354  		// Each LUCI projects gets the same number of config versions.
   355  		// Each version has a random non-empty subset of repos (Gerrit projects).
   356  		var tasks []struct {
   357  			meta prjcfg.Meta
   358  			cgs  []*prjcfg.ConfigGroup
   359  		}
   360  		for v := 1; v <= versions; v++ {
   361  			for lp := 1; lp <= projects; lp++ {
   362  				lProject := fmt.Sprintf("project-%d", lp)
   363  				var gerritProjects []*cfgpb.ConfigGroup_Gerrit_Project
   364  				for i := 1; i <= repos; i++ {
   365  					if mathrand.Float32(ctx) <= repoPresenceProb || (len(gerritProjects) == 0 && i == repos) {
   366  						gerritProjects = append(gerritProjects, &cfgpb.ConfigGroup_Gerrit_Project{
   367  							Name:      fmt.Sprintf("repo-%d", i),
   368  							RefRegexp: []string{gRef},
   369  						})
   370  					}
   371  				}
   372  				cfg := &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{
   373  					Name:   fmt.Sprintf("%d-%d", lp, v),
   374  					Gerrit: []*cfgpb.ConfigGroup_Gerrit{{Url: "https://" + gHost, Projects: gerritProjects}},
   375  				}}}
   376  				if v == 1 {
   377  					prjcfgtest.Create(ctx, lProject, cfg)
   378  				} else {
   379  					prjcfgtest.Update(ctx, lProject, cfg)
   380  				}
   381  
   382  				task := struct {
   383  					meta prjcfg.Meta
   384  					cgs  []*prjcfg.ConfigGroup
   385  				}{meta: prjcfgtest.MustExist(ctx, lProject)}
   386  				var err error
   387  				if task.cgs, err = task.meta.GetConfigGroups(ctx); err != nil {
   388  					panic(err)
   389  				}
   390  				for t := 1; t <= taskRedundancy; t++ {
   391  					tasks = append(tasks, task)
   392  				}
   393  			}
   394  		}
   395  
   396  		ctx, fb := featureBreaker.FilterRDS(ctx, nil)
   397  		// Use a single random source for all flaky.Errors(...) instances. Otherwise
   398  		// they repeat the same random pattern each time withBrokenDS is called.
   399  		rnd := rand.NewSource(0)
   400  		// Make datastore a bit faulty.
   401  		fb.BreakFeaturesWithCallback(
   402  			flaky.Errors(flaky.Params{
   403  				Rand:                             rnd,
   404  				DeadlineProbability:              0.01,
   405  				ConcurrentTransactionProbability: 0.01,
   406  			}),
   407  			featureBreaker.DatastoreFeatures...,
   408  		)
   409  
   410  		// Run workers. Each worker process Update tasks in order.
   411  		// Each task is retried until it succeeds.
   412  		eg, egCtx := errgroup.WithContext(ctx)
   413  		retries := make([]int, workers)
   414  		for w := 0; w < workers; w++ {
   415  			w := w
   416  			eg.Go(func() error {
   417  				for i := w; i < len(tasks); i += workers {
   418  				retryLoop:
   419  					for {
   420  						// Simulate passage of time but slow enough that some updates
   421  						// succeed before the lease expiry.
   422  						ct.Clock.Add(maxUpdateDuration / workers)
   423  						switch err := Update(egCtx, &tasks[i].meta, tasks[i].cgs); {
   424  						case err == nil:
   425  							break retryLoop
   426  						case ctx.Err() != nil:
   427  							// This test should be fast. If test context expired, fail
   428  							// quickly.
   429  							return err
   430  						default:
   431  							retries[w]++
   432  						}
   433  					}
   434  				}
   435  				return nil
   436  			})
   437  		}
   438  		So(eg.Wait(), ShouldBeNil)
   439  
   440  		// If individual retries exceed 1K, it's probably a good idea to tweak
   441  		// parameters s.t. test runs faster.
   442  		t.Logf("Retries per each worker: %v", retries)
   443  
   444  		// "Fix" datastore, letting us examine it.
   445  		fb.BreakFeaturesWithCallback(
   446  			func(context.Context, string) error { return nil },
   447  			featureBreaker.DatastoreFeatures...,
   448  		)
   449  		for p := 1; p <= projects; p++ {
   450  			project := fmt.Sprintf("project-%d", p)
   451  
   452  			// Compute which repos we expect to see.
   453  			expectedRepos := stringset.Set{}
   454  			meta := prjcfgtest.MustExist(ctx, project)
   455  			cgs, err := meta.GetConfigGroups(ctx)
   456  			So(err, ShouldBeNil)
   457  			for _, pr := range cgs[0].Content.GetGerrit()[0].GetProjects() {
   458  				expectedRepos.Add(pr.GetName())
   459  			}
   460  
   461  			// Ensure the map contains these repos and only them.
   462  			// NOTE: this test reproducibly fails because gobmap.Update is not really
   463  			// safe to call concurrently, so asserted are marked with SkipSo.
   464  			// TODO(crbug/1179286): fix the code and the test.
   465  			var mps []*mapPart
   466  			So(datastore.GetAll(ctx, datastore.NewQuery(mapKind).Eq("Project", project), &mps), ShouldBeNil)
   467  			for _, mp := range mps {
   468  				SkipSo(mp.ConfigHash, ShouldResemble, meta.Hash())
   469  				hostAndRepo := strings.SplitN(mp.Parent.StringID(), "/", 2)
   470  				So(hostAndRepo[0], ShouldResemble, gHost)
   471  				SkipSo(expectedRepos.Del(hostAndRepo[1]), ShouldBeTrue)
   472  			}
   473  			SkipSo(expectedRepos, ShouldBeEmpty)
   474  		}
   475  	})
   476  }
   477  
   478  // lookup is a test helper function to return just the projects and config
   479  // group names returned by Lookup.
   480  func lookup(ctx context.Context, host, repo, ref string) map[string][]string {
   481  	ret := map[string][]string{}
   482  	ac, err := Lookup(ctx, host, repo, ref)
   483  	So(err, ShouldBeNil)
   484  	for _, p := range ac.Projects {
   485  		var names []string
   486  		for _, id := range p.ConfigGroupIds {
   487  			parts := strings.Split(id, "/")
   488  			So(len(parts), ShouldEqual, 2)
   489  			names = append(names, parts[1])
   490  		}
   491  		ret[p.Name] = names
   492  	}
   493  	return ret
   494  }