go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/prjmanager/clpurger/clpurger_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 clpurger 16 17 import ( 18 "context" 19 "testing" 20 "time" 21 22 "google.golang.org/protobuf/types/known/timestamppb" 23 24 gerritpb "go.chromium.org/luci/common/proto/gerrit" 25 "go.chromium.org/luci/gae/service/datastore" 26 "go.chromium.org/luci/server/tq/tqtesting" 27 28 cfgpb "go.chromium.org/luci/cv/api/config/v2" 29 "go.chromium.org/luci/cv/internal/changelist" 30 "go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest" 31 "go.chromium.org/luci/cv/internal/cvtesting" 32 gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake" 33 "go.chromium.org/luci/cv/internal/gerrit/gobmap/gobmaptest" 34 "go.chromium.org/luci/cv/internal/gerrit/trigger" 35 gerritupdater "go.chromium.org/luci/cv/internal/gerrit/updater" 36 "go.chromium.org/luci/cv/internal/prjmanager" 37 "go.chromium.org/luci/cv/internal/prjmanager/pmtest" 38 "go.chromium.org/luci/cv/internal/prjmanager/prjpb" 39 "go.chromium.org/luci/cv/internal/run" 40 "go.chromium.org/luci/cv/internal/tryjob" 41 "go.chromium.org/luci/cv/internal/tryjob/tjcancel" 42 43 . "github.com/smartystreets/goconvey/convey" 44 . "go.chromium.org/luci/common/testing/assertions" 45 ) 46 47 func TestPurgeCL(t *testing.T) { 48 t.Parallel() 49 50 Convey("PurgeCL works", t, func() { 51 ct := cvtesting.Test{} 52 ctx, cancel := ct.SetUp(t) 53 defer cancel() 54 ctx, pmDispatcher := pmtest.MockDispatch(ctx) 55 56 pmNotifier := prjmanager.NewNotifier(ct.TQDispatcher) 57 tjNotifier := tryjob.NewNotifier(ct.TQDispatcher) 58 _ = tjcancel.NewCancellator(tjNotifier) 59 clMutator := changelist.NewMutator(ct.TQDispatcher, pmNotifier, nil, tjNotifier) 60 fakeCLUpdater := clUpdaterMock{} 61 purger := New(pmNotifier, ct.GFactory(), &fakeCLUpdater, clMutator) 62 63 const lProject = "lprj" 64 const gHost = "x-review" 65 const gRepo = "repo" 66 const change = 43 67 68 cfg := makeConfig(gHost, gRepo) 69 prjcfgtest.Create(ctx, lProject, cfg) 70 cfgMeta := prjcfgtest.MustExist(ctx, lProject) 71 gobmaptest.Update(ctx, lProject) 72 73 // Fake 1 CL in gerrit & import it to Datastore. 74 ci := gf.CI( 75 change, gf.PS(2), gf.Project(gRepo), gf.Ref("refs/heads/main"), 76 gf.CQ(+2, ct.Clock.Now().Add(-2*time.Minute), gf.U("user-1")), 77 gf.Updated(ct.Clock.Now().Add(-1*time.Minute)), 78 ) 79 ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLRestricted(lProject), ci)) 80 81 // The real CL Updater for realistic CL Snapshot in datastore. 82 clUpdater := changelist.NewUpdater(ct.TQDispatcher, clMutator) 83 gerritupdater.RegisterUpdater(clUpdater, ct.GFactory()) 84 refreshCL := func() { 85 So(clUpdater.TestingForceUpdate(ctx, &changelist.UpdateCLTask{ 86 LuciProject: lProject, 87 ExternalId: string(changelist.MustGobID(gHost, change)), 88 }), ShouldBeNil) 89 } 90 refreshCL() 91 92 loadCL := func() *changelist.CL { 93 cl, err := changelist.MustGobID(gHost, change).Load(ctx) 94 So(err, ShouldBeNil) 95 So(cl, ShouldNotBeNil) 96 return cl 97 } 98 clBefore := loadCL() 99 100 assertPMNotified := func(purgingCL *prjpb.PurgingCL) { 101 pmtest.AssertInEventbox(ctx, lProject, &prjpb.Event{Event: &prjpb.Event_PurgeCompleted{ 102 PurgeCompleted: &prjpb.PurgeCompleted{ 103 OperationId: purgingCL.GetOperationId(), 104 Clid: purgingCL.GetClid(), 105 }, 106 }}) 107 } 108 109 // Basic task. 110 task := &prjpb.PurgeCLTask{ 111 LuciProject: lProject, 112 PurgingCl: &prjpb.PurgingCL{ 113 OperationId: "op", 114 Clid: int64(clBefore.ID), 115 Deadline: timestamppb.New(ct.Clock.Now().Add(10 * time.Minute)), 116 ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true}, 117 }, 118 PurgeReasons: []*prjpb.PurgeReason{{ 119 ClError: &changelist.CLError{ 120 Kind: &changelist.CLError_OwnerLacksEmail{OwnerLacksEmail: true}, 121 }, 122 ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true}, 123 }}, 124 ConfigGroups: []string{string(cfgMeta.ConfigGroupIDs[0])}, 125 } 126 127 schedule := func() error { 128 return datastore.RunInTransaction(ctx, func(tCtx context.Context) error { 129 return purger.Schedule(tCtx, task) 130 }, nil) 131 } 132 133 ct.Clock.Add(time.Minute) 134 135 Convey("Purge one trigger, then the other", func() { 136 task.PurgeReasons = []*prjpb.PurgeReason{ 137 { 138 ClError: &changelist.CLError{ 139 Kind: &changelist.CLError_InvalidDeps_{ 140 InvalidDeps: &changelist.CLError_InvalidDeps{ 141 Unwatched: []*changelist.Dep{ 142 {Clid: 1, Kind: changelist.DepKind_HARD}, 143 }, 144 }, 145 }, 146 }, 147 ApplyTo: &prjpb.PurgeReason_Triggers{ 148 Triggers: trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: &cfgpb.ConfigGroup{}}), 149 }, 150 }, 151 } 152 So(schedule(), ShouldBeNil) 153 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 154 155 ciAfter := ct.GFake.GetChange(gHost, change).Info 156 triggersAfter := trigger.Find(&trigger.FindInput{ 157 ChangeInfo: ciAfter, 158 ConfigGroup: cfg.GetConfigGroups()[0], 159 TriggerNewPatchsetRunAfterPS: loadCL().TriggerNewPatchsetRunAfterPS, 160 }) 161 So(triggersAfter, ShouldNotBeNil) 162 So(triggersAfter.CqVoteTrigger, ShouldBeNil) 163 So(triggersAfter.NewPatchsetRunTrigger, ShouldNotBeNil) 164 So(ciAfter, gf.ShouldLastMessageContain, "its deps are not watched") 165 So(fakeCLUpdater.scheduledTasks, ShouldHaveLength, 1) 166 assertPMNotified(task.PurgingCl) 167 168 task.PurgeReasons = []*prjpb.PurgeReason{ 169 { 170 ClError: &changelist.CLError{ 171 Kind: &changelist.CLError_UnsupportedMode{ 172 UnsupportedMode: string(run.NewPatchsetRun)}, 173 }, 174 ApplyTo: &prjpb.PurgeReason_Triggers{ 175 Triggers: &run.Triggers{ 176 NewPatchsetRunTrigger: triggersAfter.GetNewPatchsetRunTrigger(), 177 }, 178 }, 179 }, 180 } 181 So(schedule(), ShouldBeNil) 182 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 183 184 ciAfter = ct.GFake.GetChange(gHost, change).Info 185 triggersAfter = trigger.Find(&trigger.FindInput{ 186 ChangeInfo: ciAfter, 187 ConfigGroup: cfg.GetConfigGroups()[0], 188 TriggerNewPatchsetRunAfterPS: loadCL().TriggerNewPatchsetRunAfterPS, 189 }) 190 So(triggersAfter, ShouldBeNil) 191 So(ciAfter, gf.ShouldLastMessageContain, "is not supported") 192 So(fakeCLUpdater.scheduledTasks, ShouldHaveLength, 2) 193 assertPMNotified(task.PurgingCl) 194 }) 195 Convey("Happy path: reset both triggers, schedule CL refresh, and notify PM", func() { 196 So(schedule(), ShouldBeNil) 197 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 198 199 ciAfter := ct.GFake.GetChange(gHost, change).Info 200 So(trigger.Find(&trigger.FindInput{ 201 ChangeInfo: ciAfter, 202 ConfigGroup: cfg.GetConfigGroups()[0], 203 TriggerNewPatchsetRunAfterPS: loadCL().TriggerNewPatchsetRunAfterPS, 204 }), ShouldBeNil) 205 So(ciAfter, gf.ShouldLastMessageContain, "owner doesn't have a preferred email") 206 207 So(fakeCLUpdater.scheduledTasks, ShouldHaveLength, 1) 208 assertPMNotified(task.PurgingCl) 209 So(loadCL().Snapshot.GetOutdated(), ShouldNotBeNil) 210 211 Convey("Idempotent: if TQ task is retried, just notify PM", func() { 212 verifyIdempotency := func() { 213 // Use different Operation ID s.t. we can easily assert PM was notified 214 // the 2nd time. 215 task.PurgingCl.OperationId = "op-2" 216 So(schedule(), ShouldBeNil) 217 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 218 // CL in Gerrit shouldn't be changed. 219 ciAfter2 := ct.GFake.GetChange(gHost, change).Info 220 So(ciAfter2, ShouldResembleProto, ciAfter) 221 // But PM must be notified. 222 assertPMNotified(task.PurgingCl) 223 So(pmDispatcher.LatestETAof(lProject), ShouldHappenBefore, ct.Clock.Now().Add(2*time.Second)) 224 } 225 // Idempotency must not rely on CL being updated between retries. 226 Convey("CL updated between retries", func() { 227 verifyIdempotency() 228 // should remain with the same value in Outdated. 229 So(loadCL().Snapshot.GetOutdated(), ShouldNotBeNil) 230 }) 231 Convey("CL not updated between retries", func() { 232 refreshCL() 233 // Outdated should be nil after refresh(). 234 So(loadCL().Snapshot.GetOutdated(), ShouldBeNil) 235 verifyIdempotency() 236 // Idempotency should not make it outdated, because 237 // no purge is performed actually. 238 So(loadCL().Snapshot.GetOutdated(), ShouldBeNil) 239 }) 240 }) 241 }) 242 243 Convey("Even if no purging is done, PM is always notified", func() { 244 Convey("Task arrives after the deadline", func() { 245 task.PurgingCl.Deadline = timestamppb.New(ct.Clock.Now().Add(-time.Minute)) 246 So(schedule(), ShouldBeNil) 247 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 248 So(loadCL().EVersion, ShouldEqual, clBefore.EVersion) // no changes. 249 assertPMNotified(task.PurgingCl) 250 So(pmDispatcher.LatestETAof(lProject), ShouldHappenBefore, ct.Clock.Now().Add(2*time.Second)) 251 }) 252 253 Convey("Trigger is no longer matching latest CL Snapshot", func() { 254 // Simulate old trigger for CQ+1, while snapshot contains CQ+2. 255 gf.CQ(+1, ct.Clock.Now().Add(-time.Hour), gf.U("user-1"))(ci) 256 // Simulate NPR finished earlier. 257 cl := loadCL() 258 cl.TriggerNewPatchsetRunAfterPS = 2 259 So(datastore.Put(ctx, cl), ShouldBeNil) 260 261 So(schedule(), ShouldBeNil) 262 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 263 So(loadCL().EVersion, ShouldEqual, clBefore.EVersion+1) // +1 for setting Outdated{} 264 assertPMNotified(task.PurgingCl) 265 // The PM task should be ASAP. 266 So(pmDispatcher.LatestETAof(lProject), ShouldHappenBefore, ct.Clock.Now().Add(2*time.Second)) 267 }) 268 }) 269 270 Convey("Sets Notify and AddToAttentionSet", func() { 271 var reqs []*gerritpb.SetReviewRequest 272 findSetReviewReqs := func() { 273 for _, req := range ct.GFake.Requests() { 274 if r, ok := req.(*gerritpb.SetReviewRequest); ok { 275 reqs = append(reqs, r) 276 } 277 } 278 } 279 280 Convey("with the default NotifyTarget", func() { 281 task.PurgingCl.Notification = nil 282 So(schedule(), ShouldBeNil) 283 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 284 findSetReviewReqs() 285 So(reqs, ShouldHaveLength, 2) 286 postReq := reqs[0] 287 voteReq := reqs[1] 288 if reqs[1].GetMessage() != "" { 289 postReq, voteReq = reqs[1], reqs[0] 290 } 291 So(postReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE) 292 So(voteReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE) 293 So(postReq.GetNotifyDetails(), ShouldNotBeNil) 294 So(voteReq.GetNotifyDetails(), ShouldBeNil) 295 So(postReq.GetAddToAttentionSet(), ShouldNotBeNil) 296 So(voteReq.GetAddToAttentionSet(), ShouldBeNil) 297 }) 298 Convey("with a custom Notification target", func() { 299 // 0 implies nobody 300 task.PurgingCl.Notification = NoNotification 301 So(schedule(), ShouldBeNil) 302 ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass)) 303 findSetReviewReqs() 304 So(reqs, ShouldHaveLength, 2) 305 postReq := reqs[0] 306 voteReq := reqs[1] 307 if reqs[1].GetMessage() != "" { 308 postReq, voteReq = reqs[1], reqs[0] 309 } 310 So(postReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE) 311 So(voteReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE) 312 So(postReq.GetNotifyDetails(), ShouldBeNil) 313 So(voteReq.GetNotifyDetails(), ShouldBeNil) 314 So(postReq.GetAddToAttentionSet(), ShouldBeNil) 315 So(voteReq.GetAddToAttentionSet(), ShouldBeNil) 316 }) 317 }) 318 }) 319 } 320 321 func makeConfig(gHost string, gRepo string) *cfgpb.Config { 322 return &cfgpb.Config{ 323 ConfigGroups: []*cfgpb.ConfigGroup{ 324 { 325 Name: "main", 326 Gerrit: []*cfgpb.ConfigGroup_Gerrit{ 327 { 328 Url: "https://" + gHost + "/", 329 Projects: []*cfgpb.ConfigGroup_Gerrit_Project{ 330 { 331 Name: gRepo, 332 RefRegexp: []string{"refs/heads/main"}, 333 }, 334 }, 335 }, 336 }, 337 Verifiers: &cfgpb.Verifiers{ 338 Tryjob: &cfgpb.Verifiers_Tryjob{ 339 Builders: []*cfgpb.Verifiers_Tryjob_Builder{ 340 { 341 Name: "linter", 342 ModeAllowlist: []string{string(run.NewPatchsetRun)}, 343 }, 344 }, 345 }, 346 }, 347 }, 348 }, 349 } 350 } 351 352 type clUpdaterMock struct { 353 scheduledTasks []*changelist.UpdateCLTask 354 } 355 356 func (c *clUpdaterMock) Schedule(_ context.Context, task *changelist.UpdateCLTask) error { 357 c.scheduledTasks = append(c.scheduledTasks, task) 358 return nil 359 }