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 }