github.com/gravitational/teleport/api@v0.0.0-20240507183017-3110591cbafc/utils/retryutils/jitter.go (about)

     1  // Copyright 2022 Gravitational, Inc
     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 retryutils
    16  
    17  import (
    18  	"math/rand"
    19  	"sync"
    20  	"sync/atomic"
    21  	"time"
    22  
    23  	"github.com/gravitational/trace"
    24  )
    25  
    26  // Jitter is a function which applies random jitter to a
    27  // duration.  Used to randomize backoff values.  Must be
    28  // safe for concurrent usage.
    29  type Jitter func(time.Duration) time.Duration
    30  
    31  // NewJitter builds a new default jitter (currently jitters on
    32  // the range [d/2,d), but this is subject to change).
    33  func NewJitter() Jitter {
    34  	return NewHalfJitter()
    35  }
    36  
    37  // NewFullJitter builds a new jitter on the range [0,d). Most use-cases
    38  // are better served by a jitter with a meaningful minimum value, but if
    39  // the *only* purpose of the jitter is to spread out retries to the greatest
    40  // extent possible (e.g. when retrying a CompareAndSwap operation), a full jitter
    41  // may be appropriate.
    42  func NewFullJitter() Jitter {
    43  	jitter, _ := newJitter(1, newDefaultRng())
    44  	return jitter
    45  }
    46  
    47  // NewShardedFullJitter is equivalent to NewFullJitter except that it
    48  // performs better under high concurrency at the cost of having a larger
    49  // footprint in memory.
    50  func NewShardedFullJitter() Jitter {
    51  	jitter, _ := newShardedJitter(1, newDefaultRng)
    52  	return jitter
    53  }
    54  
    55  // NewHalfJitter returns a new jitter on the range [d/2,d).  This is
    56  // a large range and most suitable for jittering things like backoff
    57  // operations where breaking cycles quickly is a priority.
    58  func NewHalfJitter() Jitter {
    59  	jitter, _ := newJitter(2, newDefaultRng())
    60  	return jitter
    61  }
    62  
    63  // NewShardedHalfJitter is equivalent to NewHalfJitter except that it
    64  // performs better under high concurrency at the cost of having a larger
    65  // footprint in memory.
    66  func NewShardedHalfJitter() Jitter {
    67  	jitter, _ := newShardedJitter(2, newDefaultRng)
    68  	return jitter
    69  }
    70  
    71  // NewSeventhJitter builds a new jitter on the range [6d/7,d). Prefer smaller
    72  // jitters such as this when jittering periodic operations (e.g. cert rotation
    73  // checks) since large jitters result in significantly increased load.
    74  func NewSeventhJitter() Jitter {
    75  	jitter, _ := newJitter(7, newDefaultRng())
    76  	return jitter
    77  }
    78  
    79  // NewShardedSeventhJitter is equivalent to NewSeventhJitter except that it
    80  // performs better under high concurrency at the cost of having a larger
    81  // footprint in memory.
    82  func NewShardedSeventhJitter() Jitter {
    83  	jitter, _ := newShardedJitter(7, newDefaultRng)
    84  	return jitter
    85  }
    86  
    87  func newDefaultRng() rng {
    88  	return rand.New(rand.NewSource(time.Now().UnixNano()))
    89  }
    90  
    91  // rng is an interface implemented by math/rand.Rand. This interface
    92  // is used in testting.
    93  type rng interface {
    94  	// Int63n returns, as an int64, a non-negative pseudo-random number
    95  	// in the half-open interval [0,n). It panics if n <= 0.
    96  	Int63n(n int64) int64
    97  }
    98  
    99  // newJitter builds a new jitter on the range [d*(n-1)/n,d)
   100  // newJitter only returns an error if n < 1.
   101  func newJitter(n time.Duration, rng rng) (Jitter, error) {
   102  	if n < 1 {
   103  		return nil, trace.BadParameter("newJitter expects n>=1, but got %v", n)
   104  	}
   105  	var mu sync.Mutex
   106  	return func(d time.Duration) time.Duration {
   107  		// values less than 1 cause rng to panic, and some logic
   108  		// relies on treating zero duration as non-blocking case.
   109  		if d < 1 {
   110  			return 0
   111  		}
   112  		mu.Lock()
   113  		defer mu.Unlock()
   114  		return d*(n-1)/n + time.Duration(rng.Int63n(int64(d))/int64(n))
   115  	}, nil
   116  }
   117  
   118  // newShardedJitter constructs a new sharded jitter instance on the range [d*(n-1)/n,d)
   119  // newShardedJitter only returns an error if n < 1.
   120  func newShardedJitter(n time.Duration, mkrng func() rng) (Jitter, error) {
   121  	// the shard count here is pretty arbitrary. it was selected based on
   122  	// fiddling with some benchmarks. seems to be a good balance between
   123  	// limiting size and maximing perf under 100k concurrent calls
   124  	const shards = 64
   125  
   126  	if n < 1 {
   127  		return nil, trace.BadParameter("newShardedJitter expects n>=1, but got %v", n)
   128  	}
   129  
   130  	var rngs [shards]rng
   131  	var mus [shards]sync.Mutex
   132  	var ctr atomic.Uint64
   133  	var initOnce sync.Once
   134  
   135  	return func(d time.Duration) time.Duration {
   136  		// rng's allocate >4kb each during init, which is a bit annoying if the jitter
   137  		// isn't actually being used (e.g. when importing a package that has a global jitter).
   138  		// best to allocate lazily (this has no measurable impact on benchmarks).
   139  		initOnce.Do(func() {
   140  			for i := range rngs {
   141  				rngs[i] = mkrng()
   142  			}
   143  		})
   144  		// values less than 1 cause rng to panic, and some logic
   145  		// relies on treating zero duration as non-blocking case.
   146  		if d < 1 {
   147  			return 0
   148  		}
   149  		idx := ctr.Add(1) % shards
   150  		mus[idx].Lock()
   151  		r := d*(n-1)/n + time.Duration(rngs[idx].Int63n(int64(d))/int64(n))
   152  		mus[idx].Unlock()
   153  		return r
   154  	}, nil
   155  }