go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/changelist/mutator.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 changelist 16 17 import ( 18 "context" 19 "fmt" 20 "time" 21 22 "google.golang.org/protobuf/proto" 23 24 "go.chromium.org/luci/common/clock" 25 "go.chromium.org/luci/common/errors" 26 "go.chromium.org/luci/common/retry/transient" 27 "go.chromium.org/luci/common/sync/parallel" 28 "go.chromium.org/luci/gae/service/datastore" 29 "go.chromium.org/luci/server/tq" 30 31 "go.chromium.org/luci/cv/internal/common" 32 ) 33 34 // BatchOnCLUpdatedTaskClass is the Task Class ID of the BatchOnCLUpdatedTask, 35 // which is enqueued during CL mutations. 36 const BatchOnCLUpdatedTaskClass = "batch-notify-on-cl-updated" 37 38 // Mutator modifies CLs and guarantees at least once notification of relevant CV 39 // components. 40 // 41 // All CL entities in production code must be modified via the Mutator. 42 // 43 // Mutator notifies 2 CV components: Run and Project managers. 44 // In the future, it'll also notify Tryjob Manager. 45 // 46 // Run Manager is notified for each IncompleteRuns in the **new** CL version. 47 // 48 // Project manager is notified in following cases: 49 // 1. On the project in the context of which the CL is being modified. 50 // 2. On the project which owns the Snapshot of the *prior* CL version (if it 51 // had any Snapshot). 52 // 53 // When the number of notifications is large, Mutator may chose to 54 // transactionally enqueue a TQ task, which will send notifications in turn. 55 type Mutator struct { 56 tqd *tq.Dispatcher 57 pm pmNotifier 58 rm rmNotifier 59 tj tjNotifier 60 } 61 62 func NewMutator(tqd *tq.Dispatcher, pm pmNotifier, rm rmNotifier, tj tjNotifier) *Mutator { 63 m := &Mutator{tqd, pm, rm, tj} 64 tqd.RegisterTaskClass(tq.TaskClass{ 65 ID: BatchOnCLUpdatedTaskClass, 66 Queue: "notify-on-cl-updated", 67 Prototype: &BatchOnCLUpdatedTask{}, 68 Kind: tq.Transactional, 69 Quiet: true, 70 QuietOnError: true, 71 Handler: func(ctx context.Context, payload proto.Message) error { 72 task := payload.(*BatchOnCLUpdatedTask) 73 err := m.handleBatchOnCLUpdatedTask(ctx, task) 74 return common.TQifyError(ctx, err) 75 }, 76 }) 77 return m 78 } 79 80 // pmNotifier encapsulates interaction with Project Manager. 81 // 82 // In production, implemented by prjmanager.Notifier. 83 type pmNotifier interface { 84 NotifyCLsUpdated(ctx context.Context, project string, events *CLUpdatedEvents) error 85 } 86 87 // rmNotifier encapsulates interaction with Run Manager. 88 // 89 // In production, implemented by run.Notifier. 90 type rmNotifier interface { 91 NotifyCLsUpdated(ctx context.Context, rid common.RunID, events *CLUpdatedEvents) error 92 } 93 94 type tjNotifier interface { 95 ScheduleCancelStale(ctx context.Context, clid common.CLID, prevMinEquivalentPatchset, currentMinEquivalentPatchset int32, eta time.Time) error 96 } 97 98 // ErrStopMutation is a special error used by MutateCallback to signal that no 99 // mutation is necessary. 100 // 101 // This is very useful because the datastore.RunInTransaction(ctx, f, ...) 102 // does retries by default which combined with submarine writes (transaction 103 // actually succeeded, but the client didn't get to know, e.g. due to network 104 // flake) means an idempotent MutateCallback can avoid noop updates yet still 105 // keep the code clean and readable. For example, 106 // 107 // ``` 108 // 109 // cl, err := mu.Update(ctx, project, clid, func (cl *changelist.CL) error { 110 // if cl.Snapshot == nil { 111 // return ErrStopMutation // noop 112 // } 113 // cl.Snapshot = nil 114 // return nil 115 // }) 116 // 117 // if err != nil { 118 // return errors.Annotate(err, "failed to reset Snapshot").Err() 119 // } 120 // 121 // doSomething(ctx, cl) 122 // ``` 123 var ErrStopMutation = errors.New("stop CL mutation") 124 125 // MutateCallback is called by Mutator to mutate the CL inside a transaction. 126 // 127 // The function should be idempotent. 128 // 129 // If no error is returned, Mutator proceeds saving the CL. 130 // 131 // If special ErrStopMutation is returned, Mutator aborts the transaction and 132 // returns existing CL read from Datastore and no error. In the special case of 133 // Upsert(), the returned CL may actually be nil if CL didn't exist. 134 // 135 // If any error is returned other than ErrStopMutation, Mutator aborts the 136 // transaction and returns nil CL and the exact same error. 137 type MutateCallback func(cl *CL) error 138 139 // Upsert creates new or updates existing CL via a dedicated transaction in the 140 // context of the given LUCI project. 141 // 142 // Prefer to use Update if CL ID is known. 143 // 144 // If CL didn't exist before, the callback is provided a CL with temporarily 145 // reserved ID. Until Upsert returns with success, this ID is not final, 146 // but it's fine to use it in other entities saved within the same transaction. 147 // 148 // If CL didn't exist before and the callback returns ErrStopMutation, then 149 // Upsert returns (nil, nil). 150 func (m *Mutator) Upsert(ctx context.Context, project string, eid ExternalID, clbk MutateCallback) (*CL, error) { 151 // Quick path in case CL already exists, which is a common case, 152 // and can usually be satisfied by dscache lookup. 153 mapEntity := clMap{ExternalID: eid} 154 switch err := datastore.Get(ctx, &mapEntity); { 155 case err == datastore.ErrNoSuchEntity: 156 // OK, proceed to slow path below. 157 case err != nil: 158 return nil, errors.Annotate(err, "failed to get clMap entity %q", eid).Tag(transient.Tag).Err() 159 default: 160 return m.Update(ctx, project, mapEntity.InternalID, clbk) 161 } 162 163 var result *CL 164 var innerErr error 165 err := datastore.RunInTransaction(ctx, func(ctx context.Context) (err error) { 166 defer func() { innerErr = err }() 167 // Check if CL exists and prepare appropriate clMutation. 168 var clMutation *CLMutation 169 mapEntity := clMap{ExternalID: eid} 170 switch err := datastore.Get(ctx, &mapEntity); { 171 case err == datastore.ErrNoSuchEntity: 172 clMutation, err = m.beginInsert(ctx, project, eid) 173 if err != nil { 174 return err 175 } 176 case err != nil: 177 return errors.Annotate(err, "failed to get clMap entity %q", eid).Tag(transient.Tag).Err() 178 default: 179 clMutation, err = m.Begin(ctx, project, mapEntity.InternalID) 180 if err != nil { 181 return err 182 } 183 result = clMutation.CL 184 } 185 if err := clbk(clMutation.CL); err != nil { 186 return err 187 } 188 result, err = clMutation.Finalize(ctx) 189 return err 190 }, nil) 191 switch { 192 case innerErr == ErrStopMutation: 193 return result, nil 194 case innerErr != nil: 195 return nil, innerErr 196 case err != nil: 197 return nil, errors.Annotate(err, "failed to commit Upsert of CL %q", eid).Tag(transient.Tag).Err() 198 default: 199 return result, nil 200 } 201 } 202 203 // Update mutates one CL via a dedicated transaction in the context of the given 204 // LUCI project. 205 // 206 // If the callback returns ErrStopMutation, then Update returns the read CL 207 // entity and nil error. 208 func (m *Mutator) Update(ctx context.Context, project string, id common.CLID, clbk MutateCallback) (*CL, error) { 209 var result *CL 210 var innerErr error 211 err := datastore.RunInTransaction(ctx, func(ctx context.Context) (err error) { 212 defer func() { innerErr = err }() 213 clMutation, err := m.Begin(ctx, project, id) 214 if err != nil { 215 return err 216 } 217 result = clMutation.CL 218 if err := clbk(clMutation.CL); err != nil { 219 return err 220 } 221 result, err = clMutation.Finalize(ctx) 222 return err 223 }, nil) 224 switch { 225 case innerErr == ErrStopMutation: 226 return result, nil 227 case innerErr != nil: 228 return nil, innerErr 229 case err != nil: 230 return nil, errors.Annotate(err, "failed to commit update on CL %d", id).Tag(transient.Tag).Err() 231 default: 232 return result, nil 233 } 234 } 235 236 // CLMutation encapsulates one CL mutation. 237 type CLMutation struct { 238 // CL can be modified except the following fields: 239 // * ID 240 // * ExternalID 241 // * EVersion 242 // * UpdateTime 243 CL *CL 244 245 // m is a back reference to its parent -- Mutator. 246 m *Mutator 247 248 // trans is only to detect incorrect usage. 249 trans datastore.Transaction 250 // project in the context of which CL is modified. 251 project string 252 253 id common.CLID 254 externalID ExternalID 255 256 priorEversion int64 257 priorUpdateTime time.Time 258 priorProject string 259 priorMinEquivalentPatchset int32 260 } 261 262 func (m *Mutator) beginInsert(ctx context.Context, project string, eid ExternalID) (*CLMutation, error) { 263 clMutation := &CLMutation{ 264 CL: &CL{ExternalID: eid}, 265 m: m, 266 trans: datastore.CurrentTransaction(ctx), 267 project: project, 268 } 269 if err := datastore.AllocateIDs(ctx, clMutation.CL); err != nil { 270 return nil, errors.Annotate(err, "failed to allocate new CL ID for %q", eid).Tag(transient.Tag).Err() 271 } 272 if err := datastore.Put(ctx, &clMap{ExternalID: eid, InternalID: clMutation.CL.ID}); err != nil { 273 return nil, errors.Annotate(err, "failed to insert clMap entity for %q", eid).Tag(transient.Tag).Err() 274 } 275 clMutation.backup() 276 return clMutation, nil 277 } 278 279 // Begin starts mutation of one CL inside an existing transaction in the context of 280 // the given LUCI project. 281 func (m *Mutator) Begin(ctx context.Context, project string, id common.CLID) (*CLMutation, error) { 282 clMutation := &CLMutation{ 283 CL: &CL{ID: id}, 284 m: m, 285 trans: datastore.CurrentTransaction(ctx), 286 project: project, 287 } 288 if clMutation.trans == nil { 289 panic(fmt.Errorf("changelist.Mutator.Begin must be called inside an existing Datastore transaction")) 290 } 291 switch err := datastore.Get(ctx, clMutation.CL); { 292 case err == datastore.ErrNoSuchEntity: 293 return nil, errors.Annotate(err, "CL %d doesn't exist", id).Err() 294 case err != nil: 295 return nil, errors.Annotate(err, "failed to get CL %d", id).Tag(transient.Tag).Err() 296 } 297 clMutation.backup() 298 return clMutation, nil 299 } 300 301 // Adopt starts a mutation of a given CL which was just read from Datastore. 302 // 303 // CL must have been loaded in the same Datastore transaction. 304 // CL must have been kept read-only after loading. It's OK to modify it after 305 // CLMutation is returned. 306 // 307 // Adopt exists when there is substantial advantage in batching loading of CL 308 // and non-CL entities in a single Datastore RPC. 309 // Prefer to use Begin unless performance consideration is critical. 310 func (m *Mutator) Adopt(ctx context.Context, project string, cl *CL) *CLMutation { 311 clMutation := &CLMutation{ 312 CL: cl, 313 m: m, 314 trans: datastore.CurrentTransaction(ctx), 315 project: project, 316 } 317 if clMutation.trans == nil { 318 panic(fmt.Errorf("changelist.Mutator.Adopt must be called inside an existing Datastore transaction")) 319 } 320 clMutation.backup() 321 return clMutation 322 } 323 324 func (clm *CLMutation) backup() { 325 clm.id = clm.CL.ID 326 clm.externalID = clm.CL.ExternalID 327 clm.priorEversion = clm.CL.EVersion 328 clm.priorUpdateTime = clm.CL.UpdateTime 329 if p := clm.CL.Snapshot.GetLuciProject(); p != "" { 330 clm.priorProject = p 331 } 332 clm.priorMinEquivalentPatchset = clm.CL.Snapshot.GetMinEquivalentPatchset() 333 } 334 335 // Finalize finalizes CL mutation. 336 // 337 // Must be called at most once. 338 // Must be called in the same Datastore transaction as Begin() which began the 339 // CL mutation. 340 func (clm *CLMutation) Finalize(ctx context.Context) (*CL, error) { 341 clm.finalize(ctx) 342 if err := datastore.Put(ctx, clm.CL); err != nil { 343 return nil, errors.Annotate(err, "failed to put CL %d", clm.id).Tag(transient.Tag).Err() 344 } 345 if err := clm.m.dispatchBatchNotify(ctx, clm); err != nil { 346 return nil, err 347 } 348 return clm.CL, nil 349 } 350 351 func (clm *CLMutation) finalize(ctx context.Context) { 352 switch t := datastore.CurrentTransaction(ctx); { 353 case clm.trans == nil: 354 panic(fmt.Errorf("changelist.CLMutation.Finalize called the second time")) 355 case t == nil: 356 panic(fmt.Errorf("changelist.CLMutation.Finalize must be called inside an existing Datastore transaction")) 357 case t != clm.trans: 358 panic(fmt.Errorf("changelist.CLMutation.Finalize called inside a different Datastore transaction")) 359 } 360 clm.trans = nil 361 362 switch { 363 case clm.id != clm.CL.ID: 364 panic(fmt.Errorf("CL.ID must not be modified")) 365 case clm.externalID != clm.CL.ExternalID: 366 panic(fmt.Errorf("CL.ExternalID must not be modified")) 367 case clm.priorEversion != clm.CL.EVersion: 368 panic(fmt.Errorf("CL.EVersion must not be modified")) 369 case !clm.priorUpdateTime.Equal(clm.CL.UpdateTime): 370 panic(fmt.Errorf("CL.UpdateTime must not be modified")) 371 } 372 clm.CL.EVersion++ 373 clm.CL.UpdateTime = datastore.RoundTime(clock.Now(ctx).UTC()) 374 clm.CL.UpdateRetentionKey() 375 } 376 377 // BeginBatch starts a batch of CL mutations within the same Datastore 378 // transaction. 379 func (m *Mutator) BeginBatch(ctx context.Context, project string, ids common.CLIDs) ([]*CLMutation, error) { 380 trans := datastore.CurrentTransaction(ctx) 381 if trans == nil { 382 panic(fmt.Errorf("changelist.Mutator.BeginBatch must be called inside an existing Datastore transaction")) 383 } 384 cls, err := LoadCLsByIDs(ctx, ids) 385 if err != nil { 386 return nil, err 387 } 388 muts := make([]*CLMutation, len(ids)) 389 for i, cl := range cls { 390 muts[i] = &CLMutation{ 391 CL: cl, 392 m: m, 393 trans: trans, 394 project: project, 395 } 396 muts[i].backup() 397 } 398 return muts, nil 399 } 400 401 // FinalizeBatch finishes a batch of CL mutations within the same Datastore 402 // transaction. 403 // 404 // The given mutations can originate from either Begin or BeginBatch calls. 405 // The only requirement is that they must all originate within the current 406 // Datastore transaction. 407 // 408 // It also transactionally schedules tasks to cancel stale tryjobs for any 409 // CLs in the batch whose minEquivPatchset has changed. 410 func (m *Mutator) FinalizeBatch(ctx context.Context, muts []*CLMutation) ([]*CL, error) { 411 cls := make([]*CL, len(muts)) 412 for i, mut := range muts { 413 mut.finalize(ctx) 414 cls[i] = mut.CL 415 } 416 if err := datastore.Put(ctx, cls); err != nil { 417 return nil, errors.Annotate(err, "failed to put %d CLs", len(cls)).Tag(transient.Tag).Err() 418 } 419 if err := m.dispatchBatchNotify(ctx, muts...); err != nil { 420 return nil, err 421 } 422 return cls, nil 423 } 424 425 /////////////////////////////////////////////////////////////////////////////// 426 // Internal implementation of notification dispatch. 427 428 // projects returns which LUCI projects to notify. 429 func (clm *CLMutation) projects() []string { 430 if clm.priorProject != "" && clm.project != clm.priorProject { 431 return []string{clm.project, clm.priorProject} 432 } 433 return []string{clm.project} 434 } 435 436 func (m *Mutator) dispatchBatchNotify(ctx context.Context, muts ...*CLMutation) error { 437 batch := &BatchOnCLUpdatedTask{ 438 // There are usually at most 2 Projects and 2 Runs being notified. 439 Projects: make(map[string]*CLUpdatedEvents, 2), 440 Runs: make(map[string]*CLUpdatedEvents, 2), 441 } 442 for _, mut := range muts { 443 e := &CLUpdatedEvent{Clid: int64(mut.CL.ID), Eversion: mut.CL.EVersion} 444 for _, p := range mut.projects() { 445 batch.Projects[p] = batch.Projects[p].append(e) 446 } 447 for _, r := range mut.CL.IncompleteRuns { 448 batch.Runs[string(r)] = batch.Runs[string(r)].append(e) 449 } 450 if mut.CL.Snapshot != nil && mut.priorMinEquivalentPatchset != 0 && mut.priorMinEquivalentPatchset < mut.CL.Snapshot.GetMinEquivalentPatchset() { 451 // add 1 second delay to allow run to finalize so that Tryjobs can be 452 // cancelled right away. 453 eta := clock.Now(ctx).UTC().Add(1 * time.Second) 454 if err := m.tj.ScheduleCancelStale(ctx, mut.id, mut.priorMinEquivalentPatchset, mut.CL.Snapshot.GetMinEquivalentPatchset(), eta); err != nil { 455 return err 456 } 457 } 458 } 459 err := m.tqd.AddTask(ctx, &tq.Task{ 460 Title: fmt.Sprintf("%s/%d-cls/%d-prjs/%d-runs", muts[0].project, len(muts), len(batch.GetProjects()), len(batch.GetRuns())), 461 Payload: batch, 462 }) 463 if err != nil { 464 return errors.Annotate(err, "failed to add BatchOnCLUpdatedTask to TQ").Err() 465 } 466 return nil 467 } 468 469 func (m *Mutator) handleBatchOnCLUpdatedTask(ctx context.Context, batch *BatchOnCLUpdatedTask) error { 470 errs := parallel.WorkPool(min(16, len(batch.GetProjects())+len(batch.GetRuns())), func(work chan<- func() error) { 471 for project, events := range batch.GetProjects() { 472 project, events := project, events 473 work <- func() error { return m.pm.NotifyCLsUpdated(ctx, project, events) } 474 } 475 for run, events := range batch.GetRuns() { 476 run, events := run, events 477 work <- func() error { return m.rm.NotifyCLsUpdated(ctx, common.RunID(run), events) } 478 } 479 }) 480 return common.MostSevereError(errs) 481 } 482 483 func (b *CLUpdatedEvents) append(e *CLUpdatedEvent) *CLUpdatedEvents { 484 if b == nil { 485 return &CLUpdatedEvents{Events: []*CLUpdatedEvent{e}} 486 } 487 b.Events = append(b.Events, e) 488 return b 489 }