go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/tryjob/model.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 tryjob
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"strconv"
    21  	"strings"
    22  	"time"
    23  
    24  	"go.chromium.org/luci/common/errors"
    25  	"go.chromium.org/luci/common/retry/transient"
    26  	"go.chromium.org/luci/gae/service/datastore"
    27  
    28  	"go.chromium.org/luci/cv/internal/common"
    29  )
    30  
    31  const (
    32  	// TryjobKind is the Datastore entity kind for Tryjob.
    33  	TryjobKind = "Tryjob"
    34  )
    35  
    36  // Tryjob is an entity tracking CV Tryjobs.
    37  type Tryjob struct {
    38  	// $kind must match TryjobKind.
    39  	_kind  string                `gae:"$kind,Tryjob"`
    40  	_extra datastore.PropertyMap `gae:"-,extra"`
    41  
    42  	// ID is the Tryjob ID, autogenerated by the Datastore.
    43  	ID common.TryjobID `gae:"$id"`
    44  	// ExternalID is a Tryjob ID in external system, e.g. Buildbucket.
    45  	//
    46  	// There can be at most one Tryjob with a given ExternalID.
    47  	ExternalID ExternalID `gae:",noindex"` // Indexed in tryjobMap entities.
    48  	// EVersion is the entity version.
    49  	//
    50  	// It increments by one upon every successful modification.
    51  	EVersion int64 `gae:",noindex"`
    52  	// EntityCreateTime is the timestamp when this entity was created.
    53  	//
    54  	// NOTE: This is not the backend's tryjob creation time, which is stored in
    55  	// .Result.CreateTime.
    56  	EntityCreateTime time.Time `gae:",noindex"`
    57  	// UpdateTime is the timestamp when this entity was last updated.
    58  	//
    59  	// NOTE: This is not the backend's tryjob update time, which is stored in
    60  	// .Result.UpdateTime.
    61  	EntityUpdateTime time.Time `gae:",noindex"`
    62  
    63  	// RetentionKey is for data retention purpose.
    64  	//
    65  	// It is indexed and tries to avoid hot areas in the index. The format is
    66  	// `{shard_key}/{unix_time_of_EntityUpdateTime}`. Shard key is the last 2
    67  	// digit of ID with left padded zero. Unix timestamp is a 10 digit integer
    68  	// with left padded zero if necessary.
    69  	RetentionKey string
    70  
    71  	// ReuseKey is used to quickly decide if this Tryjob can be reused by a run.
    72  	//
    73  	// Note that, even if reuse is allowed here, reuse is still subjected to
    74  	// other restrictions (for example, Tryjob is not fresh enough for the run).
    75  	//
    76  	// reusekey is currently computed in the following way:
    77  	//  base64(
    78  	//    sha256(
    79  	//      '\0'.join(sorted('%d/%d' % (cl.ID, cl.minEquiPatchSet) for cl in cls))
    80  	//    )
    81  	//  )
    82  	//
    83  	// Indexed
    84  	ReuseKey string
    85  
    86  	// Definition of the tryjob.
    87  	//
    88  	// Immutable.
    89  	Definition *Definition
    90  
    91  	// Status of the Tryjob.
    92  	Status Status `gae:",noindex"`
    93  
    94  	// Result of the Tryjob.
    95  	//
    96  	// Must be set if Status is ENDED.
    97  	// May be set if Status is TRIGGERED.
    98  	//
    99  	// It's used by the Run Manager.
   100  	Result *Result
   101  
   102  	// LaunchedBy is the Run that launches this Tryjob.
   103  	//
   104  	// May be unset if the Tryjob was not launched by CV (e.g. through Gerrit
   105  	// UI), in which case ReusedBy should have at least one Run.
   106  	LaunchedBy common.RunID `gae:",noindex"`
   107  
   108  	// ReusedBy are the Runs that are interested in the result of this Tryjob.
   109  	ReusedBy common.RunIDs `gae:",noindex"`
   110  
   111  	// CLPatchsets is an array of CLPatchset that each identify a specific
   112  	// patchset in a specific CL.
   113  	//
   114  	// The values are to be computed by MakeCLPatchset().
   115  	// See its documentation for details.
   116  	//
   117  	// Sorted and Indexed.
   118  	CLPatchsets CLPatchsets
   119  
   120  	// UntriggeredReason is the reason why LUCI CV doesn't trigger the Tryjob.
   121  	UntriggeredReason string `gae:",noindex"`
   122  }
   123  
   124  // DO NOT decrease the shard. It will cause olds tryjobs that are out of
   125  // retention period in the shard not getting wiped out.
   126  const retentionKeyShards = 100
   127  
   128  var _ datastore.PropertyLoadSaver = (*Tryjob)(nil)
   129  
   130  // Save implements datastore.PropertyLoadSaver.
   131  //
   132  // Makes sure the EntityUpdateTime and RetentionKey are always updated.
   133  func (tj *Tryjob) Save(withMeta bool) (datastore.PropertyMap, error) {
   134  	if tj.EntityUpdateTime.IsZero() { // be defensive
   135  		tj.EntityUpdateTime = datastore.RoundTime(time.Now().UTC())
   136  	}
   137  	tj.RetentionKey = fmt.Sprintf("%02d/%010d", tj.ID%retentionKeyShards, tj.EntityUpdateTime.Unix())
   138  	return datastore.GetPLS(tj).Save(withMeta)
   139  }
   140  
   141  // Load implements datastore.PropertyLoadSaver.
   142  func (tj *Tryjob) Load(p datastore.PropertyMap) error {
   143  	return datastore.GetPLS(tj).Load(p)
   144  }
   145  
   146  // CondDelete conditionally deletes Tryjob and corresponding tryjobMap entities.
   147  //
   148  // The deletion would only proceed if the loaded tryjob has the same EVersion
   149  // as the provided one. The deletion would happen in a transaction to make sure
   150  // the deletion of Tryjob and tryjobMap entities are atomic.
   151  func CondDelete(ctx context.Context, tjID common.TryjobID, expectedEVersion int64) error {
   152  	if expectedEVersion <= 0 {
   153  		return errors.New("expected EVersion must be larger than 0")
   154  	}
   155  
   156  	return datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   157  		tj := &Tryjob{ID: tjID}
   158  		switch err := datastore.Get(ctx, tj); {
   159  		case errors.Is(err, datastore.ErrNoSuchEntity):
   160  			return nil // tryjob already gets deleted
   161  		case err != nil:
   162  			return errors.Annotate(err, "failed to load tryjob %d", tjID).Tag(transient.Tag).Err()
   163  		case tj.EVersion != expectedEVersion:
   164  			return errors.Reason("request to delete tryjob %d at EVersion: %d, got EVersion: %d", tjID, expectedEVersion, tj.EVersion).Err()
   165  		}
   166  		toDelete := []any{tj}
   167  		if tj.ExternalID != "" {
   168  			// some tryjobs might not have external ID populated.
   169  			toDelete = append(toDelete, &tryjobMap{ExternalID: tj.ExternalID})
   170  		}
   171  		if err := datastore.Delete(ctx, toDelete); err != nil {
   172  			return errors.Annotate(err, "failed to delete tryjob %d", tjID).Tag(transient.Tag).Err()
   173  		}
   174  		return nil
   175  	}, nil)
   176  }
   177  
   178  // tryjobMap is intended to quickly determine if a given ExternalID is
   179  // associated with a Tryjob entity in the datastore.
   180  //
   181  // This also ensures that at most one TryjobID will be associated with a given
   182  // ExternalID.
   183  type tryjobMap struct {
   184  	_kind string `gae:"$kind,TryjobMap"`
   185  
   186  	// ExternalID is an ID for the tryjob in the external backend.
   187  	//
   188  	// Making this the key of the map ensures uniqueness.
   189  	ExternalID ExternalID `gae:"$id"`
   190  
   191  	// InternalID is auto-generated by Datastore for Tryjob entity.
   192  	InternalID common.TryjobID `gae:",noindex"` // int64. Indexed in Tryjob entities.
   193  }
   194  
   195  // LUCIProject() returns the project in the context of which the Tryjob is
   196  // updated, and which is thus allowed to "read" the Tryjob.
   197  //
   198  // In the case of Buildbucket, this may be different from the LUCI project to
   199  // which the corresponding build belongs. For example, consider a "v8" project
   200  // with configuration saying to trigger "chromium/try/linux_rel" builder: when
   201  // CV triggers a new tryjob T for a "v8" Run, T.LUCIProject() will be "v8" even
   202  // though the build itself will be in the "chromium/try" Buildbucket bucket.
   203  //
   204  // In general, a Run of project P must not re-use tryjob T if
   205  // T.LUCIProject() != P, until it has been verified with the tryjob backend
   206  // that P has access to T.
   207  func (t *Tryjob) LUCIProject() string {
   208  	if t.LaunchedBy != "" {
   209  		return t.LaunchedBy.LUCIProject()
   210  	}
   211  	if len(t.ReusedBy) == 0 {
   212  		panic("tryjob is not associated with any runs")
   213  	}
   214  	return t.ReusedBy[0].LUCIProject()
   215  }
   216  
   217  // AllWatchingRuns returns the IDs for the Runs that care about this tryjob.
   218  //
   219  // This includes the triggerer (if the tryjob was triggered by CV) and all the
   220  // Runs reusing this tryjob (if any).
   221  func (t *Tryjob) AllWatchingRuns() common.RunIDs {
   222  	ret := make(common.RunIDs, 0, 1+len(t.ReusedBy))
   223  	if t.LaunchedBy != "" {
   224  		ret = append(ret, t.LaunchedBy)
   225  	}
   226  	return append(ret, t.ReusedBy...)
   227  }
   228  
   229  // IsEnded checks whether the Tryjob's status is final (can not change again).
   230  func (t *Tryjob) IsEnded() bool {
   231  	switch t.Status {
   232  	case Status_CANCELLED, Status_ENDED, Status_UNTRIGGERED:
   233  		return true
   234  	case Status_PENDING, Status_TRIGGERED:
   235  		return false
   236  	default:
   237  		panic(fmt.Errorf("unexpected tryjob status %s", t.Status.String()))
   238  	}
   239  }
   240  
   241  // CLPatchsets is a slice of `CLPatchset`s.
   242  //
   243  // Implements sort.Interface
   244  type CLPatchsets []CLPatchset
   245  
   246  // Len implements sort.Interface.
   247  func (c CLPatchsets) Len() int {
   248  	return len(c)
   249  }
   250  
   251  // Less implements sort.Interface.
   252  func (c CLPatchsets) Less(i int, j int) bool {
   253  	return c[i] < c[j]
   254  }
   255  
   256  // Swap implements sort.Interface.
   257  func (c CLPatchsets) Swap(i int, j int) {
   258  	c[i], c[j] = c[j], c[i]
   259  }
   260  
   261  // CLPatchset is a value computed combining a CL's ID and a patchset number.
   262  //
   263  // This is intended to efficiently query Tryjob entities associated with a
   264  // patchset.
   265  //
   266  // The values are hex string encoded and padded so that lexicographical sorting
   267  // will put the patchsets for a given CL together.
   268  type CLPatchset string
   269  
   270  const clPatchsetEncodingVersion = 1
   271  
   272  // MakeCLPatchset computes a new CLPatchset value.
   273  func MakeCLPatchset(cl common.CLID, patchset int32) CLPatchset {
   274  	return CLPatchset(fmt.Sprintf("%02x/%016x/%08x", clPatchsetEncodingVersion, cl, patchset))
   275  }
   276  
   277  // Parse extracts CLID and Patchset number from a valid CLPatchset value.
   278  //
   279  // Returns an error if the format is unexpected.
   280  func (cp CLPatchset) Parse() (common.CLID, int32, error) {
   281  	var clid, patchset int64
   282  	values := strings.Split(string(cp), "/")
   283  	// If any valid encoding versions require a different number of values,
   284  	// check it here.
   285  	switch len(values) {
   286  	case 3:
   287  		// Version 1 requires three slash-separated values.
   288  	default:
   289  		return 0, 0, errors.Reason("CLPatchset in unexpected format %q", cp).Err()
   290  	}
   291  
   292  	ver, err := strconv.ParseInt(values[0], 16, 32)
   293  	switch {
   294  	case err != nil:
   295  		return 0, 0, errors.Annotate(err, "version segment in unexpected format %q", values[0]).Err()
   296  	case ver == clPatchsetEncodingVersion:
   297  		if len(values) != 3 {
   298  			panic(fmt.Errorf("impossible: number of values is not 3"))
   299  		}
   300  		clid, err = strconv.ParseInt(values[1], 16, 64)
   301  		if err != nil {
   302  			return 0, 0, errors.Annotate(err, "clid segment in unexpected format %q", values[1]).Err()
   303  		}
   304  		patchset, err = strconv.ParseInt(values[2], 16, 32)
   305  		if err != nil {
   306  			return 0, 0, errors.Annotate(err, "patchset segment in unexpected format %q", values[2]).Err()
   307  		}
   308  		return common.CLID(clid), int32(patchset), nil
   309  	default:
   310  		return 0, 0, errors.Reason("unsupported version %d", ver).Err()
   311  	}
   312  }