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  }