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 }