go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/bq/eventupload.go (about)

     1  // Copyright 2018 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 bq
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"math"
    21  	"strconv"
    22  	"strings"
    23  	"sync"
    24  	"time"
    25  
    26  	"cloud.google.com/go/bigquery"
    27  	"golang.org/x/exp/slices"
    28  	"google.golang.org/protobuf/encoding/protojson"
    29  	"google.golang.org/protobuf/proto"
    30  	"google.golang.org/protobuf/reflect/protoreflect"
    31  	"google.golang.org/protobuf/types/known/durationpb"
    32  	"google.golang.org/protobuf/types/known/structpb"
    33  	"google.golang.org/protobuf/types/known/timestamppb"
    34  
    35  	"go.chromium.org/luci/common/errors"
    36  	"go.chromium.org/luci/common/logging"
    37  	"go.chromium.org/luci/common/sync/parallel"
    38  	"go.chromium.org/luci/common/tsmon/field"
    39  	"go.chromium.org/luci/common/tsmon/metric"
    40  )
    41  
    42  // ID is the global InsertIDGenerator
    43  var ID InsertIDGenerator
    44  
    45  const insertLimit = 10000
    46  const batchDefault = 500
    47  
    48  // Uploader contains the necessary data for streaming data to BigQuery.
    49  type Uploader struct {
    50  	*bigquery.Inserter
    51  	// Uploader is bound to a specific table. DatasetID and Table ID are
    52  	// provided for reference.
    53  	DatasetID string
    54  	TableID   string
    55  	// UploadsMetricName is a string used to create a tsmon Counter metric
    56  	// for event upload attempts via Put, e.g.
    57  	// "/chrome/infra/commit_queue/events/count". If unset, no metric will
    58  	// be created.
    59  	UploadsMetricName string
    60  	// uploads is the Counter metric described by UploadsMetricName. It
    61  	// contains a field "status" set to either "success" or "failure."
    62  	uploads        metric.Counter
    63  	initMetricOnce sync.Once
    64  	// BatchSize is the max number of rows to send to BigQuery at a time.
    65  	// The default is 500.
    66  	BatchSize int
    67  }
    68  
    69  // Row implements bigquery.ValueSaver
    70  type Row struct {
    71  	proto.Message // embedded
    72  
    73  	// InsertID is unique per insert operation to handle deduplication.
    74  	InsertID string
    75  }
    76  
    77  // Save is used by bigquery.Inserter.Put when inserting values into a table.
    78  func (r *Row) Save() (map[string]bigquery.Value, string, error) {
    79  	m, err := mapFromMessage(r.Message, nil)
    80  	return m, r.InsertID, err
    81  }
    82  
    83  // mapFromMessage returns a {BQ Field name: BQ value} map.
    84  // path is a slice of Go field names leading to m.
    85  func mapFromMessage(pm proto.Message, path []string) (map[string]bigquery.Value, error) {
    86  	type kvPair struct {
    87  		key string
    88  		val protoreflect.Value
    89  	}
    90  
    91  	m := pm.ProtoReflect()
    92  	if !m.IsValid() {
    93  		return nil, nil
    94  	}
    95  	fields := m.Descriptor().Fields()
    96  
    97  	var row map[string]bigquery.Value // keep it nil unless there are values
    98  	path = append(path, "")
    99  
   100  	for i := 0; i < fields.Len(); i++ {
   101  		var bqValue any
   102  		var err error
   103  		field := fields.Get(i)
   104  		fieldValue := m.Get(field)
   105  		bqField := string(field.Name())
   106  		path[len(path)-1] = bqField
   107  
   108  		switch {
   109  		case field.IsList():
   110  			list := fieldValue.List()
   111  
   112  			elems := make([]any, 0, list.Len())
   113  			vPath := append(path, "")
   114  			for i := 0; i < list.Len(); i++ {
   115  				vPath[len(vPath)-1] = strconv.Itoa(i)
   116  				elemValue, err := getValue(field, list.Get(i), vPath)
   117  				if err != nil {
   118  					return nil, errors.Annotate(err, "%s[%d]", bqField, i).Err()
   119  				}
   120  				if elemValue == nil {
   121  					continue
   122  				}
   123  				elems = append(elems, elemValue)
   124  			}
   125  			if len(elems) == 0 {
   126  				continue
   127  			}
   128  			bqValue = elems
   129  		case field.IsMap():
   130  			if field.MapKey().Kind() != protoreflect.StringKind {
   131  				return nil, fmt.Errorf("map key must be a string")
   132  			}
   133  
   134  			mapValue := fieldValue.Map()
   135  			if mapValue.Len() == 0 {
   136  				continue
   137  			}
   138  
   139  			pairs := make([]kvPair, 0, mapValue.Len())
   140  			mapValue.Range(func(key protoreflect.MapKey, value protoreflect.Value) bool {
   141  				pairs = append(pairs, kvPair{key.String(), value})
   142  				return true
   143  			})
   144  			slices.SortFunc(pairs, func(i, j kvPair) int {
   145  				switch {
   146  				case i.key == j.key:
   147  					return 0
   148  				case i.key < j.key:
   149  					return -1
   150  				default:
   151  					return 1
   152  				}
   153  			})
   154  
   155  			valueDesc := field.MapValue()
   156  			elems := make([]any, mapValue.Len())
   157  			vPath := append(path, "")
   158  			for i, pair := range pairs {
   159  				vPath[len(vPath)-1] = pair.key
   160  				elemValue, err := getValue(valueDesc, pair.val, vPath)
   161  				if err != nil {
   162  					return nil, errors.Annotate(err, "%s[%s]", bqField, pair.key).Err()
   163  				}
   164  
   165  				elems[i] = map[string]bigquery.Value{
   166  					"key":   pair.key,
   167  					"value": elemValue,
   168  				}
   169  			}
   170  
   171  			bqValue = elems
   172  		default:
   173  			if bqValue, err = getValue(field, fieldValue, path); err != nil {
   174  				return nil, errors.Annotate(err, "%s", bqField).Err()
   175  			} else if bqValue == nil {
   176  				// Omit NULL/nil values
   177  				continue
   178  			}
   179  		}
   180  
   181  		if row == nil {
   182  			row = map[string]bigquery.Value{}
   183  		}
   184  		row[bqField] = bigquery.Value(bqValue)
   185  	}
   186  
   187  	return row, nil
   188  }
   189  
   190  func getValue(field protoreflect.FieldDescriptor, value protoreflect.Value, path []string) (any, error) {
   191  	// enums and primitives
   192  	if enumField := field.Enum(); enumField != nil {
   193  		enumName := string(enumField.Values().ByNumber(value.Enum()).Name())
   194  		return enumName, nil
   195  	}
   196  	if field.Kind() != protoreflect.MessageKind && field.Kind() != protoreflect.GroupKind {
   197  		return value.Interface(), nil
   198  	}
   199  
   200  	// structs
   201  	messageInterface := value.Message().Interface()
   202  	if dpb, ok := messageInterface.(*durationpb.Duration); ok {
   203  		if dpb == nil {
   204  			return nil, nil
   205  		}
   206  		if err := dpb.CheckValid(); err != nil {
   207  			return nil, fmt.Errorf("tried to write an invalid duration for [%+v] for field %q", dpb, strings.Join(path, "."))
   208  		}
   209  		value := dpb.AsDuration()
   210  		// Convert to FLOAT64.
   211  		return value.Seconds(), nil
   212  	}
   213  	if tspb, ok := messageInterface.(*timestamppb.Timestamp); ok {
   214  		if tspb == nil {
   215  			return nil, nil
   216  		}
   217  		if err := tspb.CheckValid(); err != nil {
   218  			return nil, fmt.Errorf("tried to write an invalid timestamp for [%+v] for field %q", tspb, strings.Join(path, "."))
   219  		}
   220  		value := tspb.AsTime()
   221  		return value, nil
   222  	}
   223  	if s, ok := messageInterface.(*structpb.Struct); ok {
   224  		if s == nil {
   225  			return nil, nil
   226  		}
   227  		// Structs are persisted as JSONPB strings.
   228  		// See also https://bit.ly/chromium-bq-struct
   229  		var buf []byte
   230  		var err error
   231  		if buf, err = protojson.Marshal(s); err != nil {
   232  			return nil, err
   233  		}
   234  		return string(buf), nil
   235  	}
   236  	message, err := mapFromMessage(messageInterface, path)
   237  	if message == nil {
   238  		// a nil map is not nil when converted to any,
   239  		// so return nil explicitly.
   240  		return nil, err
   241  	}
   242  	return message, err
   243  }
   244  
   245  // NewUploader constructs a new Uploader struct.
   246  //
   247  // DatasetID and TableID are provided to the BigQuery client to
   248  // gain access to a particular table.
   249  //
   250  // You may want to change the default configuration of the bigquery.Inserter.
   251  // Check the documentation for more details.
   252  //
   253  // Set UploadsMetricName on the resulting Uploader to use the default counter
   254  // metric.
   255  //
   256  // Set BatchSize to set a custom batch size.
   257  func NewUploader(ctx context.Context, c *bigquery.Client, datasetID, tableID string) *Uploader {
   258  	return &Uploader{
   259  		DatasetID: datasetID,
   260  		TableID:   tableID,
   261  		Inserter:  c.Dataset(datasetID).Table(tableID).Inserter(),
   262  	}
   263  }
   264  
   265  func (u *Uploader) batchSize() int {
   266  	switch {
   267  	case u.BatchSize > insertLimit:
   268  		return insertLimit
   269  	case u.BatchSize <= 0:
   270  		return batchDefault
   271  	default:
   272  		return u.BatchSize
   273  	}
   274  }
   275  
   276  func (u *Uploader) getCounter() metric.Counter {
   277  	u.initMetricOnce.Do(func() {
   278  		if u.UploadsMetricName != "" {
   279  			desc := "Upload attempts; status is 'success' or 'failure'"
   280  			field := field.String("status")
   281  			u.uploads = metric.NewCounter(u.UploadsMetricName, desc, nil, field)
   282  		}
   283  	})
   284  	return u.uploads
   285  }
   286  
   287  func (u *Uploader) updateUploads(ctx context.Context, count int64, status string) {
   288  	if uploads := u.getCounter(); uploads != nil && count != 0 {
   289  		uploads.Add(ctx, count, status)
   290  	}
   291  }
   292  
   293  // Put uploads one or more rows to the BigQuery service. Put takes care of
   294  // adding InsertIDs, used by BigQuery to deduplicate rows.
   295  //
   296  // If any rows do now match one of the expected types, Put will not attempt to
   297  // upload any rows and returns an InvalidTypeError.
   298  //
   299  // Put returns a PutMultiError if one or more rows failed to be uploaded.
   300  // The PutMultiError contains a RowInsertionError for each failed row.
   301  //
   302  // Put will retry on temporary errors. If the error persists, the call will
   303  // run indefinitely. Because of this, if ctx does not have a timeout, Put will
   304  // add one.
   305  //
   306  // See bigquery documentation and source code for detailed information on how
   307  // struct values are mapped to rows.
   308  func (u *Uploader) Put(ctx context.Context, messages ...proto.Message) error {
   309  	if _, ok := ctx.Deadline(); !ok {
   310  		var cancel context.CancelFunc
   311  		ctx, cancel = context.WithTimeout(ctx, time.Minute)
   312  		defer cancel()
   313  	}
   314  	rows := make([]*Row, len(messages))
   315  	for i, m := range messages {
   316  		rows[i] = &Row{
   317  			Message:  m,
   318  			InsertID: ID.Generate(),
   319  		}
   320  	}
   321  
   322  	return parallel.WorkPool(16, func(workC chan<- func() error) {
   323  		for _, rowSet := range batch(rows, u.batchSize()) {
   324  			rowSet := rowSet
   325  			workC <- func() error {
   326  				var failed int
   327  				err := u.Inserter.Put(ctx, rowSet)
   328  				if err != nil {
   329  					logging.WithError(err).Errorf(ctx, "eventupload: Uploader.Put failed")
   330  					if merr, ok := err.(bigquery.PutMultiError); ok {
   331  						if failed = len(merr); failed > len(rowSet) {
   332  							logging.Errorf(ctx, "eventupload: %v failures trying to insert %v rows", failed, len(rowSet))
   333  						}
   334  					} else {
   335  						failed = len(rowSet)
   336  					}
   337  					u.updateUploads(ctx, int64(failed), "failure")
   338  				}
   339  				succeeded := len(rowSet) - failed
   340  				u.updateUploads(ctx, int64(succeeded), "success")
   341  				return err
   342  			}
   343  		}
   344  	})
   345  }
   346  
   347  func batch(rows []*Row, batchSize int) [][]*Row {
   348  	rowSetsLen := int(math.Ceil(float64(len(rows) / batchSize)))
   349  	rowSets := make([][]*Row, 0, rowSetsLen)
   350  	for len(rows) > 0 {
   351  		batch := rows
   352  		if len(batch) > batchSize {
   353  			batch = batch[:batchSize]
   354  		}
   355  		rowSets = append(rowSets, batch)
   356  		rows = rows[len(batch):]
   357  	}
   358  	return rowSets
   359  }