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  			&micros,
   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  }