go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/bqlog/bundler.go (about)

     1  // Copyright 2021 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 bqlog
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"io"
    21  	"sync"
    22  	"time"
    23  
    24  	"cloud.google.com/go/bigquery/storage/apiv1/storagepb"
    25  	codepb "google.golang.org/genproto/googleapis/rpc/code"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/grpc/status"
    28  	"google.golang.org/protobuf/proto"
    29  	"google.golang.org/protobuf/reflect/protodesc"
    30  	"google.golang.org/protobuf/reflect/protoreflect"
    31  	"google.golang.org/protobuf/types/descriptorpb"
    32  
    33  	"go.chromium.org/luci/common/clock"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/logging"
    36  	"go.chromium.org/luci/common/retry/transient"
    37  	"go.chromium.org/luci/common/sync/dispatcher"
    38  	"go.chromium.org/luci/common/sync/dispatcher/buffer"
    39  	"go.chromium.org/luci/common/tsmon/field"
    40  	"go.chromium.org/luci/common/tsmon/metric"
    41  	"go.chromium.org/luci/grpc/grpcutil"
    42  )
    43  
    44  var (
    45  	// Roughly a limit on a size of a single AppendRows message.
    46  	batchSizeMaxBytes = 5 * 1024 * 1024
    47  	// How long to wait by default before sending an incomplete batch.
    48  	defaultBatchAgeMax = 10 * time.Second
    49  	// How many bytes to buffer by default before starting dropping excesses.
    50  	defaultMaxLiveSizeBytes = 50 * 1024 * 1024
    51  )
    52  
    53  var (
    54  	metricSentCounter = metric.NewCounter(
    55  		"bqlog/sent",
    56  		"Count of log entries successfully sent",
    57  		nil,
    58  		field.String("table"), // full table ID "project.dataset.table"
    59  	)
    60  	metricDroppedCounter = metric.NewCounter(
    61  		"bqlog/dropped",
    62  		"Count of log entries dropped for various reasons",
    63  		nil,
    64  		field.String("table"),  // full table ID "project.dataset.table"
    65  		field.String("reason"), // reason of why it was dropped if known
    66  	)
    67  	metricErrorsCounter = metric.NewCounter(
    68  		"bqlog/errors",
    69  		"Count of encountered RPC errors",
    70  		nil,
    71  		field.String("table"), // full table ID "project.dataset.table"
    72  		field.String("code"),  // canonical gRPC code (as string) if known
    73  	)
    74  )
    75  
    76  // Bundler buffers logs in memory before sending them to BigQuery.
    77  type Bundler struct {
    78  	CloudProject string // the cloud project with the dataset, required
    79  	Dataset      string // the BQ dataset with log tables, required
    80  
    81  	m        sync.RWMutex
    82  	bufs     map[protoreflect.FullName]*logBuffer
    83  	ctx      context.Context
    84  	cancel   context.CancelFunc
    85  	running  bool
    86  	draining bool
    87  }
    88  
    89  // Sink describes where and how to log proto messages of the given type.
    90  type Sink struct {
    91  	Prototype        proto.Message // used only for its type descriptor, required
    92  	Table            string        // the BQ table name within the bundler's dataset, required
    93  	BatchAgeMax      time.Duration // for how long to buffer messages (or 0 for some default)
    94  	MaxLiveSizeBytes int           // approximate limit on memory buffer size (or 0 for some default)
    95  }
    96  
    97  // RegisterSink tells the bundler where and how to log messages of some
    98  // concrete proto type.
    99  //
   100  // There can currently be only one sink per proto message type. Must be called
   101  // before the bundler is running. Can be called during the init() time.
   102  func (b *Bundler) RegisterSink(sink Sink) {
   103  	if sink.BatchAgeMax == 0 {
   104  		sink.BatchAgeMax = defaultBatchAgeMax
   105  	}
   106  	if sink.MaxLiveSizeBytes == 0 {
   107  		sink.MaxLiveSizeBytes = defaultMaxLiveSizeBytes
   108  	} else if sink.MaxLiveSizeBytes < batchSizeMaxBytes {
   109  		sink.MaxLiveSizeBytes = batchSizeMaxBytes
   110  	}
   111  
   112  	if sink.Table == "" {
   113  		panic("missing required field Table")
   114  	}
   115  
   116  	b.m.Lock()
   117  	defer b.m.Unlock()
   118  
   119  	if b.running {
   120  		panic("the bundler is already running")
   121  	}
   122  
   123  	typ := proto.MessageName(sink.Prototype)
   124  	if b.bufs[typ] != nil {
   125  		panic(fmt.Sprintf("message type %q was already registered with RegisterSink", typ))
   126  	}
   127  	if b.bufs == nil {
   128  		b.bufs = make(map[protoreflect.FullName]*logBuffer, 1)
   129  	}
   130  	b.bufs[typ] = &logBuffer{
   131  		decl: sink,
   132  		desc: protodesc.ToDescriptorProto(sink.Prototype.ProtoReflect().Descriptor()),
   133  	}
   134  }
   135  
   136  // Log asynchronously logs the given message to a BQ table associated with
   137  // the message type via a prior RegisterSink call.
   138  //
   139  // This is a best effort operation (and thus returns no error).
   140  //
   141  // Messages are dropped when:
   142  //   - Writes to BigQuery are failing with a fatal error:
   143  //   - The table doesn't exist.
   144  //   - The table has an incompatible schema.
   145  //   - The server account has no permission to write to the table.
   146  //   - Etc.
   147  //   - The server crashes before it manages to flush buffered logs.
   148  //   - The internal flush buffer is full (per MaxLiveSizeBytes).
   149  //
   150  // In case of transient write errors messages may be duplicated.
   151  //
   152  // Panics if `m` was not registered via RegisterSink or if the bundler is not
   153  // running yet.
   154  func (b *Bundler) Log(ctx context.Context, m proto.Message) {
   155  	typ := proto.MessageName(m)
   156  	blob, err := proto.Marshal(m)
   157  
   158  	b.m.RLock()
   159  	defer b.m.RUnlock()
   160  
   161  	if !b.running {
   162  		panic("the bundler is not running yet")
   163  	}
   164  	buf := b.bufs[typ]
   165  	if buf == nil {
   166  		panic(fmt.Sprintf("message type %q was not registered with RegisterSink", typ))
   167  	}
   168  
   169  	switch {
   170  	case err != nil:
   171  		recordDrop(ctx, buf.tableID, 1, err, "MARSHAL_ERROR")
   172  	case b.draining:
   173  		recordDrop(ctx, buf.tableID, 1, errors.New("draining already"), "DRAINING")
   174  	default:
   175  		// Note: we explicitly do not select on ctx.Done() since often the context
   176  		// is already expired (e.g. when logging from handlers that have reached the
   177  		// deadline). Sending to the channel here should be fast, since buffer's
   178  		// FullBehavior is set to DropOldestBatch, i.e. it never really blocks.
   179  		buf.disp.C <- blob
   180  	}
   181  }
   182  
   183  // Start launches the bundler internal goroutines.
   184  //
   185  // Canceling the context with cease all bundler activities. To gracefully
   186  // shutdown the bundler (e.g. by flushing all pending logs) use Shutdown.
   187  func (b *Bundler) Start(ctx context.Context, w BigQueryWriter) {
   188  	if b.CloudProject == "" {
   189  		panic("missing required field CloudProject")
   190  	}
   191  	if b.Dataset == "" {
   192  		panic("missing required field Dataset")
   193  	}
   194  
   195  	b.m.Lock()
   196  	defer b.m.Unlock()
   197  
   198  	if b.running {
   199  		panic("the bundler is already running")
   200  	}
   201  	b.running = true
   202  	b.ctx, b.cancel = context.WithCancel(ctx)
   203  
   204  	for _, buf := range b.bufs {
   205  		tableID := fmt.Sprintf("%s.%s.%s", b.CloudProject, b.Dataset, buf.decl.Table)
   206  		bufCtx := loggingFields(b.ctx, tableID)
   207  		buf.start(bufCtx, tableID, &logSender{
   208  			ctx:     bufCtx,
   209  			w:       w,
   210  			tableID: tableID,
   211  			desc:    buf.desc,
   212  			dest:    fmt.Sprintf("projects/%s/datasets/%s/tables/%s/_default", b.CloudProject, b.Dataset, buf.decl.Table),
   213  		})
   214  	}
   215  }
   216  
   217  // Shutdown flushes pending logs and closes streaming RPCs.
   218  //
   219  // Does nothing if the bundler wasn't running. Gives up waiting for all data to
   220  // be flushed (and drops it) after 15s timeout or when `ctx` is canceled.
   221  func (b *Bundler) Shutdown(ctx context.Context) {
   222  	var drained []chan struct{}
   223  
   224  	b.m.Lock()
   225  	if b.running && !b.draining {
   226  		b.draining = true
   227  		for _, buf := range b.bufs {
   228  			drained = append(drained, buf.drain(loggingFields(ctx, buf.tableID)))
   229  		}
   230  		// Totally shutdown everything after some deadline by canceling the root
   231  		// bundler context. It should cause all dispatcher.Channels to give up on
   232  		// any retries they might be doing.
   233  		cancel := b.cancel
   234  		go func() {
   235  			<-clock.After(ctx, 15*time.Second)
   236  			cancel()
   237  		}()
   238  	}
   239  	b.m.Unlock()
   240  
   241  	for _, ch := range drained {
   242  		<-ch
   243  	}
   244  }
   245  
   246  ////////////////////////////////////////////////////////////////////////////////
   247  
   248  func loggingFields(ctx context.Context, tableID string) context.Context {
   249  	return logging.SetFields(ctx, logging.Fields{
   250  		"activity": "luci.bqlog",
   251  		"table":    tableID,
   252  	})
   253  }
   254  
   255  func recordSent(ctx context.Context, tableID string, count int) {
   256  	metricSentCounter.Add(ctx, int64(count), tableID)
   257  }
   258  
   259  func recordDrop(ctx context.Context, tableID string, count int, err error, reason string) {
   260  	ctx = loggingFields(ctx, tableID)
   261  	if err != nil {
   262  		logging.Errorf(ctx, "Dropped %d row(s): %s: %s", count, reason, err)
   263  	} else {
   264  		logging.Errorf(ctx, "Dropped %d row(s): %s", count, reason)
   265  	}
   266  	metricDroppedCounter.Add(ctx, int64(count), tableID, reason)
   267  }
   268  
   269  func recordErr(ctx context.Context, tableID string, count int, err error) {
   270  	ctx = loggingFields(ctx, tableID)
   271  	if transient.Tag.In(err) {
   272  		logging.Warningf(ctx, "Transient error when sending %d row(s): %s", count, err)
   273  	} else {
   274  		logging.Errorf(ctx, "Fatal error when sending %d row(s): %s", count, err)
   275  	}
   276  	codeStr := "UNKNOWN"
   277  	if code := grpcutil.Code(err); code != codes.Unknown {
   278  		if codeStr = codepb.Code_name[int32(code)]; codeStr == "" {
   279  			codeStr = fmt.Sprintf("CODE_%d", code)
   280  		}
   281  	} else if errors.Contains(err, io.EOF) {
   282  		codeStr = "EOF"
   283  	}
   284  	metricErrorsCounter.Add(ctx, 1, tableID, codeStr)
   285  }
   286  
   287  ////////////////////////////////////////////////////////////////////////////////
   288  
   289  type logBuffer struct {
   290  	decl    Sink
   291  	desc    *descriptorpb.DescriptorProto
   292  	tableID string
   293  	sender  *logSender
   294  	disp    dispatcher.Channel
   295  }
   296  
   297  func (b *logBuffer) start(ctx context.Context, tableID string, sender *logSender) {
   298  	b.tableID = tableID
   299  	b.sender = sender
   300  
   301  	opts := dispatcher.Options{
   302  		ItemSizeFunc: func(itm any) int { return len(itm.([]byte)) },
   303  		DropFn: func(data *buffer.Batch, flush bool) {
   304  			if data != nil {
   305  				recordDrop(ctx, b.tableID, len(data.Data), nil, "DISPATCHER")
   306  			}
   307  		},
   308  		ErrorFn: func(data *buffer.Batch, err error) (retry bool) {
   309  			recordErr(ctx, b.tableID, len(data.Data), err)
   310  			return transient.Tag.In(err)
   311  		},
   312  		Buffer: buffer.Options{
   313  			MaxLeases:     1,  // there can be only one outstanding write per an RPC stream
   314  			BatchItemsMax: -1, // cut batches based on size
   315  			BatchSizeMax:  batchSizeMaxBytes,
   316  			BatchAgeMax:   b.decl.BatchAgeMax,
   317  			FullBehavior: &buffer.DropOldestBatch{
   318  				MaxLiveSize: b.decl.MaxLiveSizeBytes,
   319  			},
   320  		},
   321  	}
   322  
   323  	var err error
   324  	b.disp, err = dispatcher.NewChannel(ctx, &opts, sender.send)
   325  	if err != nil {
   326  		panic(fmt.Sprintf("failed to start the dispatcher: %s", err)) // should not be happening
   327  	}
   328  }
   329  
   330  func (b *logBuffer) drain(ctx context.Context) chan struct{} {
   331  	logging.Debugf(ctx, "Draining...")
   332  
   333  	drained := make(chan struct{})
   334  
   335  	b.disp.Close()
   336  	go func() {
   337  		// Wait until the dispatcher channel is drained into the gRPC sender.
   338  		select {
   339  		case <-ctx.Done():
   340  		case <-b.disp.DrainC:
   341  		}
   342  
   343  		// Wait until the pending gRPC data is flushed.
   344  		b.sender.stop()
   345  
   346  		logging.Debugf(ctx, "Drained")
   347  		close(drained)
   348  	}()
   349  
   350  	return drained
   351  }
   352  
   353  ////////////////////////////////////////////////////////////////////////////////
   354  
   355  type logSender struct {
   356  	ctx     context.Context
   357  	w       BigQueryWriter
   358  	tableID string
   359  	desc    *descriptorpb.DescriptorProto
   360  	dest    string
   361  
   362  	m      sync.Mutex
   363  	stream storagepb.BigQueryWrite_AppendRowsClient
   364  }
   365  
   366  func (s *logSender) send(data *buffer.Batch) (rerr error) {
   367  	// There allowed only one concurrent Send or CloseSend per a gRPC stream.
   368  	s.m.Lock()
   369  	defer s.m.Unlock()
   370  
   371  	// Open the gRPC stream if have none yet.
   372  	opened := false
   373  	if s.stream == nil {
   374  		stream, err := s.w.AppendRows(s.ctx)
   375  		if err != nil {
   376  			return grpcutil.WrapIfTransient(err)
   377  		}
   378  		opened = true
   379  		s.stream = stream
   380  	}
   381  
   382  	// Prepare the request.
   383  	protoData := &storagepb.AppendRowsRequest_ProtoData{
   384  		Rows: &storagepb.ProtoRows{
   385  			SerializedRows: make([][]byte, len(data.Data)),
   386  		},
   387  	}
   388  	for i, row := range data.Data {
   389  		protoData.Rows.SerializedRows[i] = row.Item.([]byte)
   390  	}
   391  
   392  	// WriterSchema field is necessary only in the first request.
   393  	if opened {
   394  		protoData.WriterSchema = &storagepb.ProtoSchema{
   395  			ProtoDescriptor: s.desc,
   396  		}
   397  	}
   398  
   399  	err := s.stream.Send(&storagepb.AppendRowsRequest{
   400  		WriteStream: s.dest,
   401  		Rows: &storagepb.AppendRowsRequest_ProtoRows{
   402  			ProtoRows: protoData,
   403  		},
   404  	})
   405  
   406  	// If the stream was aborted, properly shut it all down so we can try again
   407  	// later with a new stream.
   408  	if err != nil {
   409  		s.stream.CloseSend()
   410  		s.stream.Recv()
   411  		s.stream = nil
   412  		return errors.Annotate(err, "failed to send data to BQ").Tag(transient.Tag).Err()
   413  	}
   414  
   415  	// Otherwise try to read the acknowledgment from the server. We need to wait
   416  	// for it to make sure it is OK to "forget" about the `data` batch.
   417  	resp, err := s.stream.Recv()
   418  
   419  	// If there's a gRPC-level error, it means the connection is broken and should
   420  	// be abandoned.
   421  	if err != nil {
   422  		s.stream = nil
   423  		if err == io.EOF {
   424  			return errors.Annotate(err, "server unexpected closed the connection").Tag(transient.Tag).Err()
   425  		}
   426  		return grpcutil.WrapIfTransient(err)
   427  	}
   428  
   429  	// If the overall connection is fine, but the latest push specifically was
   430  	// rejected, the error is in `resp`.
   431  	if sts := resp.GetError(); sts != nil {
   432  		return grpcutil.WrapIfTransient(status.ErrorProto(sts))
   433  	}
   434  
   435  	recordSent(s.ctx, s.tableID, len(data.Data))
   436  	return nil
   437  }
   438  
   439  func (s *logSender) stop() {
   440  	s.m.Lock()
   441  	defer s.m.Unlock()
   442  	if s.stream != nil {
   443  		s.stream.CloseSend()
   444  		s.stream.Recv() // wait for ACK
   445  		s.stream = nil
   446  	}
   447  }