Skip to content

Commit

Permalink
Add exponential backoff implementation and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ViToni committed Apr 19, 2024
1 parent 84be3e1 commit 543e5de
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 0 deletions.
116 changes: 116 additions & 0 deletions autopaho/backoff.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package autopaho

import (
"math/rand"
"time"
)

Expand Down Expand Up @@ -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
}
65 changes: 65 additions & 0 deletions autopaho/backoff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

0 comments on commit 543e5de

Please sign in to comment.