go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/testverdicts/export.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 testverdicts handles read and write test verdicts to BigQuery.
    16  package testverdicts
    17  
    18  import (
    19  	"context"
    20  	"encoding/hex"
    21  
    22  	"google.golang.org/protobuf/encoding/protojson"
    23  	"google.golang.org/protobuf/types/known/structpb"
    24  
    25  	"go.chromium.org/luci/common/errors"
    26  	rdbpbutil "go.chromium.org/luci/resultdb/pbutil"
    27  	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
    28  
    29  	"go.chromium.org/luci/analysis/internal/analysis"
    30  	controlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
    31  	"go.chromium.org/luci/analysis/internal/ingestion/resultdb"
    32  	"go.chromium.org/luci/analysis/internal/perms"
    33  	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
    34  	"go.chromium.org/luci/analysis/pbutil"
    35  	bqpb "go.chromium.org/luci/analysis/proto/bq"
    36  	pb "go.chromium.org/luci/analysis/proto/v1"
    37  )
    38  
    39  // InsertClient defines an interface for inserting rows into BigQuery.
    40  type InsertClient interface {
    41  	// Insert inserts the given rows into BigQuery.
    42  	Insert(ctx context.Context, rows []*bqpb.TestVerdictRow) error
    43  }
    44  
    45  // Exporter provides methods to stream test verdicts into BigQuery.
    46  type Exporter struct {
    47  	client InsertClient
    48  }
    49  
    50  // NewExporter instantiates a new Exporter. The given client is used
    51  // to insert rows into BigQuery.
    52  func NewExporter(client InsertClient) *Exporter {
    53  	return &Exporter{client: client}
    54  }
    55  
    56  // ExportOptions captures context which will be exported
    57  // alongside the test verdicts.
    58  type ExportOptions struct {
    59  	Payload     *taskspb.IngestTestResults
    60  	Invocation  *rdbpb.Invocation
    61  	SourcesByID map[string]*pb.Sources
    62  }
    63  
    64  // Export exports the given test verdicts to BigQuery.
    65  func (e *Exporter) Export(ctx context.Context, testVariants []*rdbpb.TestVariant, opts ExportOptions) error {
    66  	rows := make([]*bqpb.TestVerdictRow, 0, len(testVariants))
    67  	for _, tv := range testVariants {
    68  		exportRow, err := prepareExportRow(tv, opts)
    69  		if err != nil {
    70  			return errors.Annotate(err, "prepare row").Err()
    71  		}
    72  		rows = append(rows, exportRow)
    73  	}
    74  	err := e.client.Insert(ctx, rows)
    75  	if err != nil {
    76  		return errors.Annotate(err, "insert rows").Err()
    77  	}
    78  	return nil
    79  }
    80  
    81  // prepareExportRow prepares a BigQuery export row for a
    82  // ResultDB test verdict.
    83  func prepareExportRow(tv *rdbpb.TestVariant, opts ExportOptions) (*bqpb.TestVerdictRow, error) {
    84  	project, _, err := perms.SplitRealm(opts.Invocation.Realm)
    85  	if err != nil {
    86  		return nil, errors.Annotate(err, "invalid realm").Err()
    87  	}
    88  
    89  	results := make([]*bqpb.TestVerdictRow_TestResult, 0, len(tv.Results))
    90  	for _, r := range tv.Results {
    91  		resultEntry, err := result(r.Result)
    92  		if err != nil {
    93  			return nil, errors.Annotate(err, "result entry").Err()
    94  		}
    95  		results = append(results, resultEntry)
    96  	}
    97  
    98  	exonerations := make([]*bqpb.TestVerdictRow_Exoneration, 0, len(tv.Exonerations))
    99  	for _, e := range tv.Exonerations {
   100  		exonerations = append(exonerations, exoneration(e))
   101  	}
   102  
   103  	var sources *pb.Sources
   104  	var sourceRef *pb.SourceRef
   105  	var sourceRefHash string
   106  	if tv.SourcesId != "" {
   107  		sources = opts.SourcesByID[tv.SourcesId]
   108  		sourceRef = pbutil.SourceRefFromSources(sources)
   109  		sourceRefHash = hex.EncodeToString(pbutil.SourceRefHash(sourceRef))
   110  	}
   111  
   112  	var metadata *pb.TestMetadata
   113  	if tv.TestMetadata != nil {
   114  		metadata = pbutil.TestMetadataFromResultDB(tv.TestMetadata)
   115  	}
   116  
   117  	var cvRun *bqpb.TestVerdictRow_ChangeVerifierRun
   118  	if opts.Payload.PresubmitRun != nil && opts.Payload.PresubmitRun.PresubmitRunId.System == "luci-cv" {
   119  		cvRun = changeVerifierRun(opts.Payload.PresubmitRun)
   120  	}
   121  
   122  	var build *bqpb.TestVerdictRow_BuildbucketBuild
   123  	if opts.Payload.Build != nil {
   124  		build = buildbucketBuild(opts.Payload.Build)
   125  	}
   126  
   127  	inv, err := invocation(opts.Invocation)
   128  	if err != nil {
   129  		return nil, errors.Annotate(err, "invocation").Err()
   130  	}
   131  
   132  	variant, err := variantJSON(tv.Variant)
   133  	if err != nil {
   134  		return nil, errors.Annotate(err, "variant").Err()
   135  	}
   136  
   137  	return &bqpb.TestVerdictRow{
   138  		Project:           project,
   139  		TestId:            tv.TestId,
   140  		Variant:           variant,
   141  		VariantHash:       tv.VariantHash,
   142  		Invocation:        inv,
   143  		PartitionTime:     opts.Payload.PartitionTime,
   144  		Status:            pbutil.TestVerdictStatusFromResultDB(tv.Status),
   145  		Results:           results,
   146  		Exonerations:      exonerations,
   147  		Counts:            counts(results),
   148  		BuildbucketBuild:  build,
   149  		ChangeVerifierRun: cvRun,
   150  		Sources:           sources,
   151  		SourceRef:         sourceRef,
   152  		SourceRefHash:     sourceRefHash,
   153  		TestMetadata:      metadata,
   154  	}, nil
   155  }
   156  
   157  func invocation(invocation *rdbpb.Invocation) (*bqpb.TestVerdictRow_InvocationRecord, error) {
   158  	invocationID, err := rdbpbutil.ParseInvocationName(invocation.Name)
   159  	if err != nil {
   160  		return nil, errors.Annotate(err, "invalid invocation name %q", invocationID).Err()
   161  	}
   162  	propertiesJSON, err := MarshalStructPB(invocation.Properties)
   163  	if err != nil {
   164  		return nil, errors.Annotate(err, "marshal properties").Err()
   165  	}
   166  
   167  	return &bqpb.TestVerdictRow_InvocationRecord{
   168  		Id:         invocationID,
   169  		Tags:       pbutil.StringPairFromResultDB(invocation.Tags),
   170  		Realm:      invocation.Realm,
   171  		Properties: propertiesJSON,
   172  	}, nil
   173  }
   174  
   175  func exoneration(exoneration *rdbpb.TestExoneration) *bqpb.TestVerdictRow_Exoneration {
   176  	return &bqpb.TestVerdictRow_Exoneration{
   177  		ExplanationHtml: exoneration.ExplanationHtml,
   178  		Reason:          pbutil.ExonerationReasonFromResultDB(exoneration.Reason),
   179  	}
   180  }
   181  
   182  func counts(results []*bqpb.TestVerdictRow_TestResult) *bqpb.TestVerdictRow_Counts {
   183  	counts := &bqpb.TestVerdictRow_Counts{}
   184  	for _, result := range results {
   185  		counts.Total += 1
   186  		if result.Status != pb.TestResultStatus_SKIP {
   187  			counts.TotalNonSkipped += 1
   188  		}
   189  		if !result.Expected {
   190  			counts.Unexpected += 1
   191  			if result.Status != pb.TestResultStatus_SKIP {
   192  				counts.UnexpectedNonSkipped += 1
   193  				if result.Status != pb.TestResultStatus_PASS {
   194  					counts.UnexpectedNonSkippedNonPassed += 1
   195  				}
   196  			}
   197  		}
   198  	}
   199  	return counts
   200  }
   201  
   202  func changeVerifierRun(cv *controlpb.PresubmitResult) *bqpb.TestVerdictRow_ChangeVerifierRun {
   203  	return &bqpb.TestVerdictRow_ChangeVerifierRun{
   204  		Id:              cv.PresubmitRunId.Id,
   205  		Mode:            cv.Mode,
   206  		Status:          analysis.ToBQPresubmitRunStatus(cv.Status),
   207  		IsBuildCritical: cv.Critical,
   208  	}
   209  }
   210  
   211  func buildbucketBuild(build *controlpb.BuildResult) *bqpb.TestVerdictRow_BuildbucketBuild {
   212  	return &bqpb.TestVerdictRow_BuildbucketBuild{
   213  		Id: build.Id,
   214  		Builder: &bqpb.TestVerdictRow_BuildbucketBuild_Builder{
   215  			Project: build.Project,
   216  			Bucket:  build.Bucket,
   217  			Builder: build.Builder,
   218  		},
   219  		Status:            analysis.ToBQBuildStatus(build.Status),
   220  		GardenerRotations: build.GardenerRotations,
   221  	}
   222  }
   223  
   224  func result(result *rdbpb.TestResult) (*bqpb.TestVerdictRow_TestResult, error) {
   225  	propertiesJSON, err := MarshalStructPB(result.Properties)
   226  	if err != nil {
   227  		return nil, errors.Annotate(err, "marshal properties").Err()
   228  	}
   229  	invID, err := resultdb.InvocationFromTestResultName(result.Name)
   230  	if err != nil {
   231  		return nil, errors.Annotate(err, "invocation from test result name").Err()
   232  	}
   233  	tr := &bqpb.TestVerdictRow_TestResult{
   234  		Parent: &bqpb.TestVerdictRow_ParentInvocationRecord{
   235  			Id: invID,
   236  		},
   237  		Name:        result.Name,
   238  		ResultId:    result.ResultId,
   239  		Expected:    result.Expected,
   240  		Status:      pbutil.TestResultStatusFromResultDB(result.Status),
   241  		SummaryHtml: result.SummaryHtml,
   242  		StartTime:   result.StartTime,
   243  		// Null durations are represented as zeroes in the export.
   244  		// Unfortunately, BigQuery Write API does not offer a way for us
   245  		// to write NULL to a NULLABLE FLOAT column.
   246  		Duration:      result.Duration.AsDuration().Seconds(),
   247  		Tags:          pbutil.StringPairFromResultDB(result.Tags),
   248  		FailureReason: pbutil.FailureReasonFromResultDB(result.FailureReason),
   249  		Properties:    propertiesJSON,
   250  	}
   251  
   252  	skipReason := pbutil.SkipReasonFromResultDB(result.SkipReason)
   253  	if skipReason != pb.SkipReason_SKIP_REASON_UNSPECIFIED {
   254  		tr.SkipReason = skipReason.String()
   255  	}
   256  
   257  	return tr, nil
   258  }
   259  
   260  // variantJSON returns the JSON equivalent for a variant.
   261  // Each key in the variant is mapped to a top-level key in the
   262  // JSON object.
   263  // e.g. `{"builder":"linux-rel","os":"Ubuntu-18.04"}`
   264  func variantJSON(variant *rdbpb.Variant) (string, error) {
   265  	return pbutil.VariantToJSON(pbutil.VariantFromResultDB(variant))
   266  }
   267  
   268  // MarshalStructPB serialises a structpb.Struct as a JSONPB.
   269  func MarshalStructPB(s *structpb.Struct) (string, error) {
   270  	if s == nil {
   271  		// There is no string value we can send to BigQuery that will
   272  		// interpret as a NULL value for a JSON column:
   273  		// - "" (empty string) is rejected as invalid JSON.
   274  		// - "null" is interpreted as the JSON value null, not the
   275  		//   absence of a value.
   276  		// Consequently, the next best thing is to return an empty
   277  		// JSON object.
   278  		return pbutil.EmptyJSON, nil
   279  	}
   280  	// Structs are persisted as JSONPB strings.
   281  	// See also https://bit.ly/chromium-bq-struct
   282  	b, err := (&protojson.MarshalOptions{}).Marshal(s)
   283  	if err != nil {
   284  		return "", err
   285  	}
   286  	return string(b), nil
   287  }