go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/bisection/internal/lucianalysis/client.go (about)

     1  // Copyright 2023 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 lucianalysis contains methods to query test failures maintained in BigQuery.
    16  package lucianalysis
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"fmt"
    22  	"net/http"
    23  	"strings"
    24  	"text/template"
    25  
    26  	"cloud.google.com/go/bigquery"
    27  	"go.chromium.org/luci/bisection/model"
    28  	configpb "go.chromium.org/luci/bisection/proto/config"
    29  	pb "go.chromium.org/luci/bisection/proto/v1"
    30  	tpb "go.chromium.org/luci/bisection/task/proto"
    31  	"go.chromium.org/luci/bisection/util"
    32  	"go.chromium.org/luci/common/errors"
    33  	"go.chromium.org/luci/common/logging"
    34  	rdbpbutil "go.chromium.org/luci/resultdb/pbutil"
    35  	"go.chromium.org/luci/server/auth"
    36  	"google.golang.org/api/iterator"
    37  	"google.golang.org/api/option"
    38  )
    39  
    40  var readFailureTemplate = template.Must(template.New("").Parse(
    41  	`
    42  {{define "basic" -}}
    43  WITH
    44    segments_with_failure_rate AS (
    45      SELECT
    46        *,
    47        ( segments[0].counts.unexpected_results / segments[0].counts.total_results) AS current_failure_rate,
    48        ( segments[1].counts.unexpected_results / segments[1].counts.total_results) AS previous_failure_rate,
    49        segments[0].start_position AS nominal_upper,
    50        segments[1].end_position AS nominal_lower,
    51        STRING(variant.builder) AS builder
    52      FROM test_variant_segments_unexpected_realtime
    53      WHERE ARRAY_LENGTH(segments) > 1
    54    ),
    55    builder_regression_groups AS (
    56      SELECT
    57        ref_hash AS RefHash,
    58        ANY_VALUE(ref) AS Ref,
    59        nominal_lower AS RegressionStartPosition,
    60        nominal_upper AS RegressionEndPosition,
    61        ANY_VALUE(previous_failure_rate) AS StartPositionFailureRate,
    62        ANY_VALUE(current_failure_rate) AS EndPositionFailureRate,
    63        ARRAY_AGG(STRUCT(test_id AS TestId, variant_hash AS VariantHash,variant AS Variant) ORDER BY test_id, variant_hash) AS TestVariants,
    64        ANY_VALUE(segments[0].start_hour) AS StartHour,
    65        ANY_VALUE(segments[0].end_hour) AS EndHour
    66      FROM segments_with_failure_rate
    67      WHERE
    68        current_failure_rate = 1
    69        AND previous_failure_rate = 0
    70        AND segments[0].counts.unexpected_passed_results = 0
    71        AND segments[0].start_hour >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
    72        -- We only consider test failures with non-skipped result in the last 24 hour.
    73        AND segments[0].end_hour >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR)
    74      GROUP BY ref_hash, builder, nominal_lower, nominal_upper
    75    ),
    76    builder_regression_groups_with_latest_build AS (
    77      SELECT
    78        v.buildbucket_build.builder.bucket,
    79        v.buildbucket_build.builder.builder,
    80        ANY_VALUE(g) AS regression_group,
    81        ANY_VALUE(v.buildbucket_build.id HAVING MAX v.partition_time) AS build_id,
    82        ANY_VALUE(REGEXP_EXTRACT(v.results[0].parent.id, r'^task-{{.SwarmingProject}}.appspot.com-([0-9a-f]+)$') HAVING MAX v.partition_time) AS swarming_run_id,
    83        ANY_VALUE(COALESCE(b2.infra.swarming.task_dimensions, b2.infra.backend.task_dimensions, b.infra.swarming.task_dimensions, b.infra.backend.task_dimensions) HAVING MAX v.partition_time) AS task_dimensions,
    84        ANY_VALUE(JSON_VALUE_ARRAY(b.input.properties, "$.sheriff_rotations") HAVING MAX v.partition_time) AS SheriffRotations,
    85        ANY_VALUE(JSON_VALUE(b.input.properties, "$.builder_group") HAVING MAX v.partition_time) AS BuilderGroup,
    86      FROM builder_regression_groups g
    87      -- Join with test_verdict table to get the build id of the lastest build for a test variant.
    88      LEFT JOIN test_verdicts v
    89      ON g.testVariants[0].TestId = v.test_id
    90        AND g.testVariants[0].VariantHash = v.variant_hash
    91        AND g.RefHash = v.source_ref_hash
    92      -- Join with buildbucket builds table to get the buildbucket related information for tests.
    93      LEFT JOIN (select * from {{.BBTableName}} where create_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)) b
    94      ON v.buildbucket_build.id  = b.id
    95      -- JOIN with buildbucket builds table again to get task dimensions of parent builds.
    96      LEFT JOIN (select * from {{.BBTableName}} where create_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)) b2
    97      ON JSON_VALUE(b.input.properties, "$.parent_build_id") = CAST(b2.id AS string)
    98      -- Filter by test_verdict.partition_time to only return test failures that have test verdict recently.
    99      -- 3 days is chosen as we expect tests run at least once every 3 days if they are not disabled.
   100      -- If this is found to be too restricted, we can increase it later.
   101      WHERE v.partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)
   102      GROUP BY v.buildbucket_build.builder.bucket, v.buildbucket_build.builder.builder, g.testVariants[0].TestId,  g.testVariants[0].VariantHash, g.RefHash
   103    )
   104  {{- if .ExcludedPools}}
   105  {{- template "withExcludedPools" .}}
   106  {{- else}}
   107  {{- template "withoutExcludedPools" .}}
   108  {{- end -}}
   109  ORDER BY regression_group.RegressionEndPosition DESC
   110  LIMIT 5000
   111  {{- end}}
   112  
   113  {{- define "withoutExcludedPools"}}
   114  SELECT regression_group.*,
   115    bucket,
   116    builder,
   117    -- use empty array instead of null so we can read into []NullString.
   118    IFNULL(SheriffRotations, []) as SheriffRotations
   119  FROM builder_regression_groups_with_latest_build
   120  WHERE {{.DimensionExcludeFilter}} AND (bucket NOT IN UNNEST(@excludedBuckets))
   121    -- We need to compare ARRAY_LENGTH with null because of unexpected Bigquery behaviour b/138262091.
   122    AND ((BuilderGroup IN UNNEST(@allowedBuilderGroups)) OR ARRAY_LENGTH(@allowedBuilderGroups) = 0 OR ARRAY_LENGTH(@allowedBuilderGroups) IS NULL)
   123    AND (BuilderGroup NOT IN UNNEST(@excludedBuilderGroups))
   124  {{end}}
   125  
   126  {{define "withExcludedPools"}}
   127  SELECT regression_group.*,
   128    bucket,
   129    builder,
   130    -- use empty array instead of null so we can read into []NullString.
   131    IFNULL(SheriffRotations, []) as SheriffRotations
   132  FROM builder_regression_groups_with_latest_build g
   133  LEFT JOIN {{.SwarmingProject}}.swarming.task_results_run s
   134  ON g.swarming_run_id = s.run_id
   135  WHERE s.end_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)
   136    AND {{.DimensionExcludeFilter}} AND (bucket NOT IN UNNEST(@excludedBuckets))
   137    AND (s.bot.pools[0] NOT IN UNNEST(@excludedPools))
   138    -- We need to compare ARRAY_LENGTH with null because of unexpected Bigquery behaviour b/138262091.
   139    AND ((BuilderGroup IN UNNEST(@allowedBuilderGroups)) OR ARRAY_LENGTH(@allowedBuilderGroups) = 0 OR ARRAY_LENGTH(@allowedBuilderGroups) IS NULL)
   140    AND (BuilderGroup NOT IN UNNEST(@excludedBuilderGroups))
   141  {{end}}
   142  	`))
   143  
   144  // NewClient creates a new client for reading test failures from LUCI Analysis.
   145  // Close() MUST be called after you have finished using this client.
   146  // GCP project where the query operations are billed to, either luci-bisection or luci-bisection-dev.
   147  // luciAnalysisProject is the function that returns the gcp project that contains the BigQuery table we want to query.
   148  func NewClient(ctx context.Context, gcpProject string, luciAnalysisProjectFunc func(luciProject string) string) (*Client, error) {
   149  	if gcpProject == "" {
   150  		return nil, errors.New("GCP Project must be specified")
   151  	}
   152  	if luciAnalysisProjectFunc == nil {
   153  		return nil, errors.New("LUCI Analysis Project function must be specified")
   154  	}
   155  	tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(bigquery.Scope))
   156  	if err != nil {
   157  		return nil, err
   158  	}
   159  	client, err := bigquery.NewClient(ctx, gcpProject, option.WithHTTPClient(&http.Client{
   160  		Transport: tr,
   161  	}))
   162  	if err != nil {
   163  		return nil, err
   164  	}
   165  	return &Client{
   166  		client:                  client,
   167  		luciAnalysisProjectFunc: luciAnalysisProjectFunc,
   168  	}, nil
   169  }
   170  
   171  // Client may be used to read LUCI Analysis test failures.
   172  type Client struct {
   173  	client *bigquery.Client
   174  	// luciAnalysisProjectFunc is a function that return LUCI Analysis project
   175  	// given a LUCI Project.
   176  	luciAnalysisProjectFunc func(luciProject string) string
   177  }
   178  
   179  // Close releases any resources held by the client.
   180  func (c *Client) Close() error {
   181  	return c.client.Close()
   182  }
   183  
   184  // BuilderRegressionGroup contains a list of test variants
   185  // which use the same builder and have the same regression range.
   186  type BuilderRegressionGroup struct {
   187  	Bucket                   bigquery.NullString
   188  	Builder                  bigquery.NullString
   189  	RefHash                  bigquery.NullString
   190  	Ref                      *Ref
   191  	RegressionStartPosition  bigquery.NullInt64
   192  	RegressionEndPosition    bigquery.NullInt64
   193  	StartPositionFailureRate float64
   194  	EndPositionFailureRate   float64
   195  	TestVariants             []*TestVariant
   196  	StartHour                bigquery.NullTimestamp
   197  	EndHour                  bigquery.NullTimestamp
   198  	SheriffRotations         []bigquery.NullString
   199  }
   200  
   201  type Ref struct {
   202  	Gitiles *Gitiles
   203  }
   204  type Gitiles struct {
   205  	Host    bigquery.NullString
   206  	Project bigquery.NullString
   207  	Ref     bigquery.NullString
   208  }
   209  
   210  type TestVariant struct {
   211  	TestID      bigquery.NullString
   212  	VariantHash bigquery.NullString
   213  	Variant     bigquery.NullJSON
   214  }
   215  
   216  func (c *Client) ReadTestFailures(ctx context.Context, task *tpb.TestFailureDetectionTask, filter *configpb.FailureIngestionFilter) ([]*BuilderRegressionGroup, error) {
   217  	dimensionExcludeFilter := "(TRUE)"
   218  	if len(task.DimensionExcludes) > 0 {
   219  		dimensionExcludeFilter = "(NOT (SELECT LOGICAL_OR((SELECT count(*) > 0 FROM UNNEST(task_dimensions) WHERE KEY = kv.key and value = kv.value)) FROM UNNEST(@dimensionExcludes) kv))"
   220  	}
   221  
   222  	queryStm, err := generateTestFailuresQuery(task, dimensionExcludeFilter, filter.ExcludedTestPools)
   223  	if err != nil {
   224  		return nil, errors.Annotate(err, "generate test failures query").Err()
   225  	}
   226  	q := c.client.Query(queryStm)
   227  	q.DefaultDatasetID = task.Project
   228  	q.DefaultProjectID = c.luciAnalysisProjectFunc(task.Project)
   229  	q.Parameters = []bigquery.QueryParameter{
   230  		{Name: "dimensionExcludes", Value: task.DimensionExcludes},
   231  		{Name: "excludedBuckets", Value: filter.GetExcludedBuckets()},
   232  		{Name: "excludedPools", Value: filter.GetExcludedTestPools()},
   233  		{Name: "allowedBuilderGroups", Value: filter.GetAllowedBuilderGroups()},
   234  		{Name: "excludedBuilderGroups", Value: filter.GetExcludedBuilderGroups()},
   235  	}
   236  	job, err := q.Run(ctx)
   237  	if err != nil {
   238  		return nil, errors.Annotate(err, "querying test failures").Err()
   239  	}
   240  	it, err := job.Read(ctx)
   241  	if err != nil {
   242  		return nil, err
   243  	}
   244  	groups := []*BuilderRegressionGroup{}
   245  	for {
   246  		row := &BuilderRegressionGroup{}
   247  		err := it.Next(row)
   248  		if err == iterator.Done {
   249  			break
   250  		}
   251  		if err != nil {
   252  			return nil, errors.Annotate(err, "obtain next test failure group row").Err()
   253  		}
   254  		groups = append(groups, row)
   255  	}
   256  	return groups, nil
   257  }
   258  
   259  func generateTestFailuresQuery(task *tpb.TestFailureDetectionTask, dimensionExcludeFilter string, excludedPools []string) (string, error) {
   260  	bbTableName, err := buildBucketBuildTableName(task.Project)
   261  	if err != nil {
   262  		return "", errors.Annotate(err, "buildBucketBuildTableName").Err()
   263  	}
   264  
   265  	swarmingProject := ""
   266  	switch task.Project {
   267  	case "chromium":
   268  		swarmingProject = "chromium-swarm"
   269  	case "chrome":
   270  		swarmingProject = "chrome-swarming"
   271  	default:
   272  		return "", errors.Reason("couldn't get swarming project for project %s", task.Project).Err()
   273  	}
   274  
   275  	var b bytes.Buffer
   276  	err = readFailureTemplate.ExecuteTemplate(&b, "basic", map[string]any{
   277  		"SwarmingProject":        swarmingProject,
   278  		"DimensionExcludeFilter": dimensionExcludeFilter,
   279  		"BBTableName":            bbTableName,
   280  		"ExcludedPools":          excludedPools,
   281  	})
   282  	if err != nil {
   283  		return "", errors.Annotate(err, "execute template").Err()
   284  	}
   285  	return b.String(), nil
   286  }
   287  
   288  const BuildBucketProject = "cr-buildbucket"
   289  
   290  // This returns a qualified BigQuary table name of the builds table
   291  // in BuildBucket for a LUCI project.
   292  // The table name is checked against SQL-Injection.
   293  // Thus, it can be injected into a SQL query.
   294  func buildBucketBuildTableName(luciProject string) (string, error) {
   295  	// Revalidate project as safeguard against SQL-Injection.
   296  	if err := util.ValidateProject(luciProject); err != nil {
   297  		return "", err
   298  	}
   299  	return fmt.Sprintf("%s.%s.builds", BuildBucketProject, luciProject), nil
   300  }
   301  
   302  type BuildInfo struct {
   303  	BuildID         int64
   304  	StartCommitHash string
   305  	EndCommitHash   string
   306  }
   307  
   308  func (c *Client) ReadBuildInfo(ctx context.Context, tf *model.TestFailure) (BuildInfo, error) {
   309  	q := c.client.Query(`
   310  	SELECT
   311  		ANY_VALUE(buildbucket_build.id) AS BuildID,
   312  		ANY_VALUE(sources.gitiles_commit.commit_hash) AS CommitHash,
   313  		sources.gitiles_commit.position AS Position
   314  	FROM test_verdicts
   315  	WHERE test_id = @testID
   316  		AND variant_hash = @variantHash
   317  		AND source_ref_hash = @refHash
   318  		AND buildbucket_build.builder.bucket = @bucket
   319  		AND buildbucket_build.builder.builder = @builder
   320  		AND sources.gitiles_commit.position in (@startPosition, @endPosition)
   321  		AND partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
   322  	GROUP BY sources.gitiles_commit.position
   323  	ORDER BY sources.gitiles_commit.position DESC
   324  `)
   325  	q.DefaultDatasetID = tf.Project
   326  	q.DefaultProjectID = c.luciAnalysisProjectFunc(tf.Project)
   327  	q.Parameters = []bigquery.QueryParameter{
   328  		{Name: "testID", Value: tf.TestID},
   329  		{Name: "variantHash", Value: tf.VariantHash},
   330  		{Name: "refHash", Value: tf.RefHash},
   331  		{Name: "bucket", Value: tf.Bucket},
   332  		{Name: "builder", Value: tf.Builder},
   333  		{Name: "startPosition", Value: tf.RegressionStartPosition},
   334  		{Name: "endPosition", Value: tf.RegressionEndPosition},
   335  	}
   336  	job, err := q.Run(ctx)
   337  	if err != nil {
   338  		return BuildInfo{}, errors.Annotate(err, "querying test_verdicts").Err()
   339  	}
   340  	it, err := job.Read(ctx)
   341  	if err != nil {
   342  		return BuildInfo{}, err
   343  	}
   344  	rowVals := map[string]bigquery.Value{}
   345  	// First row is for regression end position.
   346  	err = it.Next(&rowVals)
   347  	if err != nil {
   348  		return BuildInfo{}, errors.Annotate(err, "read build info row for regression end position").Err()
   349  	}
   350  	// Make sure the first row is for the end position.
   351  	if rowVals["Position"].(int64) != tf.RegressionEndPosition {
   352  		return BuildInfo{}, errors.New("position should equal to RegressionEndPosition. this suggests something wrong with the query.")
   353  	}
   354  	buildInfo := BuildInfo{
   355  		BuildID:       rowVals["BuildID"].(int64),
   356  		EndCommitHash: rowVals["CommitHash"].(string),
   357  	}
   358  	// Second row is for regression start position.
   359  	err = it.Next(&rowVals)
   360  	if err != nil {
   361  		return BuildInfo{}, errors.Annotate(err, "read build info row for regression start position").Err()
   362  	}
   363  	// Make sure the second row is for the start position.
   364  	if rowVals["Position"].(int64) != tf.RegressionStartPosition {
   365  		return BuildInfo{}, errors.New("position should equal to RegressionStartPosition. this suggests something wrong with the query.")
   366  	}
   367  	buildInfo.StartCommitHash = rowVals["CommitHash"].(string)
   368  	return buildInfo, nil
   369  }
   370  
   371  type TestVerdictKey struct {
   372  	TestID      string
   373  	VariantHash string
   374  	RefHash     string
   375  }
   376  
   377  type TestVerdictResultRow struct {
   378  	TestID      bigquery.NullString
   379  	VariantHash bigquery.NullString
   380  	RefHash     bigquery.NullString
   381  	TestName    bigquery.NullString
   382  	Status      bigquery.NullString
   383  }
   384  
   385  type TestVerdictResult struct {
   386  	TestName string
   387  	Status   pb.TestVerdictStatus
   388  }
   389  
   390  // ReadLatestVerdict queries LUCI Analysis for latest verdict.
   391  // It supports querying for multiple keys at a time to save time and resources.
   392  // Returns a map of TestVerdictKey -> latest verdict.
   393  func (c *Client) ReadLatestVerdict(ctx context.Context, project string, keys []TestVerdictKey) (map[TestVerdictKey]TestVerdictResult, error) {
   394  	if len(keys) == 0 {
   395  		return nil, errors.New("no key specified")
   396  	}
   397  	err := validateTestVerdictKeys(keys)
   398  	if err != nil {
   399  		return nil, errors.Annotate(err, "validate keys").Err()
   400  	}
   401  	clauses := make([]string, len(keys))
   402  	for i, key := range keys {
   403  		clauses[i] = fmt.Sprintf("(test_id = %q AND variant_hash = %q AND source_ref_hash = %q)", key.TestID, key.VariantHash, key.RefHash)
   404  	}
   405  	whereClause := fmt.Sprintf("(%s)", strings.Join(clauses, " OR "))
   406  
   407  	// We expect a test to have result in the last 3 days.
   408  	// Set the partition time to 3 days to reduce the cost.
   409  	query := `
   410  		SELECT
   411  			test_id AS TestID,
   412  			variant_hash AS VariantHash,
   413  			source_ref_hash AS RefHash,
   414  			ARRAY_AGG (
   415  				(	SELECT value FROM UNNEST(tv.results[0].tags) WHERE KEY = "test_name")
   416  					ORDER BY tv.partition_time DESC
   417  					LIMIT 1
   418  				)[OFFSET(0)] AS TestName,
   419  			ANY_VALUE(status HAVING MAX tv.partition_time) AS Status
   420  		FROM test_verdicts tv
   421  		WHERE ` + whereClause + `
   422  		AND partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)
   423  		GROUP BY test_id, variant_hash, source_ref_hash
   424   	`
   425  	logging.Infof(ctx, "Running query %s", query)
   426  	q := c.client.Query(query)
   427  	q.DefaultDatasetID = project
   428  	q.DefaultProjectID = c.luciAnalysisProjectFunc(project)
   429  	job, err := q.Run(ctx)
   430  	if err != nil {
   431  		return nil, errors.Annotate(err, "querying test name").Err()
   432  	}
   433  	it, err := job.Read(ctx)
   434  	if err != nil {
   435  		return nil, errors.Annotate(err, "read").Err()
   436  	}
   437  	results := map[TestVerdictKey]TestVerdictResult{}
   438  	for {
   439  		row := &TestVerdictResultRow{}
   440  		err := it.Next(row)
   441  		if err == iterator.Done {
   442  			break
   443  		}
   444  		if err != nil {
   445  			return nil, errors.Annotate(err, "obtain next row").Err()
   446  		}
   447  		key := TestVerdictKey{
   448  			TestID:      row.TestID.String(),
   449  			VariantHash: row.VariantHash.String(),
   450  			RefHash:     row.RefHash.String(),
   451  		}
   452  		results[key] = TestVerdictResult{
   453  			TestName: row.TestName.String(),
   454  			Status:   pb.TestVerdictStatus(pb.TestVerdictStatus_value[row.Status.String()]),
   455  		}
   456  	}
   457  	return results, nil
   458  }
   459  
   460  type CountRow struct {
   461  	Count bigquery.NullInt64
   462  }
   463  
   464  // TestIsUnexpectedConsistently queries LUCI Analysis to see if a test is
   465  // still unexpected deterministically since a commit position.
   466  // This is to be called before we take a culprit action, in case a test
   467  // status has changed.
   468  func (c *Client) TestIsUnexpectedConsistently(ctx context.Context, project string, key TestVerdictKey, sinceCommitPosition int64) (bool, error) {
   469  	err := validateTestVerdictKeys([]TestVerdictKey{key})
   470  	if err != nil {
   471  		return false, errors.Annotate(err, "validate keys").Err()
   472  	}
   473  	// If there is a row with counts.total_non_skipped > counts.unexpected_non_skipped,
   474  	// It means there are some expected non skipped results.
   475  	query := `
   476  		SELECT
   477  			COUNT(*) as count
   478  		FROM test_verdicts
   479  		WHERE test_id = @testID AND variant_hash = @variantHash AND source_ref_hash = @refHash
   480  		AND counts.total_non_skipped > counts.unexpected_non_skipped
   481  		AND sources.gitiles_commit.position > @sinceCommitPosition
   482  		AND partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)
   483   	`
   484  	logging.Infof(ctx, "Running query %s", query)
   485  	q := c.client.Query(query)
   486  	q.DefaultDatasetID = project
   487  	q.DefaultProjectID = c.luciAnalysisProjectFunc(project)
   488  	q.Parameters = []bigquery.QueryParameter{
   489  		{Name: "testID", Value: key.TestID},
   490  		{Name: "variantHash", Value: key.VariantHash},
   491  		{Name: "refHash", Value: key.RefHash},
   492  		{Name: "sinceCommitPosition", Value: sinceCommitPosition},
   493  	}
   494  
   495  	job, err := q.Run(ctx)
   496  	if err != nil {
   497  		return false, errors.Annotate(err, "running query").Err()
   498  	}
   499  	it, err := job.Read(ctx)
   500  	if err != nil {
   501  		return false, errors.Annotate(err, "read").Err()
   502  	}
   503  	row := &CountRow{}
   504  	err = it.Next(row)
   505  	if err == iterator.Done {
   506  		return false, errors.New("cannot get count")
   507  	}
   508  	if err != nil {
   509  		return false, errors.Annotate(err, "obtain next row").Err()
   510  	}
   511  	return row.Count.Int64 == 0, nil
   512  }
   513  
   514  type ChangepointResult struct {
   515  	TestID      string
   516  	VariantHash string
   517  	RefHash     string
   518  	Segments    []*Segment
   519  }
   520  type Segment struct {
   521  	StartPosition          bigquery.NullInt64
   522  	EndPosition            bigquery.NullInt64
   523  	CountTotalResults      bigquery.NullInt64
   524  	CountUnexpectedResults bigquery.NullInt64
   525  }
   526  
   527  func (c *Client) ChangepointAnalysisForTestVariant(ctx context.Context, project string, keys []TestVerdictKey) (map[TestVerdictKey]*ChangepointResult, error) {
   528  	err := validateTestVerdictKeys(keys)
   529  	if err != nil {
   530  		return nil, errors.Annotate(err, "validate keys").Err()
   531  	}
   532  	clauses := make([]string, len(keys))
   533  	for i, key := range keys {
   534  		clauses[i] = fmt.Sprintf("(test_id = %q AND variant_hash = %q AND ref_hash = %q)", key.TestID, key.VariantHash, key.RefHash)
   535  	}
   536  	whereClause := fmt.Sprintf("(%s)", strings.Join(clauses, " OR "))
   537  	query := `
   538  		SELECT
   539  			test_id as TestID,
   540  			variant_hash as VariantHash,
   541  			ref_hash as RefHash,
   542  			(SELECT
   543  					ARRAY_AGG(STRUCT(
   544  					s.start_position as StartPosition,
   545  					s.end_position as EndPosition,
   546  					s.counts.total_results as CountTotalResults,
   547  					s.counts.unexpected_results as CountUnexpectedResults))
   548  				FROM UNNEST(segments) s
   549  			) AS Segments
   550  		FROM test_variant_segments_unexpected_realtime
   551  		WHERE ` + whereClause
   552  	logging.Infof(ctx, "Running query %s", query)
   553  	q := c.client.Query(query)
   554  	q.DefaultDatasetID = project
   555  	q.DefaultProjectID = c.luciAnalysisProjectFunc(project)
   556  
   557  	job, err := q.Run(ctx)
   558  	if err != nil {
   559  		return nil, errors.Annotate(err, "running query").Err()
   560  	}
   561  	it, err := job.Read(ctx)
   562  	if err != nil {
   563  		return nil, errors.Annotate(err, "read").Err()
   564  	}
   565  	results := map[TestVerdictKey]*ChangepointResult{}
   566  	for {
   567  		row := &ChangepointResult{}
   568  		err := it.Next(row)
   569  		if err == iterator.Done {
   570  			break
   571  		}
   572  		if err != nil {
   573  			return nil, errors.Annotate(err, "obtain next changepoint row").Err()
   574  		}
   575  		key := TestVerdictKey{
   576  			TestID:      row.TestID,
   577  			VariantHash: row.VariantHash,
   578  			RefHash:     row.RefHash,
   579  		}
   580  		results[key] = row
   581  	}
   582  	return results, nil
   583  }
   584  
   585  func validateTestVerdictKeys(keys []TestVerdictKey) error {
   586  	for _, key := range keys {
   587  		if err := rdbpbutil.ValidateTestID(key.TestID); err != nil {
   588  			return err
   589  		}
   590  		if err := util.ValidateVariantHash(key.VariantHash); err != nil {
   591  			return err
   592  		}
   593  		if err := util.ValidateRefHash(key.RefHash); err != nil {
   594  			return err
   595  		}
   596  	}
   597  	return nil
   598  }