go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/ingestion/control/span.go (about) 1 // Copyright 2022 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 control 16 17 import ( 18 "context" 19 "fmt" 20 "time" 21 22 "cloud.google.com/go/spanner" 23 "google.golang.org/protobuf/proto" 24 25 "go.chromium.org/luci/common/errors" 26 "go.chromium.org/luci/server/span" 27 28 ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto" 29 spanutil "go.chromium.org/luci/analysis/internal/span" 30 "go.chromium.org/luci/analysis/pbutil" 31 analysispb "go.chromium.org/luci/analysis/proto/v1" 32 ) 33 34 // JoinStatsHours is the number of previous hours 35 // ReadPresubmitRunJoinStatistics/ReadBuildJoinStatistics reads statistics for. 36 const JoinStatsHours = 36 37 38 // Entry is an ingestion control record, used to de-duplicate build ingestions 39 // and synchronise them with presubmit results (if required). 40 type Entry struct { 41 // The identity of the build which is being ingested. 42 // The scheme is: {buildbucket host name}/{build id}. 43 BuildID string 44 45 // Project is the LUCI Project the build belongs to. Used for 46 // metrics monitoring join performance. 47 BuildProject string 48 49 // BuildResult is the result of the build bucket build, to be passed 50 // to the result ingestion task. This is nil if the result is 51 // not yet known. 52 BuildResult *ctlpb.BuildResult 53 54 // BuildJoinedTime is the Spanner commit time the build result was 55 // populated. If the result has not yet been populated, this is the zero time. 56 BuildJoinedTime time.Time 57 58 // HasInvocation records wether the build has an associated (ResultDB) 59 // invocation. 60 // Value only populated once either BuildResult or InvocationResult populated. 61 HasInvocation bool 62 63 // Project is the LUCI Project the invocation belongs to. Used for 64 // metrics monitoring join performance. 65 InvocationProject string 66 67 // InvocationResult is the result of the invocation, to be passed 68 // to the result ingestion task. This is nil if the result is 69 // not yet known. 70 InvocationResult *ctlpb.InvocationResult 71 72 // InvocationJoinedTime is the Spanner commit time the invocation result 73 // was populated. If the result has not yet been populated, this is the zero time. 74 InvocationJoinedTime time.Time 75 76 // IsPresubmit records whether the build is part of a presubmit run. 77 // If true, ingestion should wait for the presubmit result to be 78 // populated (in addition to the build result) before commencing 79 // ingestion. 80 // Value only populated once either BuildResult or PresubmitResult populated. 81 IsPresubmit bool 82 83 // PresubmitProject is the LUCI Project the presubmit run belongs to. 84 // This may differ from the LUCI Project teh build belongs to. Used for 85 // metrics monitoring join performance. 86 PresubmitProject string 87 88 // PresubmitResult is result of the presubmit run, to be passed to the 89 // result ingestion task. This is nil if the result is not yet known. 90 PresubmitResult *ctlpb.PresubmitResult 91 92 // PresubmitJoinedTime is the Spanner commit time the presubmit result was 93 // populated. If the result has not yet been populated, this is the zero time. 94 PresubmitJoinedTime time.Time 95 96 // LastUpdated is the Spanner commit time the row was last updated. 97 LastUpdated time.Time 98 99 // The number of test result ingestion tasks have been created for this 100 // invocation. 101 // Used to avoid duplicate scheduling of ingestion tasks. If the page_index 102 // is the index of the page being processed, an ingestion task for the next 103 // page will only be created if (page_index + 1) == TaskCount. 104 TaskCount int64 105 } 106 107 // BuildID returns the control record key for a buildbucket build with the 108 // given hostname and ID. 109 func BuildID(hostname string, id int64) string { 110 return fmt.Sprintf("%s/%v", hostname, id) 111 } 112 113 // Read reads ingestion control records for the specified build IDs. 114 // Exactly one *Entry is returned for each build ID. The result entry 115 // at index i corresponds to the buildIDs[i]. 116 // If a record does not exist for the given build ID, an *Entry of 117 // nil is returned for that build ID. 118 func Read(ctx context.Context, buildIDs []string) ([]*Entry, error) { 119 uniqueIDs := make(map[string]struct{}) 120 var keys []spanner.Key 121 for _, buildID := range buildIDs { 122 keys = append(keys, spanner.Key{buildID}) 123 if _, ok := uniqueIDs[buildID]; ok { 124 return nil, fmt.Errorf("duplicate build ID %s", buildID) 125 } 126 uniqueIDs[buildID] = struct{}{} 127 } 128 cols := []string{ 129 "BuildID", 130 "BuildProject", 131 "BuildResult", 132 "BuildJoinedTime", 133 "HasInvocation", 134 "InvocationProject", 135 "InvocationResult", 136 "InvocationJoinedTime", 137 "IsPresubmit", 138 "PresubmitProject", 139 "PresubmitResult", 140 "PresubmitJoinedTime", 141 "LastUpdated", 142 "TaskCount", 143 } 144 entryByBuildID := make(map[string]*Entry) 145 rows := span.Read(ctx, "Ingestions", spanner.KeySetFromKeys(keys...), cols) 146 f := func(r *spanner.Row) error { 147 var buildID string 148 var buildProject spanner.NullString 149 var buildResultBytes []byte 150 var buildJoinedTime spanner.NullTime 151 var hasInvocation spanner.NullBool 152 var invocationProject spanner.NullString 153 var invocationResultBytes []byte 154 var invocationJoinedTime spanner.NullTime 155 var isPresubmit spanner.NullBool 156 var presubmitProject spanner.NullString 157 var presubmitResultBytes []byte 158 var presubmitJoinedTime spanner.NullTime 159 var lastUpdated time.Time 160 var taskCount spanner.NullInt64 161 162 err := r.Columns( 163 &buildID, 164 &buildProject, 165 &buildResultBytes, 166 &buildJoinedTime, 167 &hasInvocation, 168 &invocationProject, 169 &invocationResultBytes, 170 &invocationJoinedTime, 171 &isPresubmit, 172 &presubmitProject, 173 &presubmitResultBytes, 174 &presubmitJoinedTime, 175 &lastUpdated, 176 &taskCount) 177 if err != nil { 178 return errors.Annotate(err, "read Ingestions row").Err() 179 } 180 var buildResult *ctlpb.BuildResult 181 if buildResultBytes != nil { 182 buildResult = &ctlpb.BuildResult{} 183 if err := proto.Unmarshal(buildResultBytes, buildResult); err != nil { 184 return errors.Annotate(err, "unmarshal build result").Err() 185 } 186 } 187 var invocationResult *ctlpb.InvocationResult 188 if invocationResultBytes != nil { 189 invocationResult = &ctlpb.InvocationResult{} 190 if err := proto.Unmarshal(invocationResultBytes, invocationResult); err != nil { 191 return errors.Annotate(err, "unmarshal invocation result").Err() 192 } 193 } 194 var presubmitResult *ctlpb.PresubmitResult 195 if presubmitResultBytes != nil { 196 presubmitResult = &ctlpb.PresubmitResult{} 197 if err := proto.Unmarshal(presubmitResultBytes, presubmitResult); err != nil { 198 return errors.Annotate(err, "unmarshal presubmit result").Err() 199 } 200 } 201 202 entryByBuildID[buildID] = &Entry{ 203 BuildID: buildID, 204 BuildProject: buildProject.StringVal, 205 BuildResult: buildResult, 206 BuildJoinedTime: buildJoinedTime.Time, 207 // HasInvocation uses NULL to indicate false. 208 HasInvocation: hasInvocation.Valid && hasInvocation.Bool, 209 InvocationProject: invocationProject.StringVal, 210 InvocationResult: invocationResult, 211 InvocationJoinedTime: invocationJoinedTime.Time, 212 // IsPresubmit uses NULL to indicate false. 213 IsPresubmit: isPresubmit.Valid && isPresubmit.Bool, 214 PresubmitProject: presubmitProject.StringVal, 215 PresubmitResult: presubmitResult, 216 PresubmitJoinedTime: presubmitJoinedTime.Time, 217 LastUpdated: lastUpdated, 218 TaskCount: taskCount.Int64, 219 } 220 return nil 221 } 222 223 if err := rows.Do(f); err != nil { 224 return nil, err 225 } 226 227 var result []*Entry 228 for _, buildID := range buildIDs { 229 // If the entry does not exist, return nil for that build ID. 230 entry := entryByBuildID[buildID] 231 result = append(result, entry) 232 } 233 return result, nil 234 } 235 236 // InsertOrUpdate creates or updates the given ingestion record. 237 // This operation is not safe to perform blindly; perform only in a 238 // read/write transaction with an attempted read of the corresponding entry. 239 func InsertOrUpdate(ctx context.Context, e *Entry) error { 240 if err := validateEntry(e); err != nil { 241 return err 242 } 243 update := map[string]any{ 244 "BuildId": e.BuildID, 245 "BuildProject": spanner.NullString{Valid: e.BuildProject != "", StringVal: e.BuildProject}, 246 "BuildResult": e.BuildResult, 247 "BuildJoinedTime": spanner.NullTime{Valid: e.BuildJoinedTime != time.Time{}, Time: e.BuildJoinedTime}, 248 "HasInvocation": spanner.NullBool{Valid: e.HasInvocation, Bool: e.HasInvocation}, 249 "InvocationProject": spanner.NullString{Valid: e.InvocationProject != "", StringVal: e.InvocationProject}, 250 "InvocationResult": e.InvocationResult, 251 "InvocationJoinedTime": spanner.NullTime{Valid: e.InvocationJoinedTime != time.Time{}, Time: e.InvocationJoinedTime}, 252 "IsPresubmit": spanner.NullBool{Valid: e.IsPresubmit, Bool: e.IsPresubmit}, 253 "PresubmitProject": spanner.NullString{Valid: e.PresubmitProject != "", StringVal: e.PresubmitProject}, 254 "PresubmitResult": e.PresubmitResult, 255 "PresubmitJoinedTime": spanner.NullTime{Valid: e.PresubmitJoinedTime != time.Time{}, Time: e.PresubmitJoinedTime}, 256 "LastUpdated": spanner.CommitTimestamp, 257 "TaskCount": e.TaskCount, 258 } 259 m := spanutil.InsertOrUpdateMap("Ingestions", update) 260 span.BufferWrite(ctx, m) 261 return nil 262 } 263 264 // JoinStatistics captures indicators of how well two join inputs 265 // (e.g. buildbucket build completions and presubmit run completions, 266 // or buildbucket build completions and invocation finalizations) 267 // are being joined. 268 type JoinStatistics struct { 269 // TotalByHour captures the number of builds in the ingestions 270 // table eligible to be joined (i.e. have the left-hand join input). 271 // 272 // Data is broken down by by hours since the build became 273 // eligible for joining. Index 0 indicates the period 274 // from ]-1 hour, now], index 1 indicates [-2 hour, -1 hour] and so on. 275 TotalByHour []int64 276 277 // JoinedByHour captures the number of builds in the ingestions 278 // table eligible to be joined, which were successfully joined (have 279 // results for both join inputs present). 280 // 281 // Data is broken down by by hours since the build became 282 // eligible for joining. Index 0 indicates the period 283 // from ]-1 hour, now], index 1 indicates [-2 hour, -1 hour] and so on. 284 JoinedByHour []int64 285 } 286 287 // ReadBuildToPresubmitRunJoinStatistics measures the performance joining 288 // builds to presubmit runs. 289 // 290 // The statistics returned uses completed builds with a presubmit run 291 // as the denominator for measuring join performance. 292 // The performance joining to presubmit run results is then measured. 293 // Data is broken down by the project of the buildbucket build. 294 // The last 36 hours of data for each project is returned. Hours are 295 // measured since the buildbucket build result was received. 296 func ReadBuildToPresubmitRunJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) { 297 stmt := spanner.NewStatement(` 298 SELECT 299 BuildProject as project, 300 TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), BuildJoinedTime, HOUR) as hour, 301 COUNT(*) as total, 302 COUNTIF(PresubmitResult IS NOT NULL) as joined, 303 FROM Ingestions 304 WHERE IsPresubmit 305 AND BuildJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR) 306 GROUP BY project, hour 307 `) 308 stmt.Params["hours"] = JoinStatsHours 309 return readJoinStatistics(ctx, stmt) 310 } 311 312 // ReadPresubmitToBuildJoinStatistics measures the performance joining 313 // presubmit runs to builds. 314 // 315 // The statistics returned uses builds as reported by completed 316 // presubmit runs as the denominator for measuring join performance. 317 // The performance joining to buildbucket build results is then measured. 318 // Data is broken down by the project of the presubmit run. 319 // The last 36 hours of data for each project is returned. Hours are 320 // measured since the presubmit run result was received. 321 func ReadPresubmitToBuildJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) { 322 stmt := spanner.NewStatement(` 323 SELECT 324 PresubmitProject as project, 325 TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), PresubmitJoinedTime, HOUR) as hour, 326 COUNT(*) as total, 327 COUNTIF(BuildResult IS NOT NULL) as joined, 328 FROM Ingestions 329 WHERE IsPresubmit 330 AND PresubmitJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR) 331 GROUP BY project, hour 332 `) 333 stmt.Params["hours"] = JoinStatsHours 334 return readJoinStatistics(ctx, stmt) 335 } 336 337 // ReadBuildToInvocationJoinStatistics measures the performance joining 338 // builds to finalized invocations. 339 // 340 // The statistics returned uses completed builds with an invocation 341 // as the denominator for measuring join performance. 342 // The performance joining to finalized invocations is then measured. 343 // Data is broken down by the project of the buildbucket build. 344 // The last 36 hours of data for each project is returned. Hours are 345 // measured since the buildbucket build result was received. 346 func ReadBuildToInvocationJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) { 347 stmt := spanner.NewStatement(` 348 SELECT 349 BuildProject as project, 350 TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), BuildJoinedTime, HOUR) as hour, 351 COUNT(*) as total, 352 COUNTIF(InvocationResult IS NOT NULL) as joined, 353 FROM Ingestions 354 WHERE HasInvocation 355 AND BuildJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR) 356 GROUP BY project, hour 357 `) 358 stmt.Params["hours"] = JoinStatsHours 359 return readJoinStatistics(ctx, stmt) 360 } 361 362 // ReadInvocationToBuildJoinStatistics measures the performance joining 363 // finalized invocations to builds. 364 // 365 // The statistics returned uses finalized invocations (for buildbucket builds) 366 // as the denominator for measuring join performance. 367 // The performance joining to buildbucket build results is then measured. 368 // Data is broken down by the project of the ingested invocation (this 369 // should be the same as the ingested build, although it comes from a 370 // different source). 371 // The last 36 hours of data for each project is returned. Hours are 372 // measured since the finalized invocation was received. 373 func ReadInvocationToBuildJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) { 374 stmt := spanner.NewStatement(` 375 SELECT 376 InvocationProject as project, 377 TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), InvocationJoinedTime, HOUR) as hour, 378 COUNT(*) as total, 379 COUNTIF(BuildResult IS NOT NULL) as joined, 380 FROM Ingestions 381 WHERE HasInvocation 382 AND InvocationJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR) 383 GROUP BY project, hour 384 `) 385 stmt.Params["hours"] = JoinStatsHours 386 return readJoinStatistics(ctx, stmt) 387 } 388 389 func readJoinStatistics(ctx context.Context, stmt spanner.Statement) (map[string]JoinStatistics, error) { 390 result := make(map[string]JoinStatistics) 391 it := span.Query(ctx, stmt) 392 err := it.Do(func(r *spanner.Row) error { 393 var project string 394 var hour int64 395 var total, joined int64 396 397 err := r.Columns(&project, &hour, &total, &joined) 398 if err != nil { 399 return errors.Annotate(err, "read row").Err() 400 } 401 402 stats, ok := result[project] 403 if !ok { 404 stats = JoinStatistics{ 405 // Add zero data for all hours. 406 TotalByHour: make([]int64, JoinStatsHours), 407 JoinedByHour: make([]int64, JoinStatsHours), 408 } 409 } 410 stats.TotalByHour[hour] = total 411 stats.JoinedByHour[hour] = joined 412 413 result[project] = stats 414 return nil 415 }) 416 if err != nil { 417 return nil, errors.Annotate(err, "query presubmit join stats by project").Err() 418 } 419 return result, nil 420 } 421 422 func validateEntry(e *Entry) error { 423 if e.BuildID == "" { 424 return errors.New("build ID must be specified") 425 } 426 427 if e.BuildResult != nil { 428 if err := ValidateBuildResult(e.BuildResult); err != nil { 429 return errors.Annotate(err, "build result").Err() 430 } 431 if err := pbutil.ValidateProject(e.BuildProject); err != nil { 432 return errors.Annotate(err, "build project").Err() 433 } 434 } else { 435 if e.BuildProject != "" { 436 return errors.New("build project must only be specified" + 437 " if build result is specified") 438 } 439 } 440 441 if e.InvocationResult != nil { 442 if !e.HasInvocation { 443 return errors.New("invocation result must not be set unless HasInvocation is set") 444 } 445 if err := pbutil.ValidateProject(e.InvocationProject); err != nil { 446 return errors.Annotate(err, "invocation project").Err() 447 } 448 } else { 449 if e.InvocationProject != "" { 450 return errors.New("invocation project must only be specified" + 451 " if invocation result is specified") 452 } 453 } 454 455 if e.PresubmitResult != nil { 456 if !e.IsPresubmit { 457 return errors.New("presubmit result must not be set unless IsPresubmit is set") 458 } 459 if err := ValidatePresubmitResult(e.PresubmitResult); err != nil { 460 return errors.Annotate(err, "presubmit result").Err() 461 } 462 if err := pbutil.ValidateProject(e.PresubmitProject); err != nil { 463 return errors.Annotate(err, "presubmit project").Err() 464 } 465 } else { 466 if e.PresubmitProject != "" { 467 return errors.New("presubmit project must only be specified" + 468 " if presubmit result is specified") 469 } 470 } 471 472 if e.TaskCount < 0 { 473 return errors.New("task count must be non-negative") 474 } 475 return nil 476 } 477 478 func ValidateBuildResult(r *ctlpb.BuildResult) error { 479 switch { 480 case r.Host == "": 481 return errors.New("host must be specified") 482 case r.Id == 0: 483 return errors.New("id must be specified") 484 case !r.CreationTime.IsValid(): 485 return errors.New("creation time must be specified") 486 case r.Project == "": 487 return errors.New("project must be specified") 488 case r.HasInvocation && r.ResultdbHost == "": 489 return errors.New("resultdb_host must be specified if has_invocation set") 490 case r.Builder == "": 491 return errors.New("builder must be specified") 492 case r.Status == analysispb.BuildStatus_BUILD_STATUS_UNSPECIFIED: 493 return errors.New("build status must be specified") 494 } 495 return nil 496 } 497 498 func ValidatePresubmitResult(r *ctlpb.PresubmitResult) error { 499 switch { 500 case r.PresubmitRunId == nil: 501 return errors.New("presubmit run ID must be specified") 502 case r.PresubmitRunId.System != "luci-cv": 503 // LUCI CV is currently the only supported system. 504 return errors.New("presubmit run system must be 'luci-cv'") 505 case r.PresubmitRunId.Id == "": 506 return errors.New("presubmit run system-specific ID must be specified") 507 case !r.CreationTime.IsValid(): 508 return errors.New("creation time must be specified and valid") 509 case r.Status == analysispb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_UNSPECIFIED: 510 return errors.New("status must be specified") 511 case r.Mode == analysispb.PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED: 512 return errors.New("mode must be specified") 513 } 514 return nil 515 }