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 }