From 543e5debd08d444d5865c9e753b3b4db510d1a89 Mon Sep 17 00:00:00 2001 From: Victor Toni Date: Fri, 19 Apr 2024 15:25:06 +0200 Subject: [PATCH] Add exponential backoff implementation and tests --- autopaho/backoff.go | 116 +++++++++++++++++++++++++++++++++++++++ autopaho/backoff_test.go | 65 ++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/autopaho/backoff.go b/autopaho/backoff.go index 286a5b8..25583e3 100644 --- a/autopaho/backoff.go +++ b/autopaho/backoff.go @@ -16,6 +16,7 @@ package autopaho import ( + "math/rand" "time" ) @@ -80,3 +81,118 @@ func NewConstantBackoffStrategy(delay time.Duration) *ConstantBackoffStrategy { backoff: backoff, } } + +//////////////////////////////////////////////////////////////////////////////// +// implementation for an exponential backoff strategy +//////////////////////////////////////////////////////////////////////////////// + +type ExponentialBackoffStrategy struct { + minDelayMillis int64 // lower bound for computed backoff + maxDelayMillis int64 // maximum upper bound for computed backoff + initialMaxDelayMillis int64 // initial max value which wiil incerease exponentially up to the max delay + factor float32 // factor for the exponential increase of initial max delay +} + +// The ExponentialBackoffStrategy provides a backoff duration within a range +// that increases exponentially up to a specified max value. +// +// The backoff duration is computed as a random value between the fixed min and the current max value. +// The current max is updated by multiplying the current max with the factor up the the max value. +// +// Implementation note: +// +// For simplicity the backoff uses numbers instead of duration. +func (ebs *ExponentialBackoffStrategy) Backoff() Backoff { + // only "moving part", + // will be multiplied by "factor" up to the max value on each Next() call + movingMaxMillis := ebs.initialMaxDelayMillis + + // Computes the next backoff duration + // which will be a random value between the min and the current max value. + computeDuration := func() time.Duration { + randomMillisInRange := randRange(ebs.minDelayMillis, movingMaxMillis) + + return time.Duration(randomMillisInRange) * time.Millisecond + } + + // Updates the current max value by multiplying it with the factor + // and ensures it does not exceed the configured max value. + updateRange := func() { + // do nothing when max value is already reached + if movingMaxMillis == ebs.maxDelayMillis { + return + } + + nextMaxMillis := int64(float32(movingMaxMillis) * ebs.factor) + + // ensure we stay in range + // check for overflow of range OR numerical overflow + if ebs.maxDelayMillis < nextMaxMillis || nextMaxMillis < ebs.minDelayMillis { + nextMaxMillis = ebs.maxDelayMillis + } + + movingMaxMillis = nextMaxMillis + } + + return &BackoffDelegate{ + next: func() time.Duration { + defer updateRange() + + return computeDuration() + }, + } +} + +func NewExponentialBackoffStrategy( + minDelay time.Duration, + maxDelay time.Duration, + initialMaxDelay time.Duration, + factor float32, +) *ExponentialBackoffStrategy { + if minDelay <= 0 { + panic("min delay must NOT be less than or equal to: 0") + } + if maxDelay <= minDelay { + panic("max delay must NOT be less than or equal to: min delay") + } + if initialMaxDelay < minDelay || maxDelay < initialMaxDelay { + panic("initial max delay must be in range of: (min, max) delay") + } + if factor <= 1 { + panic("factor must NOT be less than or equal to: 1") + } + + return &ExponentialBackoffStrategy{ + minDelayMillis: minDelay.Milliseconds(), + maxDelayMillis: maxDelay.Milliseconds(), + initialMaxDelayMillis: initialMaxDelay.Milliseconds(), + factor: factor, + } +} + +// DefaultExponentialBackoffStrategy returns a new ExponentialBackoffStrategy with default values. +// +// The default values are: +// - min delay: 5 seconds +// - max delay: 10 minutes +// - initial max delay: 10 seconds +// - factor: 1.5 +func DefaultExponentialBackoffStrategy() *ExponentialBackoffStrategy { + return NewExponentialBackoffStrategy( + 05*time.Second, // minDelay + 10*time.Minute, // maxDelay + 10*time.Second, // initialMaxDelay + 1.5, // factor + ) +} + +//////////////////////////////////////////////////////////////////////////////// +// util functions +//////////////////////////////////////////////////////////////////////////////// + +// Returns a random number in the range of [start, end] (inclusive) +func randRange(start int64, end int64) int64 { + normalizedRange := end - start + 1 + + return rand.Int63n(normalizedRange) + start +} diff --git a/autopaho/backoff_test.go b/autopaho/backoff_test.go index 02c2a9c..bb8df3d 100644 --- a/autopaho/backoff_test.go +++ b/autopaho/backoff_test.go @@ -53,3 +53,68 @@ func TestConstantBackoffStrategyRandomValue(t *testing.T) { } } } + +// tests for the exponential backoff strategy implementation + +func TestRandomExponentialBackoff(t *testing.T) { + for i := 0; i < 20; i++ { + doSetupAndTestRandomExponentialBackoff(t) + } +} + +func doSetupAndTestRandomExponentialBackoff(t *testing.T) { + minDelayInMillisLowerBound := int64(500) // 500ms + minDelayInMillisUpperBound := minDelayInMillisLowerBound + 5*1_000 // +5s + minDelayInMillis := randRange( + minDelayInMillisLowerBound, + minDelayInMillisUpperBound, + ) + + minDelay := time.Duration(minDelayInMillis) * time.Millisecond + + // set up a partially random initial max backoff time + initialMaxDelayInMillisLowerBound := minDelayInMillis + 500 // +500ms + initialMaxDelayInMillisUpperBound := initialMaxDelayInMillisLowerBound + 30*1_000 // +30s + initialMaxDelayInMillis := randRange( + initialMaxDelayInMillisLowerBound, + initialMaxDelayInMillisUpperBound, + ) + + initialMaxDelay := time.Duration(initialMaxDelayInMillis) * time.Millisecond + + // set up a partially random max backoff time + maxDelayMillisLowerBound := minDelayInMillis + 30*60*1_000 // +30min + maxDelayInMillisUpperBound := maxDelayMillisLowerBound + 60*60*1_000 // +60min + maxDelayInMillis := randRange( + maxDelayMillisLowerBound, + maxDelayInMillisUpperBound, + ) + + maxDelay := time.Duration(maxDelayInMillis) * time.Millisecond + + // set up factor for the next variation + const factor = 1.6 + + exponentialBackoffStrategy := NewExponentialBackoffStrategy( + minDelay, + maxDelay, + initialMaxDelay, + factor, + ) + + exponentialBackoff := exponentialBackoffStrategy.Backoff() + + // create many backoffs and test they are within constraints + for i := 0; i < 50; i++ { + actual := exponentialBackoff.Next() + if i == 0 && initialMaxDelay < actual { + t.Fatalf("Actual backoff value: `%s` was higher than configured initial maximum: `%s`", actual, initialMaxDelay) + } + if actual < minDelay { + t.Fatalf("Actual backoff value: `%s` was less than configured minimum: `%s`", actual, minDelay) + } + if maxDelay < actual { + t.Fatalf("Actual backoff value: `%s` was higher than configured maximum: `%s`", actual, maxDelay) + } + } +}