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 }