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 }