diff --git a/cmix/attempts/histrogram.go b/cmix/attempts/histrogram.go index 52bf207a48345818f0f1900d5c69b45e0259d7d8..37e7292592aeae1cdda482a82b0fdcf5530933d9 100644 --- a/cmix/attempts/histrogram.go +++ b/cmix/attempts/histrogram.go @@ -1,49 +1,66 @@ package attempts import ( + "fmt" "sort" + "strconv" + "strings" "sync" "sync/atomic" ) -const maxHistogramSize = 100 -const minElements = 3 -const percentileNumerator = 66 -const percentileDenominator = 99 -const percentileDenominatorOffset = 49 +const ( + maxHistogramSize = 100 + minElements = 3 + percentileNumerator = 66 + percentileDenominator = 99 + percentileDenominatorOffset = 49 + optimalAttemptsInitValue = -1 +) +// SendAttemptTracker tracks the number of attempts it took to send a cMix +// message in order to predict how many attempt are needed. type SendAttemptTracker interface { + // SubmitProbeAttempt feeds the number of attempts it took to send a cMix + // message into the tracker and updates the optimal number of attempts. SubmitProbeAttempt(numAttemptsUntilSuccessful int) + + // GetOptimalNumAttempts returns the number of optimal sends. If there is + // insufficient data to calculate, then ready is false. GetOptimalNumAttempts() (attempts int, ready bool) } +// sendAttempts tracks the number of attempts to send a cMix message. type sendAttempts struct { - lock sync.Mutex - numAttempts []int - currentIndex int - isFull bool - optimalAttempts *int32 + isFull bool + currentIndex int + numAttempts []int + lock sync.Mutex } +// NewSendAttempts initialises a new SendAttemptTracker. func NewSendAttempts() SendAttemptTracker { - optimalAttempts := int32(-1) - + optimalAttempts := int32(optimalAttemptsInitValue) sa := &sendAttempts{ - numAttempts: make([]int, maxHistogramSize), - currentIndex: 0, - isFull: false, optimalAttempts: &optimalAttempts, + isFull: false, + currentIndex: 0, + numAttempts: make([]int, maxHistogramSize), } + return sa } -func (sa *sendAttempts) SubmitProbeAttempt(a int) { +// SubmitProbeAttempt feeds the number of attempts it took to send a cMix +// message into the tracker and updates the optimal number of attempts. +func (sa *sendAttempts) SubmitProbeAttempt(numAttemptsUntilSuccessful int) { sa.lock.Lock() defer sa.lock.Unlock() - sa.numAttempts[sa.currentIndex] = a - sa.currentIndex += 1 + sa.numAttempts[sa.currentIndex] = numAttemptsUntilSuccessful + sa.currentIndex++ + if sa.currentIndex == len(sa.numAttempts) { sa.currentIndex = 0 sa.isFull = true @@ -52,16 +69,19 @@ func (sa *sendAttempts) SubmitProbeAttempt(a int) { sa.computeOptimalUnsafe() } +// GetOptimalNumAttempts returns the number of optimal sends. If there is +// insufficient data to calculate, then ready is false. func (sa *sendAttempts) GetOptimalNumAttempts() (attempts int, ready bool) { optimalAttempts := atomic.LoadInt32(sa.optimalAttempts) - if optimalAttempts == -1 { + if optimalAttempts == optimalAttemptsInitValue { return 0, false } return int(optimalAttempts), true } +// computeOptimalUnsafe updates the optimal send attempts. func (sa *sendAttempts) computeOptimalUnsafe() { toCopy := maxHistogramSize if !sa.isFull { @@ -69,16 +89,28 @@ func (sa *sendAttempts) computeOptimalUnsafe() { return } toCopy = sa.currentIndex - } - histoCopy := make([]int, toCopy) - copy(histoCopy, sa.numAttempts[:toCopy]) - - sort.Slice(histoCopy, func(i, j int) bool { - return histoCopy[i] < histoCopy[j] - }) + histogramCopy := make([]int, toCopy) + copy(histogramCopy, sa.numAttempts[:toCopy]) + sort.Ints(histogramCopy) - optimal := histoCopy[((toCopy*percentileNumerator)+percentileDenominatorOffset)/percentileDenominator] + i := ((toCopy * percentileNumerator) + percentileDenominatorOffset) / + percentileDenominator + optimal := histogramCopy[i] atomic.StoreInt32(sa.optimalAttempts, int32(optimal)) } + +// String prints the values in the sendAttempts in a human-readable form for +// debugging and logging purposes. This function adheres to the fmt.Stringer +// interface. +func (sa *sendAttempts) String() string { + fields := []string{ + "optimalAttempts:" + strconv.Itoa(int(atomic.LoadInt32(sa.optimalAttempts))), + "isFull:" + strconv.FormatBool(sa.isFull), + "currentIndex:" + strconv.Itoa(sa.currentIndex), + "numAttempts:" + fmt.Sprintf("%d", sa.numAttempts), + } + + return "{" + strings.Join(fields, " ") + "}" +} diff --git a/cmix/attempts/histrogram_test.go b/cmix/attempts/histrogram_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6934b1f332e44cd17dd5cf2030932a8ec364fbd3 --- /dev/null +++ b/cmix/attempts/histrogram_test.go @@ -0,0 +1,89 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +package attempts + +import ( + "math/rand" + "reflect" + "testing" +) + +// Tests that NewSendAttempts returns a new sendAttempts with the expected +// fields. +func TestNewSendAttempts(t *testing.T) { + optimalAttempts := int32(optimalAttemptsInitValue) + expected := &sendAttempts{ + optimalAttempts: &optimalAttempts, + isFull: false, + currentIndex: 0, + numAttempts: make([]int, maxHistogramSize), + } + + sa := NewSendAttempts() + + if !reflect.DeepEqual(expected, sa) { + t.Errorf("New SendAttemptTracker does not match expected."+ + "\nexpected: %+v\nreceivedL %+v", expected, sa) + } +} + +// Tests that sendAttempts.SubmitProbeAttempt properly increments and stores the +// attempts. +func Test_sendAttempts_SubmitProbeAttempt(t *testing.T) { + sa := NewSendAttempts().(*sendAttempts) + + for i := 0; i < maxHistogramSize+20; i++ { + sa.SubmitProbeAttempt(i) + + if sa.currentIndex != (i+1)%maxHistogramSize { + t.Errorf("Incorrect currentIndex (%d).\nexpected: %d\nreceived: %d", + i, (i+1)%maxHistogramSize, sa.currentIndex) + } else if sa.numAttempts[i%maxHistogramSize] != i { + t.Errorf("Incorrect numAttempts at %d.\nexpected: %d\nreceived: %d", + i, i, sa.numAttempts[i%maxHistogramSize]) + } else if i > maxHistogramSize && !sa.isFull { + t.Errorf("Should be marked full when numAttempts > %d.", + maxHistogramSize) + } + } +} + +// Tests sendAttempts.GetOptimalNumAttempts returns numbers close to 70% of the +// average of attempts feeding in. +func Test_sendAttempts_GetOptimalNumAttempts(t *testing.T) { + prng := rand.New(rand.NewSource(42)) + sa := NewSendAttempts().(*sendAttempts) + + attempts, ready := sa.GetOptimalNumAttempts() + if ready { + t.Errorf("Marked ready when no attempts have been made.") + } else if attempts != 0 { + t.Errorf("Incorrect number of attempt.\nexpected: %d\nreceived: %d", + 0, attempts) + } + + const n = 100 + factor := (n * 7) / 10 + for i := 0; i < 500; i++ { + sa.SubmitProbeAttempt(prng.Intn(n)) + attempts, ready = sa.GetOptimalNumAttempts() + + if (sa.currentIndex < minElements && !sa.isFull) && ready { + t.Errorf("Ready when less than %d attempts made (%d).", + minElements, i) + } else if sa.currentIndex >= minElements { + if !ready { + t.Errorf("Not ready when more than %d attempts made (%d).", + minElements, i) + } else if attempts < factor-25 || attempts > factor+25 { + t.Errorf("Attempts is not close to average (%d)."+ + "\naverage: %d\nattempts: %d", i, factor, attempts) + } + } + } +}