go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/poller/poller_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 poller 16 17 import ( 18 "context" 19 "fmt" 20 "sort" 21 "sync" 22 "testing" 23 "time" 24 25 "google.golang.org/protobuf/types/known/timestamppb" 26 27 "go.chromium.org/luci/common/clock" 28 gerritpb "go.chromium.org/luci/common/proto/gerrit" 29 "go.chromium.org/luci/gae/service/datastore" 30 "go.chromium.org/luci/server/tq/tqtesting" 31 32 cfgpb "go.chromium.org/luci/cv/api/config/v2" 33 "go.chromium.org/luci/cv/internal/changelist" 34 "go.chromium.org/luci/cv/internal/common" 35 "go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest" 36 "go.chromium.org/luci/cv/internal/cvtesting" 37 gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake" 38 39 . "github.com/smartystreets/goconvey/convey" 40 ) 41 42 func TestSchedule(t *testing.T) { 43 t.Parallel() 44 45 Convey("Schedule works", t, func() { 46 ct := cvtesting.Test{} 47 ctx, cancel := ct.SetUp(t) 48 defer cancel() 49 50 ct.Clock.Set(ct.Clock.Now().Truncate(pollInterval).Add(pollInterval)) 51 const project = "chromium" 52 53 p := New(ct.TQDispatcher, nil, nil, nil) 54 55 So(p.schedule(ctx, project, time.Time{}), ShouldBeNil) 56 payloads := FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads()) 57 So(payloads, ShouldHaveLength, 1) 58 first := payloads[0] 59 So(first.GetLuciProject(), ShouldEqual, project) 60 firstETA := first.GetEta().AsTime() 61 So(firstETA.UnixNano(), ShouldBeBetweenOrEqual, 62 ct.Clock.Now().UnixNano(), ct.Clock.Now().Add(pollInterval).UnixNano()) 63 64 Convey("idempotency via task deduplication", func() { 65 So(p.schedule(ctx, project, time.Time{}), ShouldBeNil) 66 So(FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads()), ShouldHaveLength, 1) 67 68 Convey("but only for the same project", func() { 69 So(p.schedule(ctx, "another-project", time.Time{}), ShouldBeNil) 70 ids := FilterProjects(ct.TQ.Tasks().SortByETA().Payloads()) 71 sort.Strings(ids) 72 So(ids, ShouldResemble, []string{"another-project", project}) 73 }) 74 }) 75 76 Convey("schedule next poll", func() { 77 So(p.schedule(ctx, project, firstETA), ShouldBeNil) 78 payloads := FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads()) 79 So(payloads, ShouldHaveLength, 2) 80 So(payloads[1].GetEta().AsTime(), ShouldEqual, firstETA.Add(pollInterval)) 81 82 Convey("from a delayed prior poll", func() { 83 ct.Clock.Set(firstETA.Add(pollInterval).Add(pollInterval / 2)) 84 So(p.schedule(ctx, project, firstETA), ShouldBeNil) 85 payloads := FilterPayloads(ct.TQ.Tasks().SortByETA().Payloads()) 86 So(payloads, ShouldHaveLength, 3) 87 So(payloads[2].GetEta().AsTime(), ShouldEqual, firstETA.Add(2*pollInterval)) 88 }) 89 }) 90 }) 91 } 92 93 func TestObservesProjectLifetime(t *testing.T) { 94 t.Parallel() 95 96 Convey("Gerrit Poller observes project lifetime", t, func() { 97 ct := cvtesting.Test{} 98 ctx, cancel := ct.SetUp(t) 99 defer cancel() 100 101 const lProject = "chromium" 102 const gHost = "chromium-review.example.com" 103 const gRepo = "infra/infra" 104 105 mustLoadState := func() *State { 106 st := &State{LuciProject: lProject} 107 So(datastore.Get(ctx, st), ShouldBeNil) 108 return st 109 } 110 111 p := New(ct.TQDispatcher, ct.GFactory(), &clUpdaterMock{}, &pmMock{}) 112 113 Convey("Without project config, does nothing", func() { 114 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 115 So(ct.TQ.Tasks(), ShouldBeEmpty) 116 So(datastore.Get(ctx, &State{LuciProject: lProject}), ShouldEqual, datastore.ErrNoSuchEntity) 117 }) 118 119 Convey("For an existing project, runs via a task chain", func() { 120 prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo)) 121 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 122 So(mustLoadState().EVersion, ShouldEqual, 1) 123 for i := 0; i < 10; i++ { 124 So(ct.TQ.Tasks(), ShouldHaveLength, 1) 125 ct.TQ.Run(ctx, tqtesting.StopAfterTask(taskClassID)) 126 } 127 So(mustLoadState().EVersion, ShouldEqual, 11) 128 }) 129 130 Convey("On config changes, updates its state", func() { 131 prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo)) 132 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 133 s := mustLoadState() 134 So(s.QueryStates.GetStates(), ShouldHaveLength, 1) 135 qs0 := s.QueryStates.GetStates()[0] 136 So(qs0.GetHost(), ShouldResemble, gHost) 137 So(qs0.GetOrProjects(), ShouldResemble, []string{gRepo}) 138 139 const gRepo2 = "infra/zzzzz" 140 prjcfgtest.Update(ctx, lProject, singleRepoConfig(gHost, gRepo, gRepo2)) 141 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 142 s = mustLoadState() 143 So(s.QueryStates.GetStates(), ShouldHaveLength, 1) 144 qs0 = s.QueryStates.GetStates()[0] 145 So(qs0.GetOrProjects(), ShouldResemble, []string{gRepo, gRepo2}) 146 147 prjcfgtest.Update(ctx, lProject, singleRepoConfig(gHost, gRepo2)) 148 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 149 s = mustLoadState() 150 So(s.QueryStates.GetStates(), ShouldHaveLength, 1) 151 qs0 = s.QueryStates.GetStates()[0] 152 So(qs0.GetOrProjects(), ShouldResemble, []string{gRepo2}) 153 }) 154 155 Convey("Once project is disabled, deletes state and task chain stops running", func() { 156 prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo)) 157 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 158 So(ct.TQ.Tasks(), ShouldHaveLength, 1) 159 So(mustLoadState().EVersion, ShouldEqual, 1) 160 161 prjcfgtest.Disable(ctx, lProject) 162 ct.TQ.Run(ctx, tqtesting.StopAfterTask(taskClassID)) 163 164 So(datastore.Get(ctx, &State{LuciProject: lProject}), ShouldEqual, datastore.ErrNoSuchEntity) 165 So(ct.TQ.Tasks(), ShouldBeEmpty) 166 }) 167 }) 168 } 169 170 func TestDiscoversCLs(t *testing.T) { 171 t.Parallel() 172 173 Convey("Gerrit Poller discovers CLs", t, func() { 174 ct := cvtesting.Test{} 175 ctx, cancel := ct.SetUp(t) 176 defer cancel() 177 178 const lProject = "chromium" 179 const gHost = "chromium-review.example.com" 180 const gRepo = "infra/infra" 181 182 mustLoadState := func() *State { 183 st := &State{LuciProject: lProject} 184 So(datastore.Get(ctx, st), ShouldBeNil) 185 return st 186 } 187 ensureCLEntity := func(change int64) *changelist.CL { 188 return changelist.MustGobID(gHost, change).MustCreateIfNotExists(ctx) 189 } 190 191 pm := pmMock{} 192 clUpdater := clUpdaterMock{} 193 p := New(ct.TQDispatcher, ct.GFactory(), &clUpdater, &pm) 194 195 prjcfgtest.Create(ctx, lProject, singleRepoConfig(gHost, gRepo)) 196 // Initialize Poller state for ease of modifications in test later. 197 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 198 ct.Clock.Add(10 * fullPollInterval) 199 200 ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), 201 // These CLs ordered from oldest to newest by .Updated. 202 gf.CI(31, gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-2*fullPollInterval))), 203 gf.CI(32, gf.CQ(+1), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-1*fullPollInterval))), 204 gf.CI(33, gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-2*incrementalPollOverlap))), 205 gf.CI(34, gf.CQ(+1), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-1*incrementalPollOverlap))), 206 gf.CI(35, gf.CQ(+1), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-1*time.Millisecond))), 207 // No CQ vote. This will not show up on a full query, but only on an incremental. 208 gf.CI(36, gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))), 209 210 // These must not be matched. 211 gf.CI(40, gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-common.MaxTriggerAge-time.Second))), 212 gf.CI(41, gf.CQ(+2), gf.Project("another/project"), gf.Updated(ct.Clock.Now())), 213 // TODO(tandrii): when poller becomes ref-ware, add this to the test. 214 // gf.CI(42, gf.CQ(+2), gf.Project(gRepo), gf.Ref("refs/not/matched"), gf.Updated(ct.Clock.Now())), 215 )) 216 217 Convey("Discover all CLs in case of a full query", func() { 218 s := mustLoadState() 219 s.QueryStates.GetStates()[0].LastFullTime = nil // Force "full" fetch 220 So(datastore.Put(ctx, s), ShouldBeNil) 221 222 postFullQueryVerify := func() { 223 qs := mustLoadState().QueryStates.GetStates()[0] 224 So(qs.GetLastFullTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC()) 225 So(qs.GetLastIncrTime(), ShouldBeNil) 226 So(qs.GetChanges(), ShouldResemble, []int64{31, 32, 33, 34, 35}) 227 } 228 229 Convey("On project start, just creates CLUpdater tasks with forceNotify", func() { 230 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 231 So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{31, 32, 33, 34, 35}) 232 postFullQueryVerify() 233 }) 234 235 Convey("In a typical case, uses forceNotify judiciously", func() { 236 // In a typical case, CV has been polling before and so is already aware 237 // of every CL except 35. 238 s.QueryStates.GetStates()[0].Changes = []int64{31, 32, 33, 34} 239 So(datastore.Put(ctx, s), ShouldBeNil) 240 // However, 34 may not yet have an CL entity. 241 knownCLIDs := common.CLIDs{ 242 ensureCLEntity(31).ID, 243 ensureCLEntity(32).ID, 244 ensureCLEntity(33).ID, 245 } 246 247 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 248 249 // PM must be notified in "bulk". 250 So(pm.popNotifiedCLs(lProject), ShouldResemble, sortedCLIDs(knownCLIDs...)) 251 // All CLs must have clUpdater tasks. 252 So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{31, 32, 33, 34, 35}) 253 postFullQueryVerify() 254 }) 255 256 Convey("When previously known changes are no longer found, forces their refresh, too", func() { 257 // Test common occurrence of CL no longer appearing in query 258 // results due to user or even CV action. 259 ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLPublic(), 260 // No CQ vote. 261 gf.CI(25, gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))), 262 // Abandoned. 263 gf.CI(26, gf.Status(gerritpb.ChangeStatus_ABANDONED), gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))), 264 // Submitted. 265 gf.CI(27, gf.Status(gerritpb.ChangeStatus_ABANDONED), gf.CQ(+2), gf.Project(gRepo), gf.Updated(ct.Clock.Now().Add(-time.Minute))), 266 )) 267 s.QueryStates.GetStates()[0].Changes = []int64{25, 26, 27, 31, 32, 33, 34} 268 So(datastore.Put(ctx, s), ShouldBeNil) 269 var knownCLIDs common.CLIDs 270 for _, c := range s.QueryStates.GetStates()[0].Changes { 271 knownCLIDs = append(knownCLIDs, ensureCLEntity(c).ID) 272 } 273 274 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 275 276 // PM must be notified in "bulk" for all previously known CLs. 277 So(pm.popNotifiedCLs(lProject), ShouldResemble, sortedCLIDs(knownCLIDs...)) 278 // All current and prior CLs must have clUpdater tasks. 279 So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{25, 26, 27, 31, 32, 33, 34, 35}) 280 postFullQueryVerify() 281 }) 282 283 Convey("On config change, force notifies PM and runs a full query", func() { 284 // Strictly speaking, this test isn't just a full poll, but also a 285 // config change. 286 287 // Simulate prior full fetch has just happened. 288 s.QueryStates.GetStates()[0].LastFullTime = timestamppb.New(ct.Clock.Now().Add(-pollInterval)) 289 // But with a different query. 290 s.QueryStates.GetStates()[0].OrProjects = []string{gRepo, "repo/which/had/cl30"} 291 // And all CLs except but 35 are already known but also CL 30. 292 s.QueryStates.GetStates()[0].Changes = []int64{30, 31, 32, 33, 34} 293 s.ConfigHash = "some/other/hash" 294 So(datastore.Put(ctx, s), ShouldBeNil) 295 var knownCLIDs common.CLIDs 296 for _, c := range s.QueryStates.GetStates()[0].Changes { 297 knownCLIDs = append(knownCLIDs, ensureCLEntity(c).ID) 298 } 299 300 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 301 302 // PM must be notified about all prior CLs. 303 So(pm.popNotifiedCLs(lProject), ShouldResemble, sortedCLIDs(knownCLIDs...)) 304 // All CLs must have clUpdater tasks. 305 // NOTE: the code isn't optimized for this use case, so there will be 306 // multiple tasks for changes 31..34. While this is unfortunte, it's 307 // rare enough that it doesn't really matter. 308 So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{30, 31, 31, 32, 32, 33, 33, 34, 34, 35}) 309 postFullQueryVerify() 310 311 qs := mustLoadState().QueryStates.GetStates()[0] 312 So(qs.GetOrProjects(), ShouldResemble, []string{gRepo}) 313 }) 314 }) 315 316 Convey("Discover most recently modified CLs only in case of an incremental query", func() { 317 s := mustLoadState() 318 s.QueryStates.GetStates()[0].LastFullTime = timestamppb.New(ct.Clock.Now()) // Force incremental fetch 319 So(datastore.Put(ctx, s), ShouldBeNil) 320 321 Convey("Unless the pubsub is enabled", func() { 322 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 323 So(clUpdater.peekScheduledChanges(), ShouldBeEmpty) 324 qs := mustLoadState().QueryStates.GetStates()[0] 325 So(qs.GetLastIncrTime(), ShouldBeNil) 326 }) 327 328 ct.DisableProjectInGerritListener(ctx, lProject) 329 330 Convey("In a typical case, schedules update tasks for new CLs", func() { 331 s.QueryStates.GetStates()[0].Changes = []int64{31, 32, 33} 332 So(datastore.Put(ctx, s), ShouldBeNil) 333 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 334 335 So(clUpdater.peekScheduledChanges(), ShouldResemble, []int{34, 35, 36}) 336 337 qs := mustLoadState().QueryStates.GetStates()[0] 338 So(qs.GetLastIncrTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC()) 339 So(qs.GetChanges(), ShouldResemble, []int64{31, 32, 33, 34, 35, 36}) 340 }) 341 342 Convey("Even if CL is already known, schedules update tasks", func() { 343 s.QueryStates.GetStates()[0].Changes = []int64{31, 32, 33, 34, 35, 36} 344 So(datastore.Put(ctx, s), ShouldBeNil) 345 So(p.poll(ctx, lProject, ct.Clock.Now()), ShouldBeNil) 346 347 qs := mustLoadState().QueryStates.GetStates()[0] 348 So(qs.GetLastIncrTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC()) 349 So(qs.GetChanges(), ShouldResemble, []int64{31, 32, 33, 34, 35, 36}) 350 }) 351 352 }) 353 }) 354 } 355 356 func singleRepoConfig(gHost string, gRepos ...string) *cfgpb.Config { 357 projects := make([]*cfgpb.ConfigGroup_Gerrit_Project, len(gRepos)) 358 for i, gRepo := range gRepos { 359 projects[i] = &cfgpb.ConfigGroup_Gerrit_Project{ 360 Name: gRepo, 361 RefRegexp: []string{"refs/heads/main"}, 362 } 363 } 364 return &cfgpb.Config{ 365 ConfigGroups: []*cfgpb.ConfigGroup{ 366 { 367 Name: "main", 368 Gerrit: []*cfgpb.ConfigGroup_Gerrit{ 369 { 370 Url: "https://" + gHost + "/", 371 Projects: projects, 372 }, 373 }, 374 }, 375 }, 376 } 377 } 378 379 func sharedPrefixRepos(prefix string, n int) []string { 380 rs := make([]string, n) 381 for i := range rs { 382 rs[i] = fmt.Sprintf("%s/%03d", prefix, i) 383 } 384 return rs 385 } 386 387 type pmMock struct { 388 projects map[string]common.CLIDs 389 } 390 391 func (p *pmMock) NotifyCLsUpdated(ctx context.Context, project string, cls *changelist.CLUpdatedEvents) error { 392 if p.projects == nil { 393 p.projects = make(map[string]common.CLIDs, len(cls.GetEvents())) 394 } 395 for _, e := range cls.GetEvents() { 396 p.projects[project] = append(p.projects[project], common.CLID(e.GetClid())) 397 } 398 return nil 399 } 400 401 func (p *pmMock) popNotifiedCLs(luciProject string) common.CLIDs { 402 if p.projects == nil { 403 return nil 404 } 405 res := p.projects[luciProject] 406 delete(p.projects, luciProject) 407 return sortedCLIDs(res...) 408 } 409 410 func sortedCLIDs(ids ...common.CLID) common.CLIDs { 411 res := common.CLIDs(ids) 412 res.Dedupe() // it also sorts as a by-product. 413 return res 414 } 415 416 type clUpdaterMock struct { 417 m sync.Mutex 418 tasks []struct { 419 payload *changelist.UpdateCLTask 420 eta time.Time 421 } 422 } 423 424 func (c *clUpdaterMock) Schedule(ctx context.Context, t *changelist.UpdateCLTask) error { 425 return c.ScheduleDelayed(ctx, t, 0) 426 } 427 428 func (c *clUpdaterMock) ScheduleDelayed(ctx context.Context, t *changelist.UpdateCLTask, d time.Duration) error { 429 c.m.Lock() 430 defer c.m.Unlock() 431 c.tasks = append(c.tasks, struct { 432 payload *changelist.UpdateCLTask 433 eta time.Time 434 }{t, clock.Now(ctx).Add(d)}) 435 return nil 436 } 437 438 func (c *clUpdaterMock) sortTasksByETAlocked() { 439 sort.Slice(c.tasks, func(i, j int) bool { return c.tasks[i].eta.Before(c.tasks[j].eta) }) 440 } 441 442 func (c *clUpdaterMock) peekETAs() []time.Time { 443 c.m.Lock() 444 defer c.m.Unlock() 445 c.sortTasksByETAlocked() 446 out := make([]time.Time, len(c.tasks)) 447 for i, t := range c.tasks { 448 out[i] = t.eta 449 } 450 return out 451 } 452 453 func (c *clUpdaterMock) popPayloadsByETA() []*changelist.UpdateCLTask { 454 c.m.Lock() 455 c.sortTasksByETAlocked() 456 tasks := c.tasks 457 c.tasks = nil 458 c.m.Unlock() 459 460 out := make([]*changelist.UpdateCLTask, len(tasks)) 461 for i, t := range tasks { 462 out[i] = t.payload 463 } 464 return out 465 } 466 467 func (c *clUpdaterMock) peekScheduledChanges() []int { 468 c.m.Lock() 469 defer c.m.Unlock() 470 out := make([]int, len(c.tasks)) 471 for i, t := range c.tasks { 472 _, change, err := changelist.ExternalID(t.payload.GetExternalId()).ParseGobID() 473 if err != nil { 474 panic(err) 475 } 476 out[i] = int(change) 477 } 478 sort.Ints(out) 479 return out 480 }