go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/clustering/ingestion/ingest_test.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 ingestion
    16  
    17  import (
    18  	"encoding/hex"
    19  	"fmt"
    20  	"sort"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/protobuf/proto"
    25  	"google.golang.org/protobuf/types/known/durationpb"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/gae/impl/memory"
    29  	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
    30  	"go.chromium.org/luci/server/caching"
    31  
    32  	"go.chromium.org/luci/analysis/internal/analysis"
    33  	"go.chromium.org/luci/analysis/internal/analysis/clusteredfailures"
    34  	"go.chromium.org/luci/analysis/internal/clustering"
    35  	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
    36  	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
    37  	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
    38  	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
    39  	"go.chromium.org/luci/analysis/internal/clustering/chunkstore"
    40  	clusteringpb "go.chromium.org/luci/analysis/internal/clustering/proto"
    41  	"go.chromium.org/luci/analysis/internal/clustering/rules"
    42  	"go.chromium.org/luci/analysis/internal/config"
    43  	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
    44  	"go.chromium.org/luci/analysis/internal/testutil"
    45  	bqpb "go.chromium.org/luci/analysis/proto/bq"
    46  	configpb "go.chromium.org/luci/analysis/proto/config"
    47  	pb "go.chromium.org/luci/analysis/proto/v1"
    48  
    49  	. "github.com/smartystreets/goconvey/convey"
    50  	. "go.chromium.org/luci/common/testing/assertions"
    51  )
    52  
    53  func TestIngest(t *testing.T) {
    54  	Convey(`With Ingestor`, t, func() {
    55  		ctx := testutil.IntegrationTestContext(t)
    56  		ctx = caching.WithEmptyProcessCache(ctx) // For rules cache.
    57  		ctx = memory.Use(ctx)                    // For project config in datastore.
    58  
    59  		chunkStore := chunkstore.NewFakeClient()
    60  		clusteredFailures := clusteredfailures.NewFakeClient()
    61  		analysis := analysis.NewClusteringHandler(clusteredFailures)
    62  		ingestor := New(chunkStore, analysis)
    63  
    64  		opts := Options{
    65  			TaskIndex:     1,
    66  			Project:       "chromium",
    67  			PartitionTime: time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC),
    68  			Realm:         "chromium:ci",
    69  			InvocationID:  "build-123456790123456",
    70  			PresubmitRun: &PresubmitRun{
    71  				ID:     &pb.PresubmitRunId{System: "luci-cv", Id: "cq-run-123"},
    72  				Owner:  "automation",
    73  				Mode:   pb.PresubmitRunMode_FULL_RUN,
    74  				Status: pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED,
    75  			},
    76  			BuildStatus:            pb.BuildStatus_BUILD_STATUS_FAILURE,
    77  			BuildCritical:          true,
    78  			BuildGardenerRotations: []string{"gardener-rotation1", "gardener-rotation2"},
    79  		}
    80  		testIngestion := func(input []TestVerdict, expectedCFs []*bqpb.ClusteredFailureRow) {
    81  			err := ingestor.Ingest(ctx, opts, input)
    82  			So(err, ShouldBeNil)
    83  
    84  			insertions := clusteredFailures.Insertions
    85  			So(len(insertions), ShouldEqual, len(expectedCFs))
    86  
    87  			// Sort both actuals and expectations by key so that we compare corresponding rows.
    88  			sortClusteredFailures(insertions)
    89  			sortClusteredFailures(expectedCFs)
    90  			for i, exp := range expectedCFs {
    91  				actual := insertions[i]
    92  				So(actual, ShouldNotBeNil)
    93  
    94  				// Chunk ID and index is assigned by ingestion.
    95  				copyExp := proto.Clone(exp).(*bqpb.ClusteredFailureRow)
    96  				So(actual.ChunkId, ShouldNotBeEmpty)
    97  				So(actual.ChunkIndex, ShouldBeGreaterThanOrEqualTo, 1)
    98  				copyExp.ChunkId = actual.ChunkId
    99  				copyExp.ChunkIndex = actual.ChunkIndex
   100  
   101  				// LastUpdated time is assigned by Spanner.
   102  				So(actual.LastUpdated, ShouldNotBeZeroValue)
   103  				copyExp.LastUpdated = actual.LastUpdated
   104  
   105  				So(actual, ShouldResembleProto, copyExp)
   106  			}
   107  		}
   108  
   109  		// This rule should match failures used in this test.
   110  		rule := rules.NewRule(100).WithProject(opts.Project).WithRuleDefinition(`reason LIKE "Failure reason%"`).Build()
   111  		err := rules.SetForTesting(ctx, []*rules.Entry{
   112  			rule,
   113  		})
   114  		So(err, ShouldBeNil)
   115  
   116  		// Setup clustering configuration
   117  		projectCfg := &configpb.ProjectConfig{
   118  			Clustering:  algorithms.TestClusteringConfig(),
   119  			LastUpdated: timestamppb.New(time.Date(2020, time.January, 5, 0, 0, 0, 1, time.UTC)),
   120  		}
   121  		projectCfgs := map[string]*configpb.ProjectConfig{
   122  			"chromium": projectCfg,
   123  		}
   124  		So(config.SetTestProjectConfig(ctx, projectCfgs), ShouldBeNil)
   125  
   126  		cfg, err := compiledcfg.NewConfig(projectCfg)
   127  		So(err, ShouldBeNil)
   128  
   129  		Convey(`Ingest one failure`, func() {
   130  			const uniqifier = 1
   131  			const testRunCount = 1
   132  			const resultsPerTestRun = 1
   133  			tv := newTestVerdict(uniqifier, testRunCount, resultsPerTestRun, nil)
   134  			tvs := []TestVerdict{tv}
   135  
   136  			// Expect the test result to be clustered by both reason and test name.
   137  			const testRunNum = 0
   138  			const resultNum = 0
   139  			regexpCF := expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum)
   140  			setRegexpClustered(cfg, regexpCF)
   141  			testnameCF := expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum)
   142  			setTestNameClustered(cfg, testnameCF)
   143  			ruleCF := expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum)
   144  			setRuleClustered(ruleCF, rule)
   145  			expectedCFs := []*bqpb.ClusteredFailureRow{regexpCF, testnameCF, ruleCF}
   146  
   147  			Convey(`Unexpected failure`, func() {
   148  				tv.Verdict.Results[0].Result.Status = rdbpb.TestStatus_FAIL
   149  				tv.Verdict.Results[0].Result.Expected = false
   150  
   151  				testIngestion(tvs, expectedCFs)
   152  				So(len(chunkStore.Contents), ShouldEqual, 1)
   153  			})
   154  			Convey(`Expected failure`, func() {
   155  				tv.Verdict.Results[0].Result.Status = rdbpb.TestStatus_FAIL
   156  				tv.Verdict.Results[0].Result.Expected = true
   157  
   158  				// Expect no test results ingested for an expected
   159  				// failure.
   160  				expectedCFs = nil
   161  
   162  				testIngestion(tvs, expectedCFs)
   163  				So(len(chunkStore.Contents), ShouldEqual, 0)
   164  			})
   165  			Convey(`Unexpected pass`, func() {
   166  				tv.Verdict.Results[0].Result.Status = rdbpb.TestStatus_PASS
   167  				tv.Verdict.Results[0].Result.Expected = false
   168  
   169  				// Expect no test results ingested for a passed test
   170  				// (even if unexpected).
   171  				expectedCFs = nil
   172  				testIngestion(tvs, expectedCFs)
   173  				So(len(chunkStore.Contents), ShouldEqual, 0)
   174  			})
   175  			Convey(`Unexpected skip`, func() {
   176  				tv.Verdict.Results[0].Result.Status = rdbpb.TestStatus_SKIP
   177  				tv.Verdict.Results[0].Result.Expected = false
   178  
   179  				// Expect no test results ingested for a skipped test
   180  				// (even if unexpected).
   181  				expectedCFs = nil
   182  
   183  				testIngestion(tvs, expectedCFs)
   184  				So(len(chunkStore.Contents), ShouldEqual, 0)
   185  			})
   186  			Convey(`Failure with no tags`, func() {
   187  				// Tests are allowed to have no tags.
   188  				tv.Verdict.Results[0].Result.Tags = nil
   189  
   190  				for _, cf := range expectedCFs {
   191  					cf.Tags = nil
   192  					cf.BugTrackingComponent = nil
   193  				}
   194  
   195  				testIngestion(tvs, expectedCFs)
   196  				So(len(chunkStore.Contents), ShouldEqual, 1)
   197  			})
   198  			Convey(`Failure without variant`, func() {
   199  				// Tests are allowed to have no variant.
   200  				tv.Verdict.Variant = nil
   201  				tv.Verdict.Results[0].Result.Variant = nil
   202  
   203  				for _, cf := range expectedCFs {
   204  					cf.Variant = nil
   205  				}
   206  
   207  				testIngestion(tvs, expectedCFs)
   208  				So(len(chunkStore.Contents), ShouldEqual, 1)
   209  			})
   210  			Convey(`Failure without failure reason`, func() {
   211  				// Failures may not have a failure reason.
   212  				tv.Verdict.Results[0].Result.FailureReason = nil
   213  				testnameCF.FailureReason = nil
   214  
   215  				// As the test result does not match any rules, the
   216  				// test result is included in the suggested cluster
   217  				// with high priority.
   218  				testnameCF.IsIncludedWithHighPriority = true
   219  				expectedCFs = []*bqpb.ClusteredFailureRow{testnameCF}
   220  
   221  				testIngestion(tvs, expectedCFs)
   222  				So(len(chunkStore.Contents), ShouldEqual, 1)
   223  			})
   224  			Convey(`Failure without presubmit run`, func() {
   225  				opts.PresubmitRun = nil
   226  				for _, cf := range expectedCFs {
   227  					cf.PresubmitRunId = nil
   228  					cf.PresubmitRunMode = ""
   229  					cf.PresubmitRunOwner = ""
   230  					cf.PresubmitRunStatus = ""
   231  					cf.BuildCritical = false
   232  				}
   233  
   234  				testIngestion(tvs, expectedCFs)
   235  				So(len(chunkStore.Contents), ShouldEqual, 1)
   236  			})
   237  			Convey(`Failure with multiple exoneration`, func() {
   238  				tv.Verdict.Exonerations = []*rdbpb.TestExoneration{
   239  					{
   240  						Name:            fmt.Sprintf("invocations/testrun-mytestrun/tests/test-name-%v/exonerations/exon-1", uniqifier),
   241  						TestId:          tv.Verdict.TestId,
   242  						Variant:         proto.Clone(tv.Verdict.Variant).(*rdbpb.Variant),
   243  						VariantHash:     "hash",
   244  						ExonerationId:   "exon-1",
   245  						ExplanationHtml: "<p>Some description</p>",
   246  						Reason:          rdbpb.ExonerationReason_OCCURS_ON_MAINLINE,
   247  					},
   248  					{
   249  						Name:            fmt.Sprintf("invocations/testrun-mytestrun/tests/test-name-%v/exonerations/exon-1", uniqifier),
   250  						TestId:          tv.Verdict.TestId,
   251  						Variant:         proto.Clone(tv.Verdict.Variant).(*rdbpb.Variant),
   252  						VariantHash:     "hash",
   253  						ExonerationId:   "exon-1",
   254  						ExplanationHtml: "<p>Some description</p>",
   255  						Reason:          rdbpb.ExonerationReason_OCCURS_ON_OTHER_CLS,
   256  					},
   257  				}
   258  
   259  				for _, cf := range expectedCFs {
   260  					cf.Exonerations = []*bqpb.ClusteredFailureRow_TestExoneration{
   261  						{
   262  							Reason: pb.ExonerationReason_OCCURS_ON_MAINLINE,
   263  						}, {
   264  							Reason: pb.ExonerationReason_OCCURS_ON_OTHER_CLS,
   265  						},
   266  					}
   267  				}
   268  
   269  				testIngestion(tvs, expectedCFs)
   270  				So(len(chunkStore.Contents), ShouldEqual, 1)
   271  			})
   272  			Convey(`Failure with only suggested clusters`, func() {
   273  				reason := &pb.FailureReason{
   274  					PrimaryErrorMessage: "Should not match rule",
   275  				}
   276  				tv.Verdict.Results[0].Result.FailureReason = &rdbpb.FailureReason{
   277  					PrimaryErrorMessage: "Should not match rule",
   278  				}
   279  				testnameCF.FailureReason = reason
   280  				regexpCF.FailureReason = reason
   281  
   282  				// Recompute the cluster ID to reflect the different
   283  				// failure reason.
   284  				setRegexpClustered(cfg, regexpCF)
   285  
   286  				// As the test result does not match any rules, the
   287  				// test result should be included in the suggested clusters
   288  				// with high priority.
   289  				testnameCF.IsIncludedWithHighPriority = true
   290  				regexpCF.IsIncludedWithHighPriority = true
   291  				expectedCFs = []*bqpb.ClusteredFailureRow{testnameCF, regexpCF}
   292  
   293  				testIngestion(tvs, expectedCFs)
   294  				So(len(chunkStore.Contents), ShouldEqual, 1)
   295  			})
   296  
   297  			Convey(`Failure with bug component metadata`, func() {
   298  				Convey(`With monorail bug system`, func() {
   299  					tv.Verdict.TestMetadata.BugComponent = &rdbpb.BugComponent{
   300  						System: &rdbpb.BugComponent_Monorail{
   301  							Monorail: &rdbpb.MonorailComponent{
   302  								Project: "chromium",
   303  								Value:   "Blink>Component",
   304  							},
   305  						},
   306  					}
   307  					for _, cf := range expectedCFs {
   308  						cf.BugTrackingComponent.System = "monorail"
   309  						cf.BugTrackingComponent.Component = "Blink>Component"
   310  					}
   311  					testIngestion(tvs, expectedCFs)
   312  					So(len(chunkStore.Contents), ShouldEqual, 1)
   313  				})
   314  				Convey(`With Buganizer bug system`, func() {
   315  					tv.Verdict.TestMetadata.BugComponent = &rdbpb.BugComponent{
   316  						System: &rdbpb.BugComponent_IssueTracker{
   317  							IssueTracker: &rdbpb.IssueTrackerComponent{
   318  								ComponentId: 12345,
   319  							},
   320  						},
   321  					}
   322  					for _, cf := range expectedCFs {
   323  						cf.BugTrackingComponent.System = "buganizer"
   324  						cf.BugTrackingComponent.Component = "12345"
   325  					}
   326  					testIngestion(tvs, expectedCFs)
   327  					So(len(chunkStore.Contents), ShouldEqual, 1)
   328  				})
   329  				Convey(`No BugComponent metadata, but public_buganizer_component tag present`, func() {
   330  					tv.Verdict.TestMetadata.BugComponent = nil
   331  
   332  					for _, result := range tv.Verdict.Results {
   333  						result.Result.Tags = []*rdbpb.StringPair{
   334  							{
   335  								Key:   "public_buganizer_component",
   336  								Value: "654321",
   337  							},
   338  						}
   339  					}
   340  					for _, cf := range expectedCFs {
   341  						cf.BugTrackingComponent.System = "buganizer"
   342  						cf.BugTrackingComponent.Component = "654321"
   343  						cf.Tags = []*pb.StringPair{
   344  							{
   345  								Key:   "public_buganizer_component",
   346  								Value: "654321",
   347  							},
   348  						}
   349  					}
   350  					testIngestion(tvs, expectedCFs)
   351  					So(len(chunkStore.Contents), ShouldEqual, 1)
   352  				})
   353  				Convey(`No BugComponent metadata, both public_buganizer_component and monorail_component present`, func() {
   354  					tv.Verdict.TestMetadata.BugComponent = nil
   355  
   356  					for _, result := range tv.Verdict.Results {
   357  						result.Result.Tags = []*rdbpb.StringPair{
   358  							{
   359  								Key:   "monorail_component",
   360  								Value: "Component>MyComponent",
   361  							},
   362  							{
   363  								Key:   "public_buganizer_component",
   364  								Value: "654321",
   365  							},
   366  						}
   367  					}
   368  					for _, cf := range expectedCFs {
   369  						cf.Tags = []*pb.StringPair{
   370  							{
   371  								Key:   "monorail_component",
   372  								Value: "Component>MyComponent",
   373  							},
   374  							{
   375  								Key:   "public_buganizer_component",
   376  								Value: "654321",
   377  							},
   378  						}
   379  					}
   380  
   381  					Convey("With monorail as preferred system", func() {
   382  						opts.PreferBuganizerComponents = false
   383  
   384  						for _, cf := range expectedCFs {
   385  							cf.BugTrackingComponent.System = "monorail"
   386  							cf.BugTrackingComponent.Component = "Component>MyComponent"
   387  						}
   388  						testIngestion(tvs, expectedCFs)
   389  						So(len(chunkStore.Contents), ShouldEqual, 1)
   390  					})
   391  					Convey("With buganizer as preferred system", func() {
   392  						opts.PreferBuganizerComponents = true
   393  
   394  						for _, cf := range expectedCFs {
   395  							cf.BugTrackingComponent.System = "buganizer"
   396  							cf.BugTrackingComponent.Component = "654321"
   397  						}
   398  						testIngestion(tvs, expectedCFs)
   399  						So(len(chunkStore.Contents), ShouldEqual, 1)
   400  					})
   401  				})
   402  			})
   403  			Convey(`Failure with no sources`, func() {
   404  				tv.Sources = nil
   405  				for _, cf := range expectedCFs {
   406  					cf.Sources = nil
   407  					cf.SourceRef = nil
   408  					cf.SourceRefHash = "<fix me>"
   409  				}
   410  				// No sources also means no test variant branch analysis.
   411  				tv.TestVariantBranch = nil
   412  			})
   413  		})
   414  		Convey(`Ingest multiple failures`, func() {
   415  			const uniqifier = 1
   416  			const testRunsPerVariant = 2
   417  			const resultsPerTestRun = 2
   418  			tv := newTestVerdict(uniqifier, testRunsPerVariant, resultsPerTestRun, nil)
   419  			tvs := []TestVerdict{tv}
   420  
   421  			// Setup a scenario as follows:
   422  			// - A test was run four times in total, consisting of two test
   423  			//   runs with two tries each.
   424  			// - The test failed on all tries.
   425  			var expectedCFs []*bqpb.ClusteredFailureRow
   426  			var expectedCFsByTestRun [][]*bqpb.ClusteredFailureRow
   427  			for t := 0; t < testRunsPerVariant; t++ {
   428  				var testRunExp []*bqpb.ClusteredFailureRow
   429  				for j := 0; j < resultsPerTestRun; j++ {
   430  					regexpCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
   431  					setRegexpClustered(cfg, regexpCF)
   432  					testnameCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
   433  					setTestNameClustered(cfg, testnameCF)
   434  					ruleCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
   435  					setRuleClustered(ruleCF, rule)
   436  					testRunExp = append(testRunExp, regexpCF, testnameCF, ruleCF)
   437  				}
   438  				expectedCFsByTestRun = append(expectedCFsByTestRun, testRunExp)
   439  				expectedCFs = append(expectedCFs, testRunExp...)
   440  			}
   441  
   442  			// Expectation: all test results show both the test run and
   443  			// invocation blocked by failures.
   444  			for _, exp := range expectedCFs {
   445  				exp.IsIngestedInvocationBlocked = true
   446  				exp.IsTestRunBlocked = true
   447  			}
   448  
   449  			Convey(`Some test runs blocked and presubmit run not blocked`, func() {
   450  				// Let the last retry of the last test run pass.
   451  				tv.Verdict.Results[testRunsPerVariant*resultsPerTestRun-1].Result.Status = rdbpb.TestStatus_PASS
   452  				// Drop the expected clustered failures for the last test result.
   453  				expectedCFs = expectedCFs[0 : (testRunsPerVariant*resultsPerTestRun-1)*3]
   454  
   455  				// First test run should be blocked.
   456  				for _, exp := range expectedCFsByTestRun[0] {
   457  					exp.IsIngestedInvocationBlocked = false
   458  					exp.IsTestRunBlocked = true
   459  				}
   460  				// Last test run should not be blocked.
   461  				for _, exp := range expectedCFsByTestRun[testRunsPerVariant-1] {
   462  					exp.IsIngestedInvocationBlocked = false
   463  					exp.IsTestRunBlocked = false
   464  				}
   465  				testIngestion(tvs, expectedCFs)
   466  				So(len(chunkStore.Contents), ShouldEqual, 1)
   467  			})
   468  		})
   469  		Convey(`Ingest many failures`, func() {
   470  			var tvs []TestVerdict
   471  			var expectedCFs []*bqpb.ClusteredFailureRow
   472  
   473  			const variantCount = 20
   474  			const testRunsPerVariant = 10
   475  			const resultsPerTestRun = 10
   476  			for uniqifier := 0; uniqifier < variantCount; uniqifier++ {
   477  				tv := newTestVerdict(uniqifier, testRunsPerVariant, resultsPerTestRun, nil)
   478  				tvs = append(tvs, tv)
   479  				for t := 0; t < testRunsPerVariant; t++ {
   480  					for j := 0; j < resultsPerTestRun; j++ {
   481  						regexpCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
   482  						setRegexpClustered(cfg, regexpCF)
   483  						testnameCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
   484  						setTestNameClustered(cfg, testnameCF)
   485  						ruleCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
   486  						setRuleClustered(ruleCF, rule)
   487  						expectedCFs = append(expectedCFs, regexpCF, testnameCF, ruleCF)
   488  					}
   489  				}
   490  			}
   491  			// Verify more than one chunk is ingested.
   492  			testIngestion(tvs, expectedCFs)
   493  			So(len(chunkStore.Contents), ShouldBeGreaterThan, 1)
   494  		})
   495  	})
   496  }
   497  
   498  func setTestNameClustered(cfg *compiledcfg.ProjectConfig, e *bqpb.ClusteredFailureRow) {
   499  	e.ClusterAlgorithm = testname.AlgorithmName
   500  	e.ClusterId = hex.EncodeToString((&testname.Algorithm{}).Cluster(cfg, &clustering.Failure{
   501  		TestID: e.TestId,
   502  	}))
   503  }
   504  
   505  func setRegexpClustered(cfg *compiledcfg.ProjectConfig, e *bqpb.ClusteredFailureRow) {
   506  	e.ClusterAlgorithm = failurereason.AlgorithmName
   507  	e.ClusterId = hex.EncodeToString((&failurereason.Algorithm{}).Cluster(cfg, &clustering.Failure{
   508  		Reason: &pb.FailureReason{PrimaryErrorMessage: e.FailureReason.PrimaryErrorMessage},
   509  	}))
   510  }
   511  
   512  func setRuleClustered(e *bqpb.ClusteredFailureRow, rule *rules.Entry) {
   513  	e.ClusterAlgorithm = rulesalgorithm.AlgorithmName
   514  	e.ClusterId = rule.RuleID
   515  	e.IsIncludedWithHighPriority = true
   516  }
   517  
   518  func sortClusteredFailures(cfs []*bqpb.ClusteredFailureRow) {
   519  	sort.Slice(cfs, func(i, j int) bool {
   520  		return clusteredFailureKey(cfs[i]) < clusteredFailureKey(cfs[j])
   521  	})
   522  }
   523  
   524  func clusteredFailureKey(cf *bqpb.ClusteredFailureRow) string {
   525  	return fmt.Sprintf("%q/%q/%q/%q", cf.ClusterAlgorithm, cf.ClusterId, cf.TestResultSystem, cf.TestResultId)
   526  }
   527  
   528  func newTestVerdict(uniqifier, testRunCount, resultsPerTestRun int, bugComponent *rdbpb.BugComponent) TestVerdict {
   529  	testID := fmt.Sprintf("ninja://test_name/%v", uniqifier)
   530  	variant := &rdbpb.Variant{
   531  		Def: map[string]string{
   532  			"k1": "v1",
   533  		},
   534  	}
   535  	rdbVerdict := &rdbpb.TestVariant{
   536  		TestId:       testID,
   537  		Variant:      variant,
   538  		VariantHash:  "hash",
   539  		Status:       rdbpb.TestVariantStatus_UNEXPECTED,
   540  		Exonerations: nil,
   541  		TestMetadata: &rdbpb.TestMetadata{
   542  			BugComponent: bugComponent,
   543  		},
   544  	}
   545  	for i := 0; i < testRunCount; i++ {
   546  		for j := 0; j < resultsPerTestRun; j++ {
   547  			tr := newTestResult(uniqifier, i, j)
   548  			// Test ID, Variant, VariantHash are not populated on the test
   549  			// results of a Test Variant as it is present on the parent record.
   550  			tr.TestId = ""
   551  			tr.Variant = nil
   552  			tr.VariantHash = ""
   553  			rdbVerdict.Results = append(rdbVerdict.Results, &rdbpb.TestResultBundle{Result: tr})
   554  		}
   555  	}
   556  	return TestVerdict{
   557  		Verdict: rdbVerdict,
   558  		Sources: testSources(),
   559  		TestVariantBranch: &clusteringpb.TestVariantBranch{
   560  			FlakyVerdicts_24H:      111,
   561  			UnexpectedVerdicts_24H: 222,
   562  			TotalVerdicts_24H:      555,
   563  		},
   564  	}
   565  }
   566  
   567  func newTestResult(uniqifier, testRunNum, resultNum int) *rdbpb.TestResult {
   568  	return newFakeTestResult(uniqifier, testRunNum, resultNum)
   569  }
   570  
   571  func newFakeTestResult(uniqifier, testRunNum, resultNum int) *rdbpb.TestResult {
   572  	resultID := fmt.Sprintf("result-%v-%v", testRunNum, resultNum)
   573  	return &rdbpb.TestResult{
   574  		Name:        fmt.Sprintf("invocations/testrun-%v/tests/test-name-%v/results/%s", testRunNum, uniqifier, resultID),
   575  		ResultId:    resultID,
   576  		Expected:    false,
   577  		Status:      rdbpb.TestStatus_CRASH,
   578  		SummaryHtml: "<p>Some SummaryHTML</p>",
   579  		StartTime:   timestamppb.New(time.Date(2022, time.February, 12, 0, 0, 0, 0, time.UTC)),
   580  		Duration:    durationpb.New(time.Second * 10),
   581  		Tags: []*rdbpb.StringPair{
   582  			{
   583  				Key:   "monorail_component",
   584  				Value: "Component>MyComponent",
   585  			},
   586  		},
   587  		FailureReason: &rdbpb.FailureReason{
   588  			PrimaryErrorMessage: "Failure reason.",
   589  		},
   590  	}
   591  }
   592  
   593  func testSources() *pb.Sources {
   594  	result := &pb.Sources{
   595  		GitilesCommit: &pb.GitilesCommit{
   596  			Host:       "chromium.googlesource.com",
   597  			Project:    "infra/infra",
   598  			Ref:        "refs/heads/main",
   599  			CommitHash: "1234567890abcdefabcd1234567890abcdefabcd",
   600  			Position:   12345,
   601  		},
   602  		IsDirty: true,
   603  		Changelists: []*pb.GerritChange{
   604  			{
   605  				Host:     "chromium-review.googlesource.com",
   606  				Project:  "myproject",
   607  				Change:   87654,
   608  				Patchset: 321,
   609  			},
   610  		},
   611  	}
   612  	return result
   613  }
   614  
   615  func expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum int) *bqpb.ClusteredFailureRow {
   616  	resultID := fmt.Sprintf("result-%v-%v", testRunNum, resultNum)
   617  	return &bqpb.ClusteredFailureRow{
   618  		ClusterAlgorithm:           "", // Determined by clustering algorithm.
   619  		ClusterId:                  "", // Determined by clustering algorithm.
   620  		TestResultSystem:           "resultdb",
   621  		TestResultId:               fmt.Sprintf("invocations/testrun-%v/tests/test-name-%v/results/%s", testRunNum, uniqifier, resultID),
   622  		LastUpdated:                nil, // Only known at runtime, Spanner commit timestamp.
   623  		Project:                    "chromium",
   624  		PartitionTime:              timestamppb.New(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)),
   625  		IsIncluded:                 true,
   626  		IsIncludedWithHighPriority: false,
   627  
   628  		ChunkId:    "",
   629  		ChunkIndex: 0, // To be set by caller as needed.
   630  
   631  		Realm:  "chromium:ci",
   632  		TestId: fmt.Sprintf("ninja://test_name/%v", uniqifier),
   633  		Tags: []*pb.StringPair{
   634  			{
   635  				Key:   "monorail_component",
   636  				Value: "Component>MyComponent",
   637  			},
   638  		},
   639  		Variant: []*pb.StringPair{
   640  			{
   641  				Key:   "k1",
   642  				Value: "v1",
   643  			},
   644  		},
   645  		VariantHash:   "hash",
   646  		FailureReason: &pb.FailureReason{PrimaryErrorMessage: "Failure reason."},
   647  		BugTrackingComponent: &pb.BugTrackingComponent{
   648  			System:    "monorail",
   649  			Component: "Component>MyComponent",
   650  		},
   651  		StartTime:    timestamppb.New(time.Date(2022, time.February, 12, 0, 0, 0, 0, time.UTC)),
   652  		Duration:     10.0,
   653  		Exonerations: nil,
   654  
   655  		PresubmitRunId:                &pb.PresubmitRunId{System: "luci-cv", Id: "cq-run-123"},
   656  		PresubmitRunOwner:             "automation",
   657  		PresubmitRunMode:              "FULL_RUN", // pb.PresubmitRunMode_FULL_RUN
   658  		PresubmitRunStatus:            "FAILED",   // pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED,
   659  		BuildStatus:                   "FAILURE",  // pb.BuildStatus_BUILD_STATUS_FAILURE
   660  		BuildCritical:                 true,
   661  		IngestedInvocationId:          "build-123456790123456",
   662  		IngestedInvocationResultIndex: int64(testRunNum*resultsPerTestRun + resultNum),
   663  		IngestedInvocationResultCount: int64(testRunCount * resultsPerTestRun),
   664  		IsIngestedInvocationBlocked:   true,
   665  		TestRunId:                     fmt.Sprintf("testrun-%v", testRunNum),
   666  		TestRunResultIndex:            int64(resultNum),
   667  		TestRunResultCount:            int64(resultsPerTestRun),
   668  		IsTestRunBlocked:              true,
   669  
   670  		SourceRefHash: `923c0d1af67f8ef8`,
   671  		SourceRef: &pb.SourceRef{
   672  			System: &pb.SourceRef_Gitiles{
   673  				Gitiles: &pb.GitilesRef{
   674  					Host:    "chromium.googlesource.com",
   675  					Project: "infra/infra",
   676  					Ref:     "refs/heads/main",
   677  				},
   678  			},
   679  		},
   680  		Sources:                testSources(),
   681  		BuildGardenerRotations: []string{"gardener-rotation1", "gardener-rotation2"},
   682  		TestVariantBranch: &bqpb.ClusteredFailureRow_TestVariantBranch{
   683  			FlakyVerdicts_24H:      111,
   684  			UnexpectedVerdicts_24H: 222,
   685  			TotalVerdicts_24H:      555,
   686  		},
   687  	}
   688  }