go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/testresults/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 testresults contains methods for accessing test results in Spanner. 16 package testresults 17 18 import ( 19 "context" 20 "sort" 21 "text/template" 22 "time" 23 24 "cloud.google.com/go/spanner" 25 "google.golang.org/protobuf/types/known/durationpb" 26 27 "go.chromium.org/luci/common/errors" 28 "go.chromium.org/luci/server/span" 29 30 "go.chromium.org/luci/analysis/internal/pagination" 31 spanutil "go.chromium.org/luci/analysis/internal/span" 32 "go.chromium.org/luci/analysis/pbutil" 33 pb "go.chromium.org/luci/analysis/proto/v1" 34 ) 35 36 const pageTokenTimeFormat = time.RFC3339Nano 37 38 // The suffix used for all gerrit hostnames. 39 const GerritHostnameSuffix = "-review.googlesource.com" 40 41 var ( 42 // minTimestamp is the minimum Timestamp value in Spanner. 43 // https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#timestamp_type 44 MinSpannerTimestamp = time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) 45 // maxSpannerTimestamp is the max Timestamp value in Spanner. 46 // https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#timestamp_type 47 MaxSpannerTimestamp = time.Date(9999, time.December, 31, 23, 59, 59, 999999999, time.UTC) 48 ) 49 50 // Changelist represents a gerrit changelist. 51 type Changelist struct { 52 // Host is the gerrit hostname. E.g. chromium-review.googlesource.com. 53 Host string 54 Change int64 55 Patchset int64 56 OwnerKind pb.ChangelistOwnerKind 57 } 58 59 // SortChangelists sorts a slice of changelists to be in ascending 60 // lexicographical order by (host, change, patchset). 61 func SortChangelists(cls []Changelist) { 62 sort.Slice(cls, func(i, j int) bool { 63 // Returns true iff cls[i] is less than cls[j]. 64 if cls[i].Host < cls[j].Host { 65 return true 66 } 67 if cls[i].Host == cls[j].Host && cls[i].Change < cls[j].Change { 68 return true 69 } 70 if cls[i].Host == cls[j].Host && cls[i].Change == cls[j].Change && cls[i].Patchset < cls[j].Patchset { 71 return true 72 } 73 return false 74 }) 75 } 76 77 // Sources captures information about the code sources that were 78 // tested by a test result. 79 type Sources struct { 80 // 8-byte hash of the source reference (e.g. git branch) tested. 81 // This refers to the base commit/version tested, before any changelists 82 // are applied. 83 RefHash []byte 84 // The position along the source reference that was tested. 85 // This refers to the base commit/version tested, before any changelists 86 // are applied. 87 Position int64 88 // The gerrit changelists applied on top of the base version/commit. 89 // At most 10 changelists should be specified here, if there are more 90 // then limit to 10 and set HasDirtySources to true. 91 Changelists []Changelist 92 // Whether other modifications were made to the sources, not described 93 // by the fields above. For example, a package was upreved in the build. 94 // If this is set, then the source information is approximate: suitable 95 // for plotting results by source position the UI but not good enough 96 // for change point analysis. 97 IsDirty bool 98 } 99 100 // TestResult represents a row in the TestResults table. 101 type TestResult struct { 102 Project string 103 TestID string 104 PartitionTime time.Time 105 VariantHash string 106 IngestedInvocationID string 107 RunIndex int64 108 ResultIndex int64 109 IsUnexpected bool 110 RunDuration *time.Duration 111 Status pb.TestResultStatus 112 // Properties of the test verdict (stored denormalised) follow. 113 ExonerationReasons []pb.ExonerationReason 114 Sources Sources 115 // Properties of the invocation (stored denormalised) follow. 116 SubRealm string 117 IsFromBisection bool 118 } 119 120 // ReadTestResults reads test results from the TestResults table. 121 // Must be called in a spanner transactional context. 122 func ReadTestResults(ctx context.Context, keys spanner.KeySet, fn func(tr *TestResult) error) error { 123 var b spanutil.Buffer 124 fields := []string{ 125 "Project", "TestId", "PartitionTime", "VariantHash", "IngestedInvocationId", 126 "RunIndex", "ResultIndex", 127 "IsUnexpected", "RunDurationUsec", "Status", 128 "ExonerationReasons", 129 "SourceRefHash", "SourcePosition", 130 "ChangelistHosts", "ChangelistChanges", "ChangelistPatchsets", "ChangelistOwnerKinds", 131 "HasDirtySources", 132 "SubRealm", 133 "IsFromBisection", 134 } 135 return span.Read(ctx, "TestResults", keys, fields).Do( 136 func(row *spanner.Row) error { 137 tr := &TestResult{} 138 var runDurationUsec spanner.NullInt64 139 var isUnexpected spanner.NullBool 140 var sourceRefHash []byte 141 var sourcePosition spanner.NullInt64 142 var changelistHosts []string 143 var changelistChanges []int64 144 var changelistPatchsets []int64 145 var changelistOwnerKinds []string 146 var hasDirtySources spanner.NullBool 147 var isFromBisection spanner.NullBool 148 err := b.FromSpanner( 149 row, 150 &tr.Project, &tr.TestID, &tr.PartitionTime, &tr.VariantHash, &tr.IngestedInvocationID, 151 &tr.RunIndex, &tr.ResultIndex, 152 &isUnexpected, &runDurationUsec, &tr.Status, 153 &tr.ExonerationReasons, 154 &sourceRefHash, &sourcePosition, 155 &changelistHosts, &changelistChanges, &changelistPatchsets, &changelistOwnerKinds, 156 &hasDirtySources, 157 &tr.SubRealm, 158 &isFromBisection, 159 ) 160 if err != nil { 161 return err 162 } 163 if runDurationUsec.Valid { 164 runDuration := time.Microsecond * time.Duration(runDurationUsec.Int64) 165 tr.RunDuration = &runDuration 166 } 167 tr.IsUnexpected = isUnexpected.Valid && isUnexpected.Bool 168 tr.IsFromBisection = isFromBisection.Valid && isFromBisection.Bool 169 170 // Data in Spanner should be consistent, so commitPosition.Valid == 171 // (gitReferenceHash != nil). 172 if sourcePosition.Valid { 173 tr.Sources.RefHash = sourceRefHash 174 tr.Sources.Position = sourcePosition.Int64 175 } 176 177 // Data in spanner should be consistent, so 178 // len(changelistHosts) == len(changelistChanges) 179 // == len(changelistPatchsets). 180 // 181 // ChangeListOwnerKinds was retrofitted after the table 182 // was first created, so it should be of equal length 183 // only if present. It was introduced in November 2022, 184 // so this special-case can be deleted in March 2023+. 185 if len(changelistHosts) != len(changelistChanges) || 186 len(changelistChanges) != len(changelistPatchsets) || 187 (changelistOwnerKinds != nil && len(changelistOwnerKinds) != len(changelistPatchsets)) { 188 panic("Changelist arrays have mismatched length in Spanner") 189 } 190 changelists := make([]Changelist, 0, len(changelistHosts)) 191 for i := range changelistHosts { 192 var ownerKind pb.ChangelistOwnerKind 193 if changelistOwnerKinds != nil { 194 ownerKind = OwnerKindFromDB(changelistOwnerKinds[i]) 195 } 196 changelists = append(changelists, Changelist{ 197 Host: DecompressHost(changelistHosts[i]), 198 Change: changelistChanges[i], 199 Patchset: changelistPatchsets[i], 200 OwnerKind: ownerKind, 201 }) 202 } 203 tr.Sources.Changelists = changelists 204 tr.Sources.IsDirty = hasDirtySources.Valid && hasDirtySources.Bool 205 206 return fn(tr) 207 }) 208 } 209 210 // TestResultSaveCols is the set of columns written to in a test result save. 211 // Allocated here once to avoid reallocating on every test result save. 212 var TestResultSaveCols = []string{ 213 "Project", "TestId", "PartitionTime", "VariantHash", 214 "IngestedInvocationId", "RunIndex", "ResultIndex", 215 "IsUnexpected", "RunDurationUsec", "Status", 216 "ExonerationReasons", 217 "SourceRefHash", "SourcePosition", 218 "ChangelistHosts", "ChangelistChanges", "ChangelistPatchsets", 219 "ChangelistOwnerKinds", 220 "HasDirtySources", 221 "SubRealm", "IsFromBisection", 222 } 223 224 // SaveUnverified prepare a mutation to insert the test result into the 225 // TestResults table. The test result is not validated. 226 func (tr *TestResult) SaveUnverified() *spanner.Mutation { 227 var runDurationUsec spanner.NullInt64 228 if tr.RunDuration != nil { 229 runDurationUsec.Int64 = tr.RunDuration.Microseconds() 230 runDurationUsec.Valid = true 231 } 232 233 var sourceRefHash []byte 234 var sourcePosition spanner.NullInt64 235 if tr.Sources.Position > 0 && len(tr.Sources.RefHash) > 0 { 236 sourceRefHash = tr.Sources.RefHash 237 sourcePosition = spanner.NullInt64{Valid: true, Int64: tr.Sources.Position} 238 } 239 240 changelistHosts := make([]string, 0, len(tr.Sources.Changelists)) 241 changelistChanges := make([]int64, 0, len(tr.Sources.Changelists)) 242 changelistPatchsets := make([]int64, 0, len(tr.Sources.Changelists)) 243 changelistOwnerKinds := make([]string, 0, len(tr.Sources.Changelists)) 244 for _, cl := range tr.Sources.Changelists { 245 changelistHosts = append(changelistHosts, CompressHost(cl.Host)) 246 changelistChanges = append(changelistChanges, cl.Change) 247 changelistPatchsets = append(changelistPatchsets, int64(cl.Patchset)) 248 changelistOwnerKinds = append(changelistOwnerKinds, ownerKindToDB(cl.OwnerKind)) 249 } 250 251 hasDirtySources := spanner.NullBool{Bool: tr.Sources.IsDirty, Valid: tr.Sources.IsDirty} 252 253 isUnexpected := spanner.NullBool{Bool: tr.IsUnexpected, Valid: tr.IsUnexpected} 254 isFromBisection := spanner.NullBool{Bool: tr.IsFromBisection, Valid: tr.IsFromBisection} 255 256 exonerationReasons := tr.ExonerationReasons 257 if len(exonerationReasons) == 0 { 258 // Store absence of exonerations as a NULL value in the database 259 // rather than an empty array. Backfilling the column is too 260 // time consuming and NULLs use slightly less storage space. 261 exonerationReasons = nil 262 } 263 264 // Specify values in a slice directly instead of 265 // creating a map and using spanner.InsertOrUpdateMap. 266 // Profiling revealed ~15% of all CPU cycles spent 267 // ingesting test results were wasted generating a 268 // map and converting it back to the slice 269 // needed for a *spanner.Mutation using InsertOrUpdateMap. 270 // Ingestion appears to be CPU bound at times. 271 vals := []any{ 272 tr.Project, tr.TestID, tr.PartitionTime, tr.VariantHash, 273 tr.IngestedInvocationID, tr.RunIndex, tr.ResultIndex, 274 isUnexpected, runDurationUsec, int64(tr.Status), 275 spanutil.ToSpanner(exonerationReasons), 276 sourceRefHash, sourcePosition, 277 changelistHosts, changelistChanges, changelistPatchsets, changelistOwnerKinds, 278 hasDirtySources, 279 tr.SubRealm, isFromBisection, 280 } 281 return spanner.InsertOrUpdate("TestResults", TestResultSaveCols, vals) 282 } 283 284 // ReadTestHistoryOptions specifies options for ReadTestHistory(). 285 type ReadTestHistoryOptions struct { 286 Project string 287 TestID string 288 SubRealms []string 289 VariantPredicate *pb.VariantPredicate 290 SubmittedFilter pb.SubmittedFilter 291 TimeRange *pb.TimeRange 292 ExcludeBisectionResults bool 293 PageSize int 294 PageToken string 295 } 296 297 // statement generates a spanner statement for the specified query template. 298 func (opts ReadTestHistoryOptions) statement(ctx context.Context, tmpl string, paginationParams []string) (spanner.Statement, error) { 299 params := map[string]any{ 300 "project": opts.Project, 301 "testId": opts.TestID, 302 "subRealms": opts.SubRealms, 303 "limit": opts.PageSize, 304 305 // If the filter is unspecified, this param will be ignored during the 306 // statement generation step. 307 "hasUnsubmittedChanges": opts.SubmittedFilter == pb.SubmittedFilter_ONLY_UNSUBMITTED, 308 309 // Verdict status enum values. 310 "unexpected": int(pb.TestVerdictStatus_UNEXPECTED), 311 "unexpectedlySkipped": int(pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED), 312 "flaky": int(pb.TestVerdictStatus_FLAKY), 313 "exonerated": int(pb.TestVerdictStatus_EXONERATED), 314 "expected": int(pb.TestVerdictStatus_EXPECTED), 315 316 // Test result status enum values. 317 "skip": int(pb.TestResultStatus_SKIP), 318 "pass": int(pb.TestResultStatus_PASS), 319 } 320 input := map[string]any{ 321 "hasLimit": opts.PageSize > 0, 322 "hasSubmittedFilter": opts.SubmittedFilter != pb.SubmittedFilter_SUBMITTED_FILTER_UNSPECIFIED, 323 "excludeBisectionResults": opts.ExcludeBisectionResults, 324 "pagination": opts.PageToken != "", 325 "params": params, 326 } 327 328 if opts.TimeRange.GetEarliest() != nil { 329 params["afterTime"] = opts.TimeRange.GetEarliest().AsTime() 330 } else { 331 params["afterTime"] = MinSpannerTimestamp 332 } 333 if opts.TimeRange.GetLatest() != nil { 334 params["beforeTime"] = opts.TimeRange.GetLatest().AsTime() 335 } else { 336 params["beforeTime"] = MaxSpannerTimestamp 337 } 338 339 switch p := opts.VariantPredicate.GetPredicate().(type) { 340 case *pb.VariantPredicate_Equals: 341 input["hasVariantHash"] = true 342 params["variantHash"] = pbutil.VariantHash(p.Equals) 343 case *pb.VariantPredicate_Contains: 344 if len(p.Contains.Def) > 0 { 345 input["hasVariantKVs"] = true 346 params["variantKVs"] = pbutil.VariantToStrings(p.Contains) 347 } 348 case *pb.VariantPredicate_HashEquals: 349 input["hasVariantHash"] = true 350 params["variantHash"] = p.HashEquals 351 case nil: 352 // No filter. 353 default: 354 panic(errors.Reason("unexpected variant predicate %q", opts.VariantPredicate).Err()) 355 } 356 357 if opts.PageToken != "" { 358 tokens, err := pagination.ParseToken(opts.PageToken) 359 if err != nil { 360 return spanner.Statement{}, err 361 } 362 363 if len(tokens) != len(paginationParams) { 364 return spanner.Statement{}, pagination.InvalidToken(errors.Reason("expected %d components, got %d", len(paginationParams), len(tokens)).Err()) 365 } 366 367 // Keep all pagination params as strings and convert them to other data 368 // types in the query as necessary. So we can have a unified way of handling 369 // different page tokens. 370 for i, param := range paginationParams { 371 params[param] = tokens[i] 372 } 373 } 374 375 stmt, err := spanutil.GenerateStatement(testHistoryQueryTmpl, tmpl, input) 376 if err != nil { 377 return spanner.Statement{}, err 378 } 379 stmt.Params = params 380 381 return stmt, nil 382 } 383 384 // ReadTestHistory reads verdicts from the spanner database. 385 // Must be called in a spanner transactional context. 386 func ReadTestHistory(ctx context.Context, opts ReadTestHistoryOptions) (verdicts []*pb.TestVerdict, nextPageToken string, err error) { 387 stmt, err := opts.statement(ctx, "testHistoryQuery", []string{"paginationTime", "paginationVariantHash", "paginationInvId"}) 388 if err != nil { 389 return nil, "", err 390 } 391 392 var b spanutil.Buffer 393 verdicts = make([]*pb.TestVerdict, 0, opts.PageSize) 394 err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error { 395 tv := &pb.TestVerdict{ 396 TestId: opts.TestID, 397 } 398 var status int64 399 var passedAvgDurationUsec spanner.NullInt64 400 var changelistHosts []string 401 var changelistChanges []int64 402 var changelistPatchsets []int64 403 var changelistOwnerKinds []string 404 err := b.FromSpanner( 405 row, 406 &tv.PartitionTime, 407 &tv.VariantHash, 408 &tv.InvocationId, 409 &status, 410 &passedAvgDurationUsec, 411 &changelistHosts, 412 &changelistChanges, 413 &changelistPatchsets, 414 &changelistOwnerKinds, 415 ) 416 if err != nil { 417 return err 418 } 419 tv.Status = pb.TestVerdictStatus(status) 420 if passedAvgDurationUsec.Valid { 421 tv.PassedAvgDuration = durationpb.New(time.Microsecond * time.Duration(passedAvgDurationUsec.Int64)) 422 } 423 424 // Data in spanner should be consistent, so 425 // len(changelistHosts) == len(changelistChanges) 426 // == len(changelistPatchsets). 427 // 428 // ChangeListOwnerKinds was retrofitted after the table 429 // was first created, so it should be of equal length 430 // only if present. It was introduced in November 2022, 431 // so this special-case can be deleted in March 2023+. 432 if len(changelistHosts) != len(changelistChanges) || 433 len(changelistChanges) != len(changelistPatchsets) || 434 (changelistOwnerKinds != nil && len(changelistOwnerKinds) != len(changelistPatchsets)) { 435 panic("Changelist arrays have mismatched length in Spanner") 436 } 437 changelists := make([]*pb.Changelist, 0, len(changelistHosts)) 438 for i := range changelistHosts { 439 var ownerKind pb.ChangelistOwnerKind 440 if changelistOwnerKinds != nil { 441 ownerKind = OwnerKindFromDB(changelistOwnerKinds[i]) 442 } 443 changelists = append(changelists, &pb.Changelist{ 444 Host: DecompressHost(changelistHosts[i]), 445 Change: changelistChanges[i], 446 Patchset: int32(changelistPatchsets[i]), 447 OwnerKind: ownerKind, 448 }) 449 } 450 tv.Changelists = changelists 451 452 verdicts = append(verdicts, tv) 453 return nil 454 }) 455 if err != nil { 456 return nil, "", errors.Annotate(err, "query test history").Err() 457 } 458 459 if opts.PageSize != 0 && len(verdicts) == opts.PageSize { 460 lastTV := verdicts[len(verdicts)-1] 461 nextPageToken = pagination.Token(lastTV.PartitionTime.AsTime().Format(pageTokenTimeFormat), lastTV.VariantHash, lastTV.InvocationId) 462 } 463 return verdicts, nextPageToken, nil 464 } 465 466 // ReadTestHistoryStats reads stats of verdicts grouped by UTC dates from the 467 // spanner database. 468 // Must be called in a spanner transactional context. 469 func ReadTestHistoryStats(ctx context.Context, opts ReadTestHistoryOptions) (groups []*pb.QueryTestHistoryStatsResponse_Group, nextPageToken string, err error) { 470 stmt, err := opts.statement(ctx, "testHistoryStatsQuery", []string{"paginationDate", "paginationVariantHash"}) 471 if err != nil { 472 return nil, "", err 473 } 474 475 var b spanutil.Buffer 476 groups = make([]*pb.QueryTestHistoryStatsResponse_Group, 0, opts.PageSize) 477 err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error { 478 group := &pb.QueryTestHistoryStatsResponse_Group{} 479 var ( 480 unexpectedCount, unexpectedlySkippedCount int64 481 flakyCount, exoneratedCount, expectedCount int64 482 passedAvgDurationUsec spanner.NullInt64 483 ) 484 err := b.FromSpanner( 485 row, 486 &group.PartitionTime, 487 &group.VariantHash, 488 &unexpectedCount, &unexpectedlySkippedCount, 489 &flakyCount, &exoneratedCount, &expectedCount, 490 &passedAvgDurationUsec, 491 ) 492 if err != nil { 493 return err 494 } 495 group.UnexpectedCount = int32(unexpectedCount) 496 group.UnexpectedlySkippedCount = int32(unexpectedlySkippedCount) 497 group.FlakyCount = int32(flakyCount) 498 group.ExoneratedCount = int32(exoneratedCount) 499 group.ExpectedCount = int32(expectedCount) 500 if passedAvgDurationUsec.Valid { 501 group.PassedAvgDuration = durationpb.New(time.Microsecond * time.Duration(passedAvgDurationUsec.Int64)) 502 } 503 groups = append(groups, group) 504 return nil 505 }) 506 if err != nil { 507 return nil, "", errors.Annotate(err, "query test history stats").Err() 508 } 509 510 if opts.PageSize != 0 && len(groups) == opts.PageSize { 511 lastGroup := groups[len(groups)-1] 512 nextPageToken = pagination.Token(lastGroup.PartitionTime.AsTime().Format(pageTokenTimeFormat), lastGroup.VariantHash) 513 } 514 return groups, nextPageToken, nil 515 } 516 517 // TestVariantRealm represents a row in the TestVariantRealm table. 518 type TestVariantRealm struct { 519 Project string 520 TestID string 521 VariantHash string 522 SubRealm string 523 Variant *pb.Variant 524 LastIngestionTime time.Time 525 } 526 527 // ReadTestVariantRealms read test variant realms from the TestVariantRealms 528 // table. 529 // Must be called in a spanner transactional context. 530 func ReadTestVariantRealms(ctx context.Context, keys spanner.KeySet, fn func(tvr *TestVariantRealm) error) error { 531 var b spanutil.Buffer 532 fields := []string{"Project", "TestId", "VariantHash", "SubRealm", "Variant", "LastIngestionTime"} 533 return span.Read(ctx, "TestVariantRealms", keys, fields).Do( 534 func(row *spanner.Row) error { 535 tvr := &TestVariantRealm{} 536 err := b.FromSpanner( 537 row, 538 &tvr.Project, 539 &tvr.TestID, 540 &tvr.VariantHash, 541 &tvr.SubRealm, 542 &tvr.Variant, 543 &tvr.LastIngestionTime, 544 ) 545 if err != nil { 546 return err 547 } 548 return fn(tvr) 549 }) 550 } 551 552 // TestVariantRealmSaveCols is the set of columns written to in a test variant 553 // realm save. Allocated here once to avoid reallocating on every save. 554 var TestVariantRealmSaveCols = []string{ 555 "Project", "TestId", "VariantHash", "SubRealm", 556 "Variant", "LastIngestionTime", 557 } 558 559 // SaveUnverified creates a mutation to save the test variant realm into 560 // the TestVariantRealms table. The test variant realm is not verified. 561 // Must be called in spanner RW transactional context. 562 func (tvr *TestVariantRealm) SaveUnverified() *spanner.Mutation { 563 vals := []any{ 564 tvr.Project, tvr.TestID, tvr.VariantHash, tvr.SubRealm, 565 pbutil.VariantToStrings(tvr.Variant), tvr.LastIngestionTime, 566 } 567 return spanner.InsertOrUpdate("TestVariantRealms", TestVariantRealmSaveCols, vals) 568 } 569 570 // TestVariantRealm represents a row in the TestVariantRealm table. 571 type TestRealm struct { 572 Project string 573 TestID string 574 SubRealm string 575 LastIngestionTime time.Time 576 } 577 578 // ReadTestRealms read test variant realms from the TestRealms table. 579 // Must be called in a spanner transactional context. 580 func ReadTestRealms(ctx context.Context, keys spanner.KeySet, fn func(tr *TestRealm) error) error { 581 var b spanutil.Buffer 582 fields := []string{"Project", "TestId", "SubRealm", "LastIngestionTime"} 583 return span.Read(ctx, "TestRealms", keys, fields).Do( 584 func(row *spanner.Row) error { 585 tr := &TestRealm{} 586 err := b.FromSpanner( 587 row, 588 &tr.Project, 589 &tr.TestID, 590 &tr.SubRealm, 591 &tr.LastIngestionTime, 592 ) 593 if err != nil { 594 return err 595 } 596 return fn(tr) 597 }) 598 } 599 600 // TestRealmSaveCols is the set of columns written to in a test variant 601 // realm save. Allocated here once to avoid reallocating on every save. 602 var TestRealmSaveCols = []string{"Project", "TestId", "SubRealm", "LastIngestionTime"} 603 604 // SaveUnverified creates a mutation to save the test realm into the TestRealms 605 // table. The test realm is not verified. 606 // Must be called in spanner RW transactional context. 607 func (tvr *TestRealm) SaveUnverified() *spanner.Mutation { 608 vals := []any{tvr.Project, tvr.TestID, tvr.SubRealm, tvr.LastIngestionTime} 609 return spanner.InsertOrUpdate("TestRealms", TestRealmSaveCols, vals) 610 } 611 612 // ReadVariantsOptions specifies options for ReadVariants(). 613 type ReadVariantsOptions struct { 614 SubRealms []string 615 VariantPredicate *pb.VariantPredicate 616 PageSize int 617 PageToken string 618 } 619 620 // parseQueryVariantsPageToken parses the positions from the page token. 621 func parseQueryVariantsPageToken(pageToken string) (afterHash string, err error) { 622 tokens, err := pagination.ParseToken(pageToken) 623 if err != nil { 624 return "", err 625 } 626 627 if len(tokens) != 1 { 628 return "", pagination.InvalidToken(errors.Reason("expected 1 components, got %d", len(tokens)).Err()) 629 } 630 631 return tokens[0], nil 632 } 633 634 // ReadVariants reads all the variants of the specified test from the 635 // spanner database. 636 // Must be called in a spanner transactional context. 637 func ReadVariants(ctx context.Context, project, testID string, opts ReadVariantsOptions) (variants []*pb.QueryVariantsResponse_VariantInfo, nextPageToken string, err error) { 638 paginationVariantHash := "" 639 if opts.PageToken != "" { 640 paginationVariantHash, err = parseQueryVariantsPageToken(opts.PageToken) 641 if err != nil { 642 return nil, "", err 643 } 644 } 645 646 params := map[string]any{ 647 "project": project, 648 "testId": testID, 649 "subRealms": opts.SubRealms, 650 651 // Control pagination. 652 "limit": opts.PageSize, 653 "paginationVariantHash": paginationVariantHash, 654 } 655 input := map[string]any{ 656 "hasLimit": opts.PageSize > 0, 657 "params": params, 658 } 659 660 switch p := opts.VariantPredicate.GetPredicate().(type) { 661 case *pb.VariantPredicate_Equals: 662 input["hasVariantHash"] = true 663 params["variantHash"] = pbutil.VariantHash(p.Equals) 664 case *pb.VariantPredicate_Contains: 665 if len(p.Contains.Def) > 0 { 666 input["hasVariantKVs"] = true 667 params["variantKVs"] = pbutil.VariantToStrings(p.Contains) 668 } 669 case *pb.VariantPredicate_HashEquals: 670 input["hasVariantHash"] = true 671 params["variantHash"] = p.HashEquals 672 case nil: 673 // No filter. 674 default: 675 panic(errors.Reason("unexpected variant predicate %q", opts.VariantPredicate).Err()) 676 } 677 678 stmt, err := spanutil.GenerateStatement(variantsQueryTmpl, variantsQueryTmpl.Name(), input) 679 if err != nil { 680 return nil, "", err 681 } 682 stmt.Params = params 683 684 var b spanutil.Buffer 685 variants = make([]*pb.QueryVariantsResponse_VariantInfo, 0, opts.PageSize) 686 err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error { 687 variant := &pb.QueryVariantsResponse_VariantInfo{} 688 err := b.FromSpanner( 689 row, 690 &variant.VariantHash, 691 &variant.Variant, 692 ) 693 if err != nil { 694 return err 695 } 696 variants = append(variants, variant) 697 return nil 698 }) 699 if err != nil { 700 return nil, "", err 701 } 702 703 if opts.PageSize != 0 && len(variants) == opts.PageSize { 704 lastVariant := variants[len(variants)-1] 705 nextPageToken = pagination.Token(lastVariant.VariantHash) 706 } 707 return variants, nextPageToken, nil 708 } 709 710 // QueryTestsOptions specifies options for QueryTests(). 711 type QueryTestsOptions struct { 712 SubRealms []string 713 PageSize int 714 PageToken string 715 } 716 717 // parseQueryTestsPageToken parses the positions from the page token. 718 func parseQueryTestsPageToken(pageToken string) (afterTestId string, err error) { 719 tokens, err := pagination.ParseToken(pageToken) 720 if err != nil { 721 return "", err 722 } 723 724 if len(tokens) != 1 { 725 return "", pagination.InvalidToken(errors.Reason("expected 1 components, got %d", len(tokens)).Err()) 726 } 727 728 return tokens[0], nil 729 } 730 731 // QueryTests finds all the test IDs with the specified testIDSubstring from 732 // the spanner database. 733 // Must be called in a spanner transactional context. 734 func QueryTests(ctx context.Context, project, testIDSubstring string, opts QueryTestsOptions) (testIDs []string, nextPageToken string, err error) { 735 paginationTestID := "" 736 if opts.PageToken != "" { 737 paginationTestID, err = parseQueryTestsPageToken(opts.PageToken) 738 if err != nil { 739 return nil, "", err 740 } 741 } 742 params := map[string]any{ 743 "project": project, 744 "testIdPattern": "%" + spanutil.QuoteLike(testIDSubstring) + "%", 745 "subRealms": opts.SubRealms, 746 747 // Control pagination. 748 "limit": opts.PageSize, 749 "paginationTestId": paginationTestID, 750 } 751 input := map[string]any{ 752 "hasLimit": opts.PageSize > 0, 753 "params": params, 754 } 755 756 stmt, err := spanutil.GenerateStatement(QueryTestsQueryTmpl, QueryTestsQueryTmpl.Name(), input) 757 if err != nil { 758 return nil, "", err 759 } 760 stmt.Params = params 761 762 var b spanutil.Buffer 763 testIDs = make([]string, 0, opts.PageSize) 764 err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error { 765 var testID string 766 err := b.FromSpanner( 767 row, 768 &testID, 769 ) 770 if err != nil { 771 return err 772 } 773 testIDs = append(testIDs, testID) 774 return nil 775 }) 776 if err != nil { 777 return nil, "", err 778 } 779 780 if opts.PageSize != 0 && len(testIDs) == opts.PageSize { 781 lastTestID := testIDs[len(testIDs)-1] 782 nextPageToken = pagination.Token(lastTestID) 783 } 784 return testIDs, nextPageToken, nil 785 } 786 787 var testHistoryQueryTmpl = template.Must(template.New("").Parse(` 788 {{define "tvStatus"}} 789 CASE 790 WHEN ANY_VALUE(ExonerationReasons IS NOT NULL AND ARRAY_LENGTH(ExonerationReasons) > 0) THEN @exonerated 791 -- Use COALESCE as IsUnexpected uses NULL to indicate false. 792 WHEN LOGICAL_AND(NOT COALESCE(IsUnexpected, FALSE)) THEN @expected 793 WHEN LOGICAL_AND(COALESCE(IsUnexpected, FALSE) AND Status = @skip) THEN @unexpectedlySkipped 794 WHEN LOGICAL_AND(COALESCE(IsUnexpected, FALSE)) THEN @unexpected 795 ELSE @flaky 796 END TvStatus 797 {{end}} 798 799 {{define "testResultFilter"}} 800 Project = @project 801 AND TestId = @testId 802 AND PartitionTime >= @afterTime 803 AND PartitionTime < @beforeTime 804 AND SubRealm IN UNNEST(@subRealms) 805 {{if .hasVariantHash}} 806 AND VariantHash = @variantHash 807 {{end}} 808 {{if .hasVariantKVs}} 809 AND VariantHash IN ( 810 SELECT DISTINCT VariantHash 811 FROM TestVariantRealms 812 WHERE 813 Project = @project 814 AND TestId = @testId 815 AND SubRealm IN UNNEST(@subRealms) 816 AND (SELECT LOGICAL_AND(kv IN UNNEST(Variant)) FROM UNNEST(@variantKVs) kv) 817 ) 818 {{end}} 819 {{if .hasSubmittedFilter}} 820 AND (ARRAY_LENGTH(ChangelistHosts) > 0) = @hasUnsubmittedChanges 821 {{end}} 822 {{if .excludeBisectionResults}} 823 -- IsFromBisection uses NULL to indicate false. 824 AND IsFromBisection IS NULL 825 {{end}} 826 {{end}} 827 828 {{define "testHistoryQuery"}} 829 SELECT 830 PartitionTime, 831 VariantHash, 832 IngestedInvocationId, 833 {{template "tvStatus" .}}, 834 CAST(AVG(IF(Status = @pass, RunDurationUsec, NULL)) AS INT64) AS PassedAvgDurationUsec, 835 ANY_VALUE(ChangelistHosts) AS ChangelistHosts, 836 ANY_VALUE(ChangelistChanges) AS ChangelistChanges, 837 ANY_VALUE(ChangelistPatchsets) AS ChangelistPatchsets, 838 ANY_VALUE(ChangelistOwnerKinds) AS ChangelistOwnerKinds, 839 FROM TestResults 840 WHERE 841 {{template "testResultFilter" .}} 842 {{if .pagination}} 843 AND ( 844 PartitionTime < TIMESTAMP(@paginationTime) 845 OR (PartitionTime = TIMESTAMP(@paginationTime) AND VariantHash > @paginationVariantHash) 846 OR (PartitionTime = TIMESTAMP(@paginationTime) AND VariantHash = @paginationVariantHash AND IngestedInvocationId > @paginationInvId) 847 ) 848 {{end}} 849 GROUP BY PartitionTime, VariantHash, IngestedInvocationId 850 ORDER BY 851 PartitionTime DESC, 852 VariantHash ASC, 853 IngestedInvocationId ASC 854 {{if .hasLimit}} 855 LIMIT @limit 856 {{end}} 857 {{end}} 858 859 {{define "testHistoryStatsQuery"}} 860 WITH verdicts AS ( 861 SELECT 862 PartitionTime, 863 VariantHash, 864 IngestedInvocationId, 865 {{template "tvStatus" .}}, 866 COUNTIF(Status = @pass AND RunDurationUsec IS NOT NULL) AS PassedWithDurationCount, 867 SUM(IF(Status = @pass, RunDurationUsec, 0)) AS SumPassedDurationUsec, 868 FROM TestResults 869 WHERE 870 {{template "testResultFilter" .}} 871 {{if .pagination}} 872 AND PartitionTime < TIMESTAMP_ADD(TIMESTAMP(@paginationDate), INTERVAL 1 DAY) 873 {{end}} 874 GROUP BY PartitionTime, VariantHash, IngestedInvocationId 875 ) 876 877 SELECT 878 TIMESTAMP_TRUNC(PartitionTime, DAY, "UTC") AS PartitionDate, 879 VariantHash, 880 COUNTIF(TvStatus = @unexpected) AS UnexpectedCount, 881 COUNTIF(TvStatus = @unexpectedlySkipped) AS UnexpectedlySkippedCount, 882 COUNTIF(TvStatus = @flaky) AS FlakyCount, 883 COUNTIF(TvStatus = @exonerated) AS ExoneratedCount, 884 COUNTIF(TvStatus = @expected) AS ExpectedCount, 885 CAST(SAFE_DIVIDE(SUM(SumPassedDurationUsec), SUM(PassedWithDurationCount)) AS INT64) AS PassedAvgDurationUsec, 886 FROM verdicts 887 GROUP BY PartitionDate, VariantHash 888 {{if .pagination}} 889 HAVING 890 PartitionDate < TIMESTAMP(@paginationDate) 891 OR (PartitionDate = TIMESTAMP(@paginationDate) AND VariantHash > @paginationVariantHash) 892 {{end}} 893 ORDER BY 894 PartitionDate DESC, 895 VariantHash ASC 896 {{if .hasLimit}} 897 LIMIT @limit 898 {{end}} 899 {{end}} 900 `)) 901 902 var variantsQueryTmpl = template.Must(template.New("variantsQuery").Parse(` 903 SELECT 904 VariantHash, 905 ANY_VALUE(Variant) as Variant, 906 FROM TestVariantRealms 907 WHERE 908 Project = @project 909 AND TestId = @testId 910 AND SubRealm IN UNNEST(@subRealms) 911 {{if .hasVariantHash}} 912 AND VariantHash = @variantHash 913 {{end}} 914 {{if .hasVariantKVs}} 915 AND (SELECT LOGICAL_AND(kv IN UNNEST(Variant)) FROM UNNEST(@variantKVs) kv) 916 {{end}} 917 AND VariantHash > @paginationVariantHash 918 GROUP BY VariantHash 919 ORDER BY VariantHash ASC 920 {{if .hasLimit}} 921 LIMIT @limit 922 {{end}} 923 `)) 924 925 // The query is written in a way to force spanner NOT to put 926 // `SubRealm IN UNNEST(@subRealms)` check in Filter Scan seek condition, which 927 // can significantly increase the time it takes to scan the table. 928 var QueryTestsQueryTmpl = template.Must(template.New("QueryTestsQuery").Parse(` 929 WITH Tests as ( 930 SELECT DISTINCT TestId, SubRealm IN UNNEST(@subRealms) as HasAccess 931 FROM TestRealms 932 WHERE 933 Project = @project 934 AND TestId > @paginationTestId 935 AND TestId LIKE @testIdPattern 936 ) 937 SELECT TestId FROM Tests 938 WHERE HasAccess 939 ORDER BY TestId ASC 940 {{if .hasLimit}} 941 LIMIT @limit 942 {{end}} 943 `))