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  `))