go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/bisection/bqexporter/bqexporter.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 bqexporter handles export to BigQuery.
    16  package bqexporter
    17  
    18  import (
    19  	"context"
    20  	"time"
    21  
    22  	"go.chromium.org/luci/bisection/model"
    23  	bqpb "go.chromium.org/luci/bisection/proto/bq"
    24  	pb "go.chromium.org/luci/bisection/proto/v1"
    25  	"go.chromium.org/luci/bisection/util/bqutil"
    26  	"go.chromium.org/luci/bisection/util/datastoreutil"
    27  	"go.chromium.org/luci/common/clock"
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/common/logging"
    30  	"go.chromium.org/luci/gae/service/datastore"
    31  	"go.chromium.org/luci/gae/service/info"
    32  )
    33  
    34  // The number of days to look back for past analyses.
    35  // We only look back and export analyses within the past 14 days.
    36  const daysToLookBack = 14
    37  
    38  // ExportTestAnalyses exports test failure analyses to BigQuery.
    39  // A test failure analysis will be exported if it satisfies the following conditions:
    40  //  1. It has not been exported yet.
    41  //  2. It was created within the past 14 days.
    42  //  3. Has ended.
    43  //  4. If it found culprit, then actions must have been taken.
    44  //
    45  // The limit of 14 days is chosen to save the query time. It is also because if the exporter
    46  // is broken for some reasons, we will have 14 days to fix it.
    47  func ExportTestAnalyses(ctx context.Context) error {
    48  	// TODO (nqmtuan): We should read it from config.
    49  	// But currently we only have per-project config, not service config.
    50  	// So for now we are hard-coding it.
    51  	if !isEnabled(ctx) {
    52  		logging.Warningf(ctx, "export test analyses is not enabled")
    53  	}
    54  
    55  	client, err := NewClient(ctx, info.AppID(ctx))
    56  	if err != nil {
    57  		return errors.Annotate(err, "new client").Err()
    58  	}
    59  	defer client.Close()
    60  	err = export(ctx, client)
    61  	if err != nil {
    62  		return errors.Annotate(err, "export").Err()
    63  	}
    64  	return nil
    65  }
    66  
    67  type ExportClient interface {
    68  	EnsureSchema(ctx context.Context) error
    69  	Insert(ctx context.Context, rows []*bqpb.TestAnalysisRow) error
    70  	ReadTestFailureAnalysisRows(ctx context.Context) ([]*TestFailureAnalysisRow, error)
    71  }
    72  
    73  func export(ctx context.Context, client ExportClient) error {
    74  	err := client.EnsureSchema(ctx)
    75  	if err != nil {
    76  		return errors.Annotate(err, "ensure schema").Err()
    77  	}
    78  
    79  	analyses, err := fetchTestAnalyses(ctx)
    80  	if err != nil {
    81  		return errors.Annotate(err, "fetch test analyses").Err()
    82  	}
    83  	logging.Infof(ctx, "There are %d test analyses fetched from datastore", len(analyses))
    84  
    85  	// Read existing rows from bigquery.
    86  	bqrows, err := client.ReadTestFailureAnalysisRows(ctx)
    87  	if err != nil {
    88  		return errors.Annotate(err, "read test failure analysis rows").Err()
    89  	}
    90  	logging.Infof(ctx, "There are %d existing rows in BigQuery", len(bqrows))
    91  
    92  	// Filter out existing rows.
    93  	// Construct a map for fast filtering.
    94  	existingIDs := map[int64]bool{}
    95  	for _, r := range bqrows {
    96  		existingIDs[r.AnalysisID] = true
    97  	}
    98  
    99  	// Construct BQ rows.
   100  	rowsToInsert := []*bqpb.TestAnalysisRow{}
   101  	for _, tfa := range analyses {
   102  		if _, ok := existingIDs[tfa.ID]; !ok {
   103  			row, err := bqutil.TestFailureAnalysisToBqRow(ctx, tfa)
   104  			if err != nil {
   105  				return errors.Annotate(err, "test failure analysis to bq row for analysis ID: %d", tfa.ID).Err()
   106  			}
   107  			rowsToInsert = append(rowsToInsert, row)
   108  		}
   109  	}
   110  	logging.Infof(ctx, "After filtering, there are %d rows to insert to BigQuery.", len(rowsToInsert))
   111  
   112  	// Insert into BQ.
   113  	err = client.Insert(ctx, rowsToInsert)
   114  	if err != nil {
   115  		return errors.Annotate(err, "insert").Err()
   116  	}
   117  	return nil
   118  }
   119  
   120  // fetchTestAnalyses returns the test analyses that:
   121  // - Created within 14 days
   122  // - Has ended
   123  // - If it found a culprit, then either the actions have been taken,
   124  // or the it has ended more than 1 day ago.
   125  func fetchTestAnalyses(ctx context.Context) ([]*model.TestFailureAnalysis, error) {
   126  	// Query all analyses within 14 days.
   127  	cutoffTime := clock.Now(ctx).Add(-time.Hour * 24 * daysToLookBack)
   128  	q := datastore.NewQuery("TestFailureAnalysis").Gt("create_time", cutoffTime).Order("-create_time")
   129  	analyses := []*model.TestFailureAnalysis{}
   130  	err := datastore.GetAll(ctx, q, &analyses)
   131  	if err != nil {
   132  		return nil, errors.Annotate(err, "get test analyses").Err()
   133  	}
   134  
   135  	// Check that the analyses ended and actions were taken.
   136  	results := []*model.TestFailureAnalysis{}
   137  	for _, tfa := range analyses {
   138  		// Ignore all analyses that have not ended.
   139  		if !tfa.HasEnded() {
   140  			continue
   141  		}
   142  		// If the analyses did not find any culprit, then we don't
   143  		// need to check for culprit actions.
   144  		if tfa.Status != pb.AnalysisStatus_FOUND {
   145  			results = append(results, tfa)
   146  			continue
   147  		}
   148  
   149  		//Get culprit.
   150  		culprit, err := datastoreutil.GetVerifiedCulpritForTestAnalysis(ctx, tfa)
   151  		if err != nil {
   152  			return nil, errors.Annotate(err, "get verified culprit").Err()
   153  		}
   154  		if culprit == nil {
   155  			return nil, errors.Reason("no culprit found for analysis %d", tfa.ID).Err()
   156  		}
   157  
   158  		// Make an exception: If an analysis ended more than 1 day ago, and
   159  		// HasTakenActions is still set to false, most likely something was stuck
   160  		// that prevent the filed from being set. In this case, we want to
   161  		// export the analysis anyway, since there will be no changes to it.
   162  		// It also let us export the analyses without suspect's HasTakenActions field set.
   163  		oneDayAgo := clock.Now(ctx).Add(-time.Hour * 24)
   164  		if !culprit.HasTakenActions && tfa.EndTime.Before(oneDayAgo) {
   165  			// Logging for visibility.
   166  			logging.Warningf(ctx, "Analysis %d has ended more than a day ago, but actions are not taken", tfa.ID)
   167  		}
   168  
   169  		if culprit.HasTakenActions || tfa.EndTime.Before(oneDayAgo) {
   170  			results = append(results, tfa)
   171  		}
   172  	}
   173  	return results, nil
   174  }
   175  
   176  func isEnabled(ctx context.Context) bool {
   177  	// return info.AppID(ctx) == "luci-bisection-dev"
   178  	return true
   179  }