go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/testresults/query_failure_rate.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 16 17 import ( 18 "context" 19 "text/template" 20 "time" 21 22 "cloud.google.com/go/spanner" 23 "google.golang.org/protobuf/types/known/timestamppb" 24 25 "go.chromium.org/luci/common/sync/parallel" 26 "go.chromium.org/luci/server/span" 27 28 spanutil "go.chromium.org/luci/analysis/internal/span" 29 "go.chromium.org/luci/analysis/pbutil" 30 pb "go.chromium.org/luci/analysis/proto/v1" 31 ) 32 33 const ( 34 // The maximum number of workers to run in parallel. 35 // Given 100 is the maximum number of test variants queried at once, 36 // it is desirable that maxWorkers * batchSize >= 100. 37 maxWorkers = 10 38 39 // The size of each batch (in test variants). 40 batchSize = 10 41 ) 42 43 // QueryFailureRateOptions specifies options for QueryFailureRate(). 44 type QueryFailureRateOptions struct { 45 // Project is the LUCI Project to query. 46 Project string 47 // SubRealms are the realms (of the form "ci", NOT "chromium:ci") 48 // within the project to query. 49 SubRealms []string 50 // TestVariants are the test variants to query. 51 TestVariants []*pb.TestVariantIdentifier 52 // AsAtTime is latest parititon time to include in the results; 53 // outside of testing contexts, this should be the current time. 54 // QueryTestVariants returns data for the 5 * 24 weekday hour 55 // period leading up to this time. 56 AsAtTime time.Time 57 } 58 59 // QueryFailureRate queries the failure rate of nominated test variants. 60 // 61 // Must be called in a Spanner transactional context. Context must 62 // support multiple reads (i.e. NOT spanner.Single()) as request may 63 // batched over multiple reads. 64 func QueryFailureRate(ctx context.Context, opts QueryFailureRateOptions) (*pb.QueryTestVariantFailureRateResponse, error) { 65 batches := partitionIntoBatches(opts.TestVariants) 66 67 intervals := defineIntervals(opts.AsAtTime) 68 69 err := parallel.WorkPool(maxWorkers, func(c chan<- func() error) { 70 for _, b := range batches { 71 // Assign batch to a local variable to ensure its current 72 // value is captured by function closures. 73 batch := b 74 c <- func() error { 75 var err error 76 batchOpts := opts 77 batchOpts.TestVariants = batch.input 78 // queryFailureRateShard ensures test variants appear 79 // in the output in the same order as they appear in the 80 // input. 81 batch.output, err = queryFailureRateShard(ctx, batchOpts, intervals) 82 return err 83 } 84 } 85 }) 86 if err != nil { 87 return nil, err 88 } 89 90 // The order of test variants in the output should be the 91 // same as the input. Perform the inverse to what we did 92 // in batching. 93 analysis := make([]*pb.TestVariantFailureRateAnalysis, 0, len(opts.TestVariants)) 94 for _, b := range batches { 95 analysis = append(analysis, b.output...) 96 } 97 98 response := &pb.QueryTestVariantFailureRateResponse{ 99 Intervals: toPBIntervals(intervals), 100 TestVariants: analysis, 101 } 102 return response, nil 103 } 104 105 type batch struct { 106 input []*pb.TestVariantIdentifier 107 output []*pb.TestVariantFailureRateAnalysis 108 } 109 110 // partitionIntoBatches partitions a list of test variants into batches. 111 func partitionIntoBatches(tvs []*pb.TestVariantIdentifier) []*batch { 112 var batches []*batch 113 batchInput := make([]*pb.TestVariantIdentifier, 0, batchSize) 114 for _, tv := range tvs { 115 if len(batchInput) >= batchSize { 116 batches = append(batches, &batch{ 117 input: batchInput, 118 }) 119 batchInput = make([]*pb.TestVariantIdentifier, 0, batchSize) 120 } 121 batchInput = append(batchInput, tv) 122 } 123 if len(batchInput) > 0 { 124 batches = append(batches, &batch{ 125 input: batchInput, 126 }) 127 } 128 return batches 129 } 130 131 // queryFailureRateShard reads failure rate statistics for test variants. 132 // Must be called in a spanner transactional context. 133 func queryFailureRateShard(ctx context.Context, opts QueryFailureRateOptions, intervals []interval) ([]*pb.TestVariantFailureRateAnalysis, error) { 134 type testVariant struct { 135 TestID string 136 VariantHash string 137 } 138 139 tvs := make([]testVariant, 0, len(opts.TestVariants)) 140 for _, ptv := range opts.TestVariants { 141 variantHash := ptv.VariantHash 142 if variantHash == "" { 143 variantHash = pbutil.VariantHash(ptv.Variant) 144 } 145 146 tvs = append(tvs, testVariant{ 147 TestID: ptv.TestId, 148 VariantHash: variantHash, 149 }) 150 } 151 152 stmt, err := spanutil.GenerateStatement(failureRateQueryTmpl, failureRateQueryTmpl.Name(), nil) 153 if err != nil { 154 return nil, err 155 } 156 stmt.Params = map[string]any{ 157 "project": opts.Project, 158 "testVariants": tvs, 159 "subRealms": opts.SubRealms, 160 "afterPartitionTime": queryStartTime(intervals), 161 "beforePartitionTime": queryEndTime(intervals), 162 "skip": int64(pb.TestResultStatus_SKIP), 163 } 164 165 results := make([]*pb.TestVariantFailureRateAnalysis, 0, len(tvs)) 166 167 index := 0 168 var b spanutil.Buffer 169 err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error { 170 var testID, variantHash string 171 var intervalStats []*intervalStats 172 var runFlakyExamples []*verdictExample 173 var recentVerdicts []*recentVerdict 174 175 err := b.FromSpanner( 176 row, 177 &testID, 178 &variantHash, 179 &intervalStats, 180 &runFlakyExamples, 181 &recentVerdicts, 182 ) 183 if err != nil { 184 return err 185 } 186 187 analysis := &pb.TestVariantFailureRateAnalysis{} 188 if testID != tvs[index].TestID || variantHash != tvs[index].VariantHash { 189 // This should never happen, as the SQL statement is designed 190 // to return results in the same order as test variants requested. 191 panic("results in incorrect order") 192 } 193 194 analysis.TestId = testID 195 analysis.Variant = opts.TestVariants[index].Variant 196 analysis.VariantHash = opts.TestVariants[index].VariantHash 197 analysis.IntervalStats = toPBIntervalStats(intervalStats, intervals) 198 analysis.RunFlakyVerdictExamples = toPBVerdictExamples(runFlakyExamples) 199 analysis.RecentVerdicts = toPBRecentVerdicts(recentVerdicts) 200 results = append(results, analysis) 201 index++ 202 return nil 203 }) 204 if err != nil { 205 return nil, err 206 } 207 return results, nil 208 } 209 210 // jumpBack24WeekdayHours calculates the start time of an interval 211 // ending at the given endTime, such that the interval includes exactly 212 // 24 hours of weekday data (in UTC). Where there are multiple interval 213 // start times satisfying this criteria, the latest time is selected. 214 // 215 // For example, if the endTime is 08:21 on Tuesday, we would pick 08:21 216 // on Monday as the start time, as the period from start time to end time 217 // includes 24 hours of weekday (split over Monday and Tuesday). 218 // 219 // If the endTime is 08:21 on Monday, we would pick 08:21 on the previous 220 // Friday as the start time, as the period from start time to end time 221 // includes 24 hours of weekday (split over the Friday and Monday). 222 // 223 // If the endTime is midnight on the morning of Tuesday, any start time from 224 // midnight on Saturday morning to midnight on Monday morning would produce 225 // an interval that includes 24 hours of weekday data (i.e. the 24 hours of 226 // Monday). In this case, we pick midnight on Monday. This is the only case 227 // that is ambiguous. 228 // 229 // Rationale: 230 // Many projects see reduced testing activity on weekends, as fewer CLs are 231 // submitted. To avoid a dip in the sample size of statistics returned 232 // on these days (which stops exoneration), we effectively bunch weekend 233 // data together with Friday data in one period. 234 func jumpBack24WeekdayHours(endTime time.Time) time.Time { 235 endTime = endTime.In(time.UTC) 236 var startTime time.Time 237 switch endTime.Weekday() { 238 case time.Saturday: 239 // Take us back to Saturday at 0:00. 240 startTime = endTime.Truncate(24 * time.Hour) 241 // Now take us back to Friday at 0:00. 242 startTime = startTime.Add(-1 * 24 * time.Hour) 243 case time.Sunday: 244 // Take us back to Sunday at 0:00. 245 startTime = endTime.Truncate(24 * time.Hour) 246 // Now take us back to Friday at 0:00. 247 startTime = startTime.Add(-2 * 24 * time.Hour) 248 case time.Monday: 249 // Take take us back to the same time 250 // on the Friday. 251 startTime = endTime.Add(-3 * 24 * time.Hour) 252 default: 253 // Take take us back to the same time 254 // on the previous day (which will be a weekday). 255 startTime = endTime.Add(-24 * time.Hour) 256 } 257 return startTime 258 } 259 260 // interval represents a time interval of data to be returned by 261 // QueryFailureRate. 262 type interval struct { 263 // The interval start time (inclusive). 264 startTime time.Time 265 // The interval end time (exclusive). 266 endTime time.Time 267 } 268 269 // defineIntervals defines the time intervals that should be returned 270 // by the QueryFailureRate query. This comprises five consecutive 271 // 24 weekday hour intervals ending at the given asAtTime. 272 // 273 // The first interval is the most recent interval, which ends at asAtTime 274 // and starts at such a time that means the interval includes 24 275 // hours of weekday data (as measured in UTC). 276 // The second interval ends at the start time of the first interval, 277 // and is generated in a similar fashion, and so on for the other 278 // three intervals. 279 // See jumpBack24WeekdayHours for how "24 weekday hours" is defined. 280 func defineIntervals(asAtTime time.Time) []interval { 281 const intervalCount = 5 282 result := make([]interval, 0, intervalCount) 283 endTime := asAtTime.In(time.UTC) 284 for i := 0; i < intervalCount; i++ { 285 startTime := jumpBack24WeekdayHours(endTime) 286 result = append(result, interval{ 287 startTime: startTime, 288 endTime: endTime, 289 }) 290 endTime = startTime 291 } 292 return result 293 } 294 295 // queryStartTime returns the start of the partition time range that 296 // should be queried (inclusive). Verdicts with a partition time 297 // earlier than this time should not be included in the results. 298 func queryStartTime(intervals []interval) time.Time { 299 return intervals[len(intervals)-1].startTime 300 } 301 302 // queryEndTime returns the end of the partition time range that should be 303 // queried (exclusive). Verdicts with partition times later than (or equal to) 304 // this time should not be included in the results. 305 func queryEndTime(intervals []interval) time.Time { 306 return intervals[0].endTime 307 } 308 309 func toPBIntervals(intervals []interval) []*pb.QueryTestVariantFailureRateResponse_Interval { 310 result := make([]*pb.QueryTestVariantFailureRateResponse_Interval, 0, len(intervals)) 311 for i, iv := range intervals { 312 result = append(result, &pb.QueryTestVariantFailureRateResponse_Interval{ 313 IntervalAge: int32(i + 1), 314 StartTime: timestamppb.New(iv.startTime), 315 EndTime: timestamppb.New(iv.endTime), 316 }) 317 } 318 return result 319 } 320 321 // intervalStats represents time interval data returned by the 322 // QueryFailureRate query. Each interval represents 24 hours of data. 323 type intervalStats struct { 324 // DaysSinceQueryStart is the time interval bucket represented by this 325 // row. 326 // Interval 0 represents the first 24 hours of the partition time range 327 // queried, interval 1 is the second 24 hours, and so on. 328 DaysSinceQueryStart int64 329 // TotalRunExpectedVerdicts is the number of verdicts which had only 330 // expected runs. An expected run is a run in which at least one test 331 // result was expected (excluding skips). 332 TotalRunExpectedVerdicts int64 333 // TotalRunFlakyVerdicts is the number of verdicts which had both expected 334 // and unexpected runs. An expected run is a run in which at least one 335 // test result was expected (excluding skips). An unexpected run is a run 336 // in which all test results are unexpected (excluding skips). 337 TotalRunFlakyVerdicts int64 338 // TotalRunExpectedVerdicts is the number of verdicts which had only 339 // unexpected runs. An unexpected run is a run in which 340 // al test results are unexpected (excluding skips). 341 TotalRunUnexpectedVerdicts int64 342 } 343 344 func toPBIntervalStats(stats []*intervalStats, intervals []interval) []*pb.TestVariantFailureRateAnalysis_IntervalStats { 345 queryStartTime := queryStartTime(intervals) 346 queryEndTime := queryEndTime(intervals) 347 348 results := make([]*pb.TestVariantFailureRateAnalysis_IntervalStats, 0, len(intervals)) 349 // Ensure every interval is included in the output, even if there are 350 // no verdicts in it. 351 for i := range intervals { 352 results = append(results, &pb.TestVariantFailureRateAnalysis_IntervalStats{ 353 IntervalAge: int32(i + 1), 354 }) 355 } 356 for _, s := range stats { 357 // Match the interval data returned by the SQL query to the intervals 358 // returned by the RPC. The SQL query returns one interval per 359 // rolling 24 hour period, whereas the RPC returns one interval per 360 // rolling 24 weekday hour period (including any weekend included 361 // in that range). In practice, this means weekend data needs to get 362 // summed into the 24 weekday hour period that includes the Friday. 363 364 // Calculate the time interval represented by the interval data 365 // returned by the query. 366 intervalStartTime := queryStartTime.Add(time.Duration(s.DaysSinceQueryStart) * 24 * time.Hour) 367 intervalEndTime := intervalStartTime.Add(24 * time.Hour) 368 if queryEndTime.Before(intervalEndTime) { 369 intervalEndTime = queryEndTime 370 } 371 372 // Find the output interval that contains the query interval. 373 intervalIndex := -1 374 for i, iv := range intervals { 375 if !intervalEndTime.After(iv.endTime) && !intervalStartTime.Before(iv.startTime) { 376 intervalIndex = i 377 break 378 } 379 } 380 if intervalIndex == -1 { 381 // This should never happen. 382 panic("could not reconcile query intervals with output intervals") 383 } 384 385 results[intervalIndex].TotalRunExpectedVerdicts += int32(s.TotalRunExpectedVerdicts) 386 results[intervalIndex].TotalRunFlakyVerdicts += int32(s.TotalRunFlakyVerdicts) 387 results[intervalIndex].TotalRunUnexpectedVerdicts += int32(s.TotalRunUnexpectedVerdicts) 388 } 389 return results 390 } 391 392 // verdictExample is used to store an example verdict returned by 393 // a Spanner query. 394 type verdictExample struct { 395 PartitionTime time.Time 396 IngestedInvocationId string 397 ChangelistHosts []string 398 ChangelistChanges []int64 399 ChangelistPatchsets []int64 400 ChangelistOwnerKinds []string 401 } 402 403 func toPBVerdictExamples(ves []*verdictExample) []*pb.TestVariantFailureRateAnalysis_VerdictExample { 404 results := make([]*pb.TestVariantFailureRateAnalysis_VerdictExample, 0, len(ves)) 405 for _, ve := range ves { 406 cls := make([]*pb.Changelist, 0, len(ve.ChangelistHosts)) 407 // TODO(b/258734241): Expect ChangelistOwnerKinds will 408 // have matching length in all cases from March 2023. 409 if len(ve.ChangelistHosts) != len(ve.ChangelistChanges) || 410 len(ve.ChangelistChanges) != len(ve.ChangelistPatchsets) || 411 (ve.ChangelistOwnerKinds != nil && len(ve.ChangelistOwnerKinds) != len(ve.ChangelistHosts)) { 412 panic("data consistency issue: length of changelist arrays do not match") 413 } 414 for i := range ve.ChangelistHosts { 415 var ownerKind pb.ChangelistOwnerKind 416 if ve.ChangelistOwnerKinds != nil { 417 ownerKind = OwnerKindFromDB(ve.ChangelistOwnerKinds[i]) 418 } 419 cls = append(cls, &pb.Changelist{ 420 Host: DecompressHost(ve.ChangelistHosts[i]), 421 Change: ve.ChangelistChanges[i], 422 Patchset: int32(ve.ChangelistPatchsets[i]), 423 OwnerKind: ownerKind, 424 }) 425 } 426 results = append(results, &pb.TestVariantFailureRateAnalysis_VerdictExample{ 427 PartitionTime: timestamppb.New(ve.PartitionTime), 428 IngestedInvocationId: ve.IngestedInvocationId, 429 Changelists: cls, 430 }) 431 } 432 return results 433 } 434 435 // recentVerdict represents one of the most recent verdicts for the test variant. 436 type recentVerdict struct { 437 verdictExample 438 HasUnexpectedRun bool 439 } 440 441 func toPBRecentVerdicts(verdicts []*recentVerdict) []*pb.TestVariantFailureRateAnalysis_RecentVerdict { 442 results := make([]*pb.TestVariantFailureRateAnalysis_RecentVerdict, 0, len(verdicts)) 443 for _, v := range verdicts { 444 cls := make([]*pb.Changelist, 0, len(v.ChangelistHosts)) 445 // TODO(b/258734241): Expect ChangelistOwnerKinds will 446 // have matching length in all cases from March 2023. 447 if len(v.ChangelistHosts) != len(v.ChangelistChanges) || 448 len(v.ChangelistChanges) != len(v.ChangelistPatchsets) || 449 (v.ChangelistOwnerKinds != nil && len(v.ChangelistOwnerKinds) != len(v.ChangelistHosts)) { 450 panic("data consistency issue: length of changelist arrays do not match") 451 } 452 for i := range v.ChangelistHosts { 453 var ownerKind pb.ChangelistOwnerKind 454 if v.ChangelistOwnerKinds != nil { 455 ownerKind = OwnerKindFromDB(v.ChangelistOwnerKinds[i]) 456 } 457 cls = append(cls, &pb.Changelist{ 458 Host: DecompressHost(v.ChangelistHosts[i]), 459 Change: v.ChangelistChanges[i], 460 Patchset: int32(v.ChangelistPatchsets[i]), 461 OwnerKind: ownerKind, 462 }) 463 } 464 results = append(results, &pb.TestVariantFailureRateAnalysis_RecentVerdict{ 465 PartitionTime: timestamppb.New(v.PartitionTime), 466 IngestedInvocationId: v.IngestedInvocationId, 467 Changelists: cls, 468 HasUnexpectedRuns: v.HasUnexpectedRun, 469 }) 470 } 471 return results 472 } 473 474 var failureRateQueryTmpl = template.Must(template.New("").Parse(` 475 WITH test_variant_verdicts AS ( 476 SELECT 477 Index, 478 TestId, 479 VariantHash, 480 ARRAY( 481 -- Filter verdicts to at most one per unsubmitted changelist under 482 -- test. Don't filter verdicts without an unsubmitted changelist 483 -- under test (i.e. CI data). 484 SELECT 485 ANY_VALUE(STRUCT( 486 PartitionTime, 487 IngestedInvocationId, 488 HasUnexpectedRun, 489 HasExpectedRun, 490 ChangelistHosts, 491 ChangelistChanges, 492 ChangelistPatchsets, 493 ChangelistOwnerKinds, 494 AnyChangelistsByAutomation) 495 -- Prefer the verdict that is flaky. If both (or neither) are flaky, 496 -- pick the verdict with the highest partition time. If partition 497 -- times are also the same, pick any. 498 HAVING MAX IF(HasExpectedRun AND HasUnexpectedRun, TIMESTAMP_ADD(PartitionTime, INTERVAL 365 DAY), PartitionTime)) AS Verdict, 499 FROM ( 500 -- Flatten test runs to test verdicts. 501 SELECT 502 PartitionTime, 503 IngestedInvocationId, 504 LOGICAL_OR(UnexpectedRun) AS HasUnexpectedRun, 505 LOGICAL_OR(NOT UnexpectedRun) AS HasExpectedRun, 506 ANY_VALUE(ChangelistHosts) AS ChangelistHosts, 507 ANY_VALUE(ChangelistChanges) AS ChangelistChanges, 508 ANY_VALUE(ChangelistPatchsets) AS ChangelistPatchsets, 509 ANY_VALUE(ChangelistOwnerKinds) AS ChangelistOwnerKinds, 510 ANY_VALUE(AnyChangelistsByAutomation) As AnyChangelistsByAutomation 511 FROM ( 512 -- Flatten test results to test runs. 513 SELECT 514 PartitionTime, 515 IngestedInvocationId, 516 RunIndex, 517 LOGICAL_AND(COALESCE(IsUnexpected, FALSE)) AS UnexpectedRun, 518 ANY_VALUE(ChangelistHosts) AS ChangelistHosts, 519 ANY_VALUE(ChangelistChanges) AS ChangelistChanges, 520 ANY_VALUE(ChangelistPatchsets) AS ChangelistPatchsets, 521 ANY_VALUE(ChangelistOwnerKinds) AS ChangelistOwnerKinds, 522 'A' IN UNNEST(ANY_VALUE(ChangelistOwnerKinds)) AS AnyChangelistsByAutomation 523 FROM TestResults 524 WHERE Project = @project 525 AND PartitionTime >= @afterPartitionTime 526 AND PartitionTime < @beforePartitionTime 527 AND TestId = tv.TestId And VariantHash = tv.VariantHash 528 AND SubRealm IN UNNEST(@subRealms) 529 -- Exclude skipped results. 530 AND Status <> @skip 531 -- Exclude test results testing multiple CLs, as 532 -- we cannot ensure at most one verdict per CL for 533 -- them. 534 AND (ChangelistHosts IS NULL OR ARRAY_LENGTH(ChangelistHosts) <= 1) 535 GROUP BY PartitionTime, IngestedInvocationId, RunIndex 536 ) 537 GROUP BY PartitionTime, IngestedInvocationId 538 ORDER BY PartitionTime DESC, IngestedInvocationId 539 ) 540 -- Unique CL (if there is a CL under test). 541 GROUP BY 542 IF(ChangelistHosts IS NOT NULL AND ARRAY_LENGTH(ChangelistHosts) > 0, ChangelistHosts[OFFSET(0)], IngestedInvocationId), 543 IF(ChangelistHosts IS NOT NULL AND ARRAY_LENGTH(ChangelistHosts) > 0, ChangelistChanges[OFFSET(0)], NULL) 544 ORDER BY Verdict.PartitionTime DESC, Verdict.IngestedInvocationId 545 ) AS Verdicts, 546 FROM UNNEST(@testVariants) tv WITH OFFSET Index 547 ) 548 549 SELECT 550 TestId, 551 VariantHash, 552 ARRAY( 553 SELECT AS STRUCT 554 TIMESTAMP_DIFF(v.PartitionTime, @afterPartitionTime, DAY) as DaysSinceQueryStart, 555 COUNTIF(NOT v.HasUnexpectedRun AND v.HasExpectedRun) AS TotalRunExpectedVerdicts, 556 COUNTIF(v.HasUnexpectedRun AND v.HasExpectedRun) AS TotalRunFlakyVerdicts, 557 COUNTIF(v.HasUnexpectedRun AND NOT v.HasExpectedRun) AS TotalRunUnexpectedVerdicts 558 FROM UNNEST(Verdicts) v 559 WHERE 560 -- Filter out CLs authored by automation. 561 NOT v.AnyChangelistsByAutomation 562 GROUP BY DaysSinceQueryStart 563 ORDER BY DaysSinceQueryStart DESC 564 ) As IntervalStats, 565 ARRAY( 566 SELECT AS STRUCT 567 v.PartitionTime, 568 v.IngestedInvocationId, 569 v.ChangelistHosts, 570 v.ChangelistChanges, 571 v.ChangelistPatchsets, 572 v.ChangelistOwnerKinds, 573 FROM UNNEST(Verdicts) v WITH OFFSET o 574 WHERE v.HasUnexpectedRun AND v.HasExpectedRun AND 575 -- Filter out CLs authored by automation. 576 NOT v.AnyChangelistsByAutomation 577 ORDER BY o -- Order by descending partition time. 578 LIMIT 10 579 ) as RunFlakyExamples, 580 ARRAY( 581 SELECT AS STRUCT 582 v.PartitionTime, 583 v.IngestedInvocationId, 584 v.ChangelistHosts, 585 v.ChangelistChanges, 586 v.ChangelistPatchsets, 587 v.ChangelistOwnerKinds, 588 v.HasUnexpectedRun 589 FROM UNNEST(Verdicts) v WITH OFFSET o 590 WHERE 591 -- Filter out CLs authored by automation. 592 NOT v.AnyChangelistsByAutomation 593 ORDER BY o 594 LIMIT 10 595 ) as RecentVerdicts, 596 FROM test_variant_verdicts 597 ORDER BY Index 598 `))