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

     1  // Copyright 2020 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 limiter
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sync/atomic"
    21  
    22  	"go.chromium.org/luci/common/errors"
    23  	"go.chromium.org/luci/common/logging"
    24  	"go.chromium.org/luci/common/tsmon/field"
    25  	"go.chromium.org/luci/common/tsmon/metric"
    26  )
    27  
    28  // ErrLimitReached is returned by CheckRequest when some limit is reached.
    29  var ErrLimitReached = errors.New("the server limit reached")
    30  
    31  var (
    32  	// Number of in-flight requests.
    33  	concurrencyCurGauge = metric.NewInt(
    34  		"server/limiter/concurrency/cur",
    35  		"Number of requests being processed right now.",
    36  		nil,
    37  		field.String("limiter"), // name of the limiter that reports the metric
    38  	)
    39  
    40  	// Configured maximum number of in-flight requests.
    41  	concurrencyMaxGauge = metric.NewInt(
    42  		"server/limiter/concurrency/max",
    43  		"A configured limit on a number of concurrently processed requests.",
    44  		nil,
    45  		field.String("limiter"), // name of the limiter that reports the metric
    46  	)
    47  
    48  	// Counter with rejected requests.
    49  	rejectedCounter = metric.NewCounter(
    50  		"server/limiter/rejected",
    51  		"Number of rejected requests.",
    52  		nil,
    53  		field.String("limiter"), // name of the limiter that did the rejection
    54  		field.String("call"),    // an RPC or an endpoint being called (if known)
    55  		field.String("peer"),    // who's making the request (if known), see also peer.go.
    56  		field.String("reason"))  // why the request was rejected
    57  )
    58  
    59  // Options contains configuration of a single Limiter instance.
    60  type Options struct {
    61  	Name                  string // used for metric fields, logs and error messages
    62  	AdvisoryMode          bool   // if true, don't actually reject requests, just log
    63  	MaxConcurrentRequests int64  // a hard limit on a number of concurrent requests
    64  }
    65  
    66  // Limiter is a stateful runtime object that decides whether to accept or reject
    67  // requests based on the current load (calculated from requests that went
    68  // through it).
    69  //
    70  // It is also responsible for maintaining monitoring metrics that describe its
    71  // state and what requests it rejects.
    72  //
    73  // May be running in advisory mode, in which it will do all the usual processing
    74  // and logging, but won't actually reject the requests.
    75  //
    76  // All methods are safe for concurrent use.
    77  type Limiter struct {
    78  	opts        Options // options passed to New, as is
    79  	titleForLog string  // how the limiter is named in logs and error replies
    80  	concurrency int64   // atomic int with number of current in-flight requests
    81  }
    82  
    83  // RequestInfo holds information about a single inbound request.
    84  //
    85  // Used by the limiter to decide whether to accept or reject the request.
    86  //
    87  // Pretty sparse now, but in the future will contain fields like cost, a QoS
    88  // class and an attempt count, which will help the limiter to decide what
    89  // requests to drop.
    90  //
    91  // Fields `CallLabel` and `PeerLabel` are intentionally pretty generic, since
    92  // they will be used only as labels in internal maps and metric fields. Their
    93  // internal structure and meaning are not important to the limiter, but the
    94  // cardinality of the set of their possible values must be reasonably bounded.
    95  type RequestInfo struct {
    96  	CallLabel string // an RPC or an endpoint being called (if known)
    97  	PeerLabel string // who's making the request (if known), see also peer.go
    98  }
    99  
   100  // New returns a new limiter.
   101  //
   102  // Returns an error if options are invalid.
   103  func New(opts Options) (*Limiter, error) {
   104  	if opts.MaxConcurrentRequests <= 0 {
   105  		return nil, errors.New("max concurrent requests must be positive")
   106  	}
   107  	return &Limiter{
   108  		opts:        opts,
   109  		titleForLog: fmt.Sprintf("%s<=%d", opts.Name, opts.MaxConcurrentRequests),
   110  	}, nil
   111  }
   112  
   113  // ReportMetrics updates all limiter's gauge metrics to match the current state.
   114  //
   115  // Must be called periodically (at least once per every metrics flush).
   116  func (l *Limiter) ReportMetrics(ctx context.Context) {
   117  	concurrencyCurGauge.Set(ctx, atomic.LoadInt64(&l.concurrency), l.opts.Name)
   118  	concurrencyMaxGauge.Set(ctx, l.opts.MaxConcurrentRequests, l.opts.Name)
   119  }
   120  
   121  // CheckRequest should be called before processing a request.
   122  //
   123  // If it returns an error, the request should be declined as soon as possible
   124  // with Unavailable/HTTP 503 status and the given error (which is an annotated
   125  // ErrLimitReached).
   126  //
   127  // If it succeeds, the request should be processed as usual, and the returned
   128  // callback called afterwards to notify the limiter the processing is done.
   129  func (l *Limiter) CheckRequest(ctx context.Context, ri *RequestInfo) (done func(), err error) {
   130  	// TODO(vadimsh): This is the simplest limiter implementation possible. It
   131  	// will likely learn more tricks once we understand what features we need.
   132  	for {
   133  		cur := atomic.LoadInt64(&l.concurrency)
   134  		if cur >= l.opts.MaxConcurrentRequests && !l.opts.AdvisoryMode {
   135  			return nil, l.reject(ctx, ri, "max concurrency")
   136  		}
   137  		if !atomic.CompareAndSwapInt64(&l.concurrency, cur, cur+1) {
   138  			continue // race, try again
   139  		}
   140  		// Now that we have definitely grabbed the execution slot, report the
   141  		// advisory rejection message. Doing it sooner may result in duplications.
   142  		if cur >= l.opts.MaxConcurrentRequests && l.opts.AdvisoryMode {
   143  			_ = l.reject(ctx, ri, "max concurrency") // actually ignore the error
   144  		}
   145  		return func() { atomic.AddInt64(&l.concurrency, -1) }, nil
   146  	}
   147  }
   148  
   149  // reject is called when the request is rejected (either for real or in
   150  // advisory mode).
   151  //
   152  // It updates metrics and logs and returns an annotated ErrLimitReached error.
   153  func (l *Limiter) reject(ctx context.Context, ri *RequestInfo, reason string) error {
   154  	rejectedCounter.Add(ctx, 1, l.opts.Name, ri.CallLabel, ri.PeerLabel, reason)
   155  	if l.opts.AdvisoryMode {
   156  		logging.Warningf(ctx, "limiter %q in advisory mode: the request hit the %s limit", l.titleForLog, reason)
   157  	} else {
   158  		logging.Errorf(ctx, "limiter %q: the request hit the %s limit", l.titleForLog, reason)
   159  	}
   160  	return errors.Annotate(ErrLimitReached, "limiter %q: %s limit", l.titleForLog, reason).Err()
   161  }