go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/clustering/rules/exporter/client.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 exporter provides methods to interact with the failure_assocation_rules BigQuery table.
    16  package exporter
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  
    22  	"cloud.google.com/go/bigquery"
    23  	"cloud.google.com/go/bigquery/storage/managedwriter"
    24  	"google.golang.org/protobuf/proto"
    25  
    26  	"go.chromium.org/luci/common/bq"
    27  	"go.chromium.org/luci/common/errors"
    28  
    29  	"go.chromium.org/luci/analysis/internal/bqutil"
    30  	bqpb "go.chromium.org/luci/analysis/proto/bq"
    31  )
    32  
    33  // Client provides methods to export and read failure_association_rules_history BigQuery tale
    34  type Client struct {
    35  	// projectID is the name of the GCP project that contains LUCI Analysis datasets.
    36  	projectID string
    37  	// bqClient is the BigQuery client.
    38  	bqClient *bigquery.Client
    39  	// mwClient is the BigQuery managed writer.
    40  	mwClient *managedwriter.Client
    41  }
    42  
    43  // NewClient creates a new client for exporting failure association rules
    44  // via the BigQuery Write API.
    45  func NewClient(ctx context.Context, projectID string) (s *Client, reterr error) {
    46  	if projectID == "" {
    47  		return nil, errors.New("GCP Project must be specified")
    48  	}
    49  	bqClient, err := bqutil.Client(ctx, projectID)
    50  	if err != nil {
    51  		return nil, errors.Annotate(err, "creating BQ client").Err()
    52  	}
    53  	defer func() {
    54  		if reterr != nil {
    55  			// This method failed for some reason, clean up the
    56  			// BigQuery client. Swallow any error returned by the Close()
    57  			// call.
    58  			bqClient.Close()
    59  		}
    60  	}()
    61  
    62  	mwClient, err := bqutil.NewWriterClient(ctx, projectID)
    63  	if err != nil {
    64  		return nil, errors.Annotate(err, "creating managed writer client").Err()
    65  	}
    66  	return &Client{
    67  		projectID: projectID,
    68  		bqClient:  bqClient,
    69  		mwClient:  mwClient,
    70  	}, nil
    71  }
    72  
    73  // Close releases resources held by the client.
    74  func (c *Client) Close() (reterr error) {
    75  	// Ensure both bqClient and mwClient Close() methods
    76  	// are called, even if one panics or fails.
    77  	defer func() {
    78  		err := c.mwClient.Close()
    79  		if reterr == nil {
    80  			reterr = err
    81  		}
    82  	}()
    83  	return c.bqClient.Close()
    84  }
    85  
    86  // Insert inserts the given rows in BigQuery.
    87  func (c *Client) Insert(ctx context.Context, rows []*bqpb.FailureAssociationRulesHistoryRow) error {
    88  	if err := c.ensureSchema(ctx); err != nil {
    89  		return errors.Annotate(err, "ensure schema").Err()
    90  	}
    91  	tableName := fmt.Sprintf("projects/%s/datasets/%s/tables/%s", c.projectID, bqutil.InternalDatasetID, tableName)
    92  	writer := bqutil.NewWriter(c.mwClient, tableName, tableSchemaDescriptor)
    93  	payload := make([]proto.Message, len(rows))
    94  	for i, r := range rows {
    95  		payload[i] = r
    96  	}
    97  	return writer.AppendRowsWithPendingStream(ctx, payload)
    98  }
    99  
   100  // NewestLastUpdated get the largest value in the lastUpdated field from the BigQuery table.
   101  // The last_updated field is synced from the spanner table which is a spanner commit timestamp.
   102  // This the newest last_updated field indicate newest update of the failureAssociationRules spanner table
   103  // which has been synced to BigQuery.
   104  func (c *Client) NewestLastUpdated(ctx context.Context) (bigquery.NullTimestamp, error) {
   105  	if err := c.ensureSchema(ctx); err != nil {
   106  		return bigquery.NullTimestamp{}, errors.Annotate(err, "ensure schema").Err()
   107  	}
   108  	q := c.bqClient.Query(`
   109  		SELECT MAX(last_update_time) as LastUpdateTime
   110  		FROM failure_association_rules_history
   111  	`)
   112  	q.DefaultDatasetID = bqutil.InternalDatasetID
   113  	job, err := q.Run(ctx)
   114  	if err != nil {
   115  		return bigquery.NullTimestamp{}, errors.Annotate(err, "querying max last update").Err()
   116  	}
   117  	it, err := job.Read(ctx)
   118  
   119  	if err != nil {
   120  		return bigquery.NullTimestamp{}, err
   121  	}
   122  	type result struct {
   123  		LastUpdateTime bigquery.NullTimestamp
   124  	}
   125  	var lastUpdatedResult result
   126  	err = it.Next(&lastUpdatedResult)
   127  	if err != nil {
   128  		return bigquery.NullTimestamp{}, errors.Annotate(err, "obtain next row").Err()
   129  	}
   130  	return lastUpdatedResult.LastUpdateTime, nil
   131  }
   132  
   133  // schemaApplier ensures BQ schema matches the row proto definitions.
   134  var schemaApplier = bq.NewSchemaApplyer(bq.RegisterSchemaApplyerCache(1))
   135  
   136  func (c *Client) ensureSchema(ctx context.Context) error {
   137  	// Dataset for the project may have to be manually created.
   138  	table := c.bqClient.Dataset(bqutil.InternalDatasetID).Table(tableName)
   139  	if err := schemaApplier.EnsureTable(ctx, table, tableMetadata); err != nil {
   140  		return errors.Annotate(err, "ensuring %s table", tableName).Err()
   141  	}
   142  	return nil
   143  }