go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/testresults/query.go (about) 1 // Copyright 2020 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 16 17 import ( 18 "bytes" 19 "context" 20 "fmt" 21 "regexp" 22 "strings" 23 "text/template" 24 25 "cloud.google.com/go/spanner" 26 "go.opentelemetry.io/otel/attribute" 27 "google.golang.org/genproto/protobuf/field_mask" 28 29 "go.chromium.org/luci/common/errors" 30 "go.chromium.org/luci/common/proto/mask" 31 "go.chromium.org/luci/resultdb/internal/invocations" 32 "go.chromium.org/luci/resultdb/internal/pagination" 33 "go.chromium.org/luci/resultdb/internal/spanutil" 34 "go.chromium.org/luci/resultdb/internal/tracing" 35 "go.chromium.org/luci/resultdb/pbutil" 36 pb "go.chromium.org/luci/resultdb/proto/v1" 37 ) 38 39 // AllFields is a field mask that selects all TestResults fields. 40 var AllFields = mask.All(&pb.TestResult{}) 41 42 // limitedFields is a field mask for TestResult to use when the caller only 43 // has the listLimited permission for test results. 44 var limitedFields = mask.MustFromReadMask(&pb.TestResult{}, 45 "name", 46 "test_id", 47 "result_id", 48 "expected", 49 "status", 50 "start_time", 51 "duration", 52 "variant_hash", 53 "failure_reason", 54 "skip_reason", 55 ) 56 57 // limitedReasonLength is the length to which the failure reason's primary error 58 // message will be truncated for a TestResult when the caller only has the 59 // listLimited permission for test results. 60 const limitedReasonLength = 140 61 62 // defaultListMask is the default field mask to use for QueryTestResults and 63 // ListTestResults requests. 64 var defaultListMask = mask.MustFromReadMask(&pb.TestResult{}, 65 "name", 66 "test_id", 67 "result_id", 68 "variant", 69 "variant_hash", 70 "expected", 71 "status", 72 "start_time", 73 "duration", 74 "skip_reason", 75 ) 76 77 // ListMask returns mask.Mask converted from field_mask.FieldMask. 78 // It returns a default mask with all fields except summary_html if readMask is 79 // empty. 80 func ListMask(readMask *field_mask.FieldMask) (*mask.Mask, error) { 81 if len(readMask.GetPaths()) == 0 { 82 return defaultListMask, nil 83 } 84 return mask.FromFieldMask(readMask, &pb.TestResult{}, false, false) 85 } 86 87 // Query specifies test results to fetch. 88 type Query struct { 89 InvocationIDs invocations.IDSet 90 Predicate *pb.TestResultPredicate 91 PageSize int // must be positive 92 PageToken string 93 Mask *mask.Mask 94 } 95 96 func (q *Query) run(ctx context.Context, f func(*pb.TestResult) error) (err error) { 97 ctx, ts := tracing.Start(ctx, "testresults.Query.run", 98 attribute.Int("cr.dev.invocations", len(q.InvocationIDs)), 99 ) 100 defer func() { tracing.End(ts, err) }() 101 102 switch { 103 case q.PageSize < 0: 104 panic("PageSize < 0") 105 case q.Predicate.GetExcludeExonerated() && q.Predicate.GetExpectancy() == pb.TestResultPredicate_ALL: 106 panic("ExcludeExonerated and Expectancy=ALL are mutually exclusive") 107 } 108 109 columns, parser := q.selectClause() 110 params, err := q.baseParams() 111 if err != nil { 112 return err 113 } 114 115 err = invocations.TokenToMap(q.PageToken, params, "afterInvocationId", "afterTestId", "afterResultId") 116 if err != nil { 117 return err 118 } 119 120 _, filterByTestIdOrVariant := params["literalPrefix"] 121 if !filterByTestIdOrVariant { 122 _, filterByTestIdOrVariant = params["variant"] 123 } 124 125 // Execute the query. 126 st := q.genStatement("testResults", map[string]any{ 127 "params": params, 128 "columns": strings.Join(columns, ", "), 129 "onlyUnexpected": q.Predicate.GetExpectancy() == pb.TestResultPredicate_VARIANTS_WITH_ONLY_UNEXPECTED_RESULTS, 130 "withUnexpected": q.Predicate.GetExpectancy() == pb.TestResultPredicate_VARIANTS_WITH_UNEXPECTED_RESULTS || q.Predicate.GetExpectancy() == pb.TestResultPredicate_VARIANTS_WITH_ONLY_UNEXPECTED_RESULTS, 131 "excludeExonerated": q.Predicate.GetExcludeExonerated(), 132 "filterByTestIdOrVariant": filterByTestIdOrVariant, 133 }) 134 return spanutil.Query(ctx, st, func(row *spanner.Row) error { 135 tr, err := parser(row) 136 if err != nil { 137 return err 138 } 139 return f(tr) 140 }) 141 } 142 143 // select returns the SELECT clause of the SQL statement to fetch test results, 144 // as well as a function to convert a Spanner row to a TestResult message. 145 // The returned SELECT clause assumes that the TestResults has alias "tr". 146 // The returned parser is stateful and must not be called concurrently. 147 func (q *Query) selectClause() (columns []string, parser func(*spanner.Row) (*pb.TestResult, error)) { 148 columns = []string{ 149 "InvocationId", 150 "TestId", 151 "ResultId", 152 "IsUnexpected", 153 "Status", 154 "StartTime", 155 "RunDurationUsec", 156 } 157 158 // Select extra columns depending on the mask. 159 var extraColumns []string 160 readMask := q.Mask 161 if readMask.IsEmpty() { 162 readMask = defaultListMask 163 } 164 selectIfIncluded := func(column, field string) { 165 switch inc, err := readMask.Includes(field); { 166 case err != nil: 167 panic(err) 168 case inc != mask.Exclude: 169 extraColumns = append(extraColumns, column) 170 columns = append(columns, column) 171 } 172 } 173 selectIfIncluded("SummaryHtml", "summary_html") 174 selectIfIncluded("Tags", "tags") 175 selectIfIncluded("TestMetadata", "test_metadata") 176 selectIfIncluded("Variant", "variant") 177 selectIfIncluded("VariantHash", "variant_hash") 178 selectIfIncluded("FailureReason", "failure_reason") 179 selectIfIncluded("Properties", "properties") 180 selectIfIncluded("SkipReason", "skip_reason") 181 182 // Build a parser function. 183 var b spanutil.Buffer 184 var summaryHTML spanutil.Compressed 185 var tmd spanutil.Compressed 186 var fr spanutil.Compressed 187 var properties spanutil.Compressed 188 parser = func(row *spanner.Row) (*pb.TestResult, error) { 189 var invID invocations.ID 190 var maybeUnexpected spanner.NullBool 191 var micros spanner.NullInt64 192 var skipReason spanner.NullInt64 193 tr := &pb.TestResult{} 194 195 ptrs := []any{ 196 &invID, 197 &tr.TestId, 198 &tr.ResultId, 199 &maybeUnexpected, 200 &tr.Status, 201 &tr.StartTime, 202 µs, 203 } 204 205 for _, v := range extraColumns { 206 switch v { 207 case "SummaryHtml": 208 ptrs = append(ptrs, &summaryHTML) 209 case "Tags": 210 ptrs = append(ptrs, &tr.Tags) 211 case "TestMetadata": 212 ptrs = append(ptrs, &tmd) 213 case "Variant": 214 ptrs = append(ptrs, &tr.Variant) 215 case "VariantHash": 216 ptrs = append(ptrs, &tr.VariantHash) 217 case "FailureReason": 218 ptrs = append(ptrs, &fr) 219 case "Properties": 220 ptrs = append(ptrs, &properties) 221 case "SkipReason": 222 ptrs = append(ptrs, &skipReason) 223 default: 224 panic("impossible") 225 } 226 } 227 228 err := b.FromSpanner(row, ptrs...) 229 if err != nil { 230 return nil, err 231 } 232 233 // Generate test result name now in case tr.TestId and tr.ResultId become 234 // empty after q.Mask.Trim(tr). 235 trName := pbutil.TestResultName(string(invID), tr.TestId, tr.ResultId) 236 tr.SummaryHtml = string(summaryHTML) 237 PopulateExpectedField(tr, maybeUnexpected) 238 PopulateDurationField(tr, micros) 239 PopulateSkipReasonField(tr, skipReason) 240 if err := populateTestMetadata(tr, tmd); err != nil { 241 return nil, errors.Annotate(err, "error unmarshalling test_metadata for %s", trName).Err() 242 } 243 if err := populateFailureReason(tr, fr); err != nil { 244 return nil, errors.Annotate(err, "error unmarshalling failure_reason for %s", trName).Err() 245 } 246 if err := populateProperties(tr, properties); err != nil { 247 return nil, errors.Annotate(err, "failed to unmarshal properties").Err() 248 } 249 if err := q.Mask.Trim(tr); err != nil { 250 return nil, errors.Annotate(err, "error trimming fields for %s", trName).Err() 251 } 252 // Always include name in tr because name is needed to calculate 253 // page token. 254 tr.Name = trName 255 return tr, nil 256 } 257 return 258 } 259 260 // Fetch returns a page of test results matching q. 261 // Returned test results are ordered by parent invocation ID, test ID and result 262 // ID. 263 func (q *Query) Fetch(ctx context.Context) (trs []*pb.TestResult, nextPageToken string, err error) { 264 if q.PageSize <= 0 { 265 panic("PageSize <= 0") 266 } 267 268 err = q.run(ctx, func(tr *pb.TestResult) error { 269 trs = append(trs, tr) 270 return nil 271 }) 272 if err != nil { 273 trs = nil 274 return 275 } 276 277 // If we got pageSize results, then we haven't exhausted the collection and 278 // need to return the next page token. 279 if len(trs) == q.PageSize { 280 last := trs[q.PageSize-1] 281 invID, testID, resultID := MustParseName(last.Name) 282 nextPageToken = pagination.Token(string(invID), testID, resultID) 283 } 284 return 285 } 286 287 // Run calls f for test results matching the query. 288 // The test results are ordered by parent invocation ID, test ID and result ID. 289 func (q *Query) Run(ctx context.Context, f func(*pb.TestResult) error) error { 290 if q.PageSize > 0 { 291 panic("PageSize is specified when Query.Run") 292 } 293 return q.run(ctx, f) 294 } 295 296 func (q *Query) baseParams() (map[string]any, error) { 297 params := map[string]any{ 298 "invIDs": q.InvocationIDs, 299 "limit": q.PageSize, 300 } 301 302 if re := q.Predicate.GetTestIdRegexp(); re != "" && re != ".*" { 303 params["testIdRegexp"] = fmt.Sprintf("^%s$", re) 304 r, err := regexp.Compile(re) 305 if err != nil { 306 return params, err 307 } 308 // We're trying to match the invocation's CommonTestIDPrefix with re, 309 // so re should have a literal prefix, otherwise the match likely 310 // fails. 311 // For example if an invocation's CommonTestIDPrefix is "ninja://" and 312 // re is ".*browser_tests.*", we wouldn't know if that invocation contains 313 // any test results with matching test ids or not. 314 params["literalPrefix"], _ = r.LiteralPrefix() 315 } 316 317 PopulateVariantParams(params, q.Predicate.GetVariant()) 318 319 return params, nil 320 } 321 322 // PopulateVariantParams populates variantHashEquals and variantContains 323 // parameters based on the predicate. 324 func PopulateVariantParams(params map[string]any, variantPredicate *pb.VariantPredicate) { 325 switch p := variantPredicate.GetPredicate().(type) { 326 case *pb.VariantPredicate_Equals: 327 params["variantHashEquals"] = pbutil.VariantHash(p.Equals) 328 params["variant"] = pbutil.VariantToStrings(p.Equals) 329 case *pb.VariantPredicate_Contains: 330 if len(p.Contains.Def) > 0 { 331 params["variantContains"] = pbutil.VariantToStrings(p.Contains) 332 params["variant"] = params["variantContains"] 333 } 334 case nil: 335 // No filter. 336 default: 337 panic(errors.Reason("unexpected variant predicate %q", variantPredicate).Err()) 338 } 339 } 340 341 // queryTmpl is a set of templates that generate the SQL statements used 342 // by Query type. 343 var queryTmpl = template.Must(template.New("").Parse(` 344 {{define "testResults"}} 345 @{USE_ADDITIONAL_PARALLELISM=TRUE} 346 WITH 347 {{if .filterByTestIdOrVariant}} 348 invs AS ( 349 SELECT 350 i.InvocationId, 351 FROM Invocations i 352 WHERE i.InvocationId IN UNNEST(@invIDs) 353 {{if .params.literalPrefix}} 354 AND ( 355 ( i.CommonTestIDPrefix IS NOT NULL 356 AND ( 357 -- For the cases where literalPrefix is long and specific, for example: 358 -- literalPrefix = "ninja://chrome/test:browser_tests/AutomationApiTest" 359 -- i.CommonTestIDPrefix = "ninja://chrome/test:browser_tests/" 360 STARTS_WITH(@literalPrefix, i.CommonTestIDPrefix) OR 361 -- For the cases where literalPrefix is short, likely because predicate.TestIdRegexp 362 -- contains non-literal regexp, for example: 363 -- predicate.TestIdRegexp = "ninja://.*browser_tests/" which makes literalPrefix 364 -- to be "ninja://". 365 -- This condition is not very useful to improve the performance of test 366 -- result history API, but without it will make the results incomplete. 367 STARTS_WITH(i.CommonTestIDPrefix, @literalPrefix) 368 ) 369 ) 370 -- To make sure the query can still work for the old invocations 371 -- that were created without CommonTestIDPrefix. 372 OR (i.CommonTestIDPrefix IS NULL AND i.CreateTime < "2021-4-13") 373 ) 374 {{end}} 375 {{if .params.variant}} 376 AND ( 377 (SELECT LOGICAL_AND(kv IN UNNEST(i.TestResultVariantUnion)) FROM UNNEST(@variant) kv) 378 -- To make sure the query can still work for the old invocations 379 -- that were created without TestResultVariantUnion. 380 OR (i.TestResultVariantUnion IS NULL AND i.CreateTime < "2021-4-13") 381 ) 382 {{end}} 383 ), 384 {{else}} 385 invs AS ( 386 SELECT * 387 FROM UNNEST(@invIDs) 388 AS InvocationId 389 ), 390 {{end}} 391 392 {{if .excludeExonerated}} 393 testVariants AS ( 394 {{template "variantsWithUnexpectedResults" .}} 395 ), 396 exonerated AS ( 397 SELECT DISTINCT TestId, VariantHash 398 FROM TestExonerations 399 WHERE InvocationId IN UNNEST(@invIDs) 400 {{template "testIDAndVariantFilter" .}} 401 ), 402 variantsWithUnexpectedResults AS ( 403 SELECT tv.* 404 FROM testVariants tv 405 LEFT JOIN exonerated USING(TestId, VariantHash) 406 WHERE exonerated.TestId IS NULL 407 ), 408 {{else}} 409 variantsWithUnexpectedResults AS ( 410 {{template "variantsWithUnexpectedResults" .}} 411 ), 412 {{end}} 413 414 withUnexpected AS ( 415 SELECT {{.columns}} 416 FROM invs 417 JOIN ( 418 variantsWithUnexpectedResults vur 419 JOIN@{FORCE_JOIN_ORDER=TRUE, JOIN_METHOD=HASH_JOIN} TestResults tr USING (TestId, VariantHash) 420 ) USING(InvocationId) 421 {{/* 422 Don't have to use testIDAndVariantFilter because 423 variantsWithUnexpectedResults is already filtered 424 */}} 425 ) 426 427 {{if .onlyUnexpected}} 428 , withOnlyUnexpected AS ( 429 SELECT ARRAY_AGG(tr) trs 430 FROM withUnexpected tr 431 GROUP BY TestId, VariantHash 432 {{/* 433 All results of the TestID and VariantHash are unexpected. 434 IFNULL() is significant because LOGICAL_AND() skips nulls. 435 */}} 436 HAVING LOGICAL_AND(IFNULL(IsUnexpected, false)) 437 ) 438 SELECT tr.* 439 FROM withOnlyUnexpected owu, owu.trs tr 440 WHERE true {{/* For optional conditions below */}} 441 {{else if .withUnexpected}} 442 SELECT * FROM withUnexpected 443 WHERE true {{/* For optional conditions below */}} 444 {{else}} 445 SELECT {{.columns}} 446 FROM invs 447 JOIN TestResults tr USING(InvocationId) 448 WHERE TRUE -- placeholder of the fist where clause, so that testIDAndVariantFilter can always start with "AND". 449 {{template "testIDAndVariantFilter" .}} 450 {{end}} 451 452 {{/* Apply the page token */}} 453 {{if .params.afterInvocationId}} 454 AND ( 455 (InvocationId > @afterInvocationId) OR 456 (InvocationId = @afterInvocationId AND TestId > @afterTestId) OR 457 (InvocationId = @afterInvocationId AND TestId = @afterTestId AND ResultId > @afterResultId) 458 ) 459 {{end}} 460 ORDER BY InvocationId, TestId, ResultId 461 {{if .params.limit}}LIMIT @limit{{end}} 462 {{end}} 463 464 {{define "variantsWithUnexpectedResults"}} 465 SELECT DISTINCT TestId, VariantHash 466 FROM invs 467 JOIN TestResults@{FORCE_INDEX=UnexpectedTestResults, spanner_emulator.disable_query_null_filtered_index_check=true} USING(InvocationId) 468 WHERE IsUnexpected 469 {{template "testIDAndVariantFilter" .}} 470 {{end}} 471 472 {{define "testIDAndVariantFilter"}} 473 {{/* Filter by Test ID */}} 474 {{if .params.testIdRegexp}} 475 AND REGEXP_CONTAINS(TestId, @testIdRegexp) 476 {{end}} 477 478 {{/* Filter by Variant */}} 479 {{if .params.variantHashEquals}} 480 AND VariantHash = @variantHashEquals 481 {{end}} 482 {{if .params.variantContains }} 483 AND (SELECT LOGICAL_AND(kv IN UNNEST(Variant)) FROM UNNEST(@variantContains) kv) 484 {{end}} 485 {{end}} 486 `)) 487 488 func (*Query) genStatement(templateName string, input map[string]any) spanner.Statement { 489 var sql bytes.Buffer 490 err := queryTmpl.ExecuteTemplate(&sql, templateName, input) 491 if err != nil { 492 panic(fmt.Sprintf("failed to generate a SQL statement: %s", err)) 493 } 494 return spanner.Statement{SQL: sql.String(), Params: input["params"].(map[string]any)} 495 } 496 497 // ToLimitedData limits the given TestResult to the fields allowed when 498 // the caller only has the listLimited permission for test results. 499 func ToLimitedData(ctx context.Context, tr *pb.TestResult) error { 500 if err := limitedFields.Trim(tr); err != nil { 501 return err 502 } 503 504 if tr.FailureReason != nil { 505 tr.FailureReason.PrimaryErrorMessage = truncateErrorMessage( 506 tr.FailureReason.PrimaryErrorMessage, limitedReasonLength) 507 508 for i := range tr.FailureReason.Errors { 509 tr.FailureReason.Errors[i].Message = truncateErrorMessage( 510 tr.FailureReason.Errors[i].Message, limitedReasonLength) 511 } 512 } 513 514 tr.IsMasked = true 515 return nil 516 } 517 518 // truncateErrorMessage truncates the error message if its length exceeds the 519 // limit. 520 func truncateErrorMessage(errorMessage string, maxLength int) string { 521 if len(errorMessage) <= maxLength { 522 return errorMessage 523 } 524 525 runes := []rune(errorMessage) 526 return string(runes[:maxLength]) + "..." 527 }