Skip to content

Commit

Permalink
pacer: Introduce LinearPacer (#425)
Browse files Browse the repository at this point in the history
* pacer: Introduce LinearPacer

This commit introduces a LinearPacer which paces an attack by starting
at a given request rate and increasing linearly with the given slope.

This is a pre-requisite for implementing #418

* Update lib/pacer.go
  • Loading branch information
tsenart authored Jul 25, 2019
1 parent 9c95632 commit b5f4fca
Show file tree
Hide file tree
Showing 2 changed files with 201 additions and 0 deletions.
60 changes: 60 additions & 0 deletions lib/pacer.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,63 @@ func (sp SinePacer) hits(t time.Duration) float64 {
}
return sp.Mean.hitsPerNs()*float64(t) + sp.ampHits()*(math.Cos(sp.StartAt)-math.Cos(sp.radians(t)))
}

// LinearPacer paces an attack by starting at a given request rate
// and increasing linearly with the given slope.
type LinearPacer struct {
StartAt Rate
Slope float64
}

// Pace determines the length of time to sleep until the next hit is sent.
func (p LinearPacer) Pace(elapsed time.Duration, hits uint64) (time.Duration, bool) {
switch {
case p.StartAt.Per == 0 || p.StartAt.Freq == 0:
return 0, false // Zero value = infinite rate
case p.StartAt.Per < 0 || p.StartAt.Freq < 0:
return 0, true
}

expectedHits := p.hits(elapsed)
if hits == 0 || hits < uint64(expectedHits) {
// Running behind, send next hit immediately.
return 0, false
}

rate := p.rate(elapsed)
interval := math.Round(1e9 / rate)

if n := uint64(interval); n != 0 && math.MaxInt64/n < hits {
// We would overflow wait if we continued, so stop the attack.
return 0, true
}

delta := float64(hits+1) - expectedHits
wait := time.Duration(interval * delta)

return wait, false
}

// hits returns the number of hits that have been sent during an attack
// lasting t nanoseconds. It returns a float so we can tell exactly how
// much we've missed our target by when solving numerically in Pace.
func (p LinearPacer) hits(t time.Duration) float64 {
if t < 0 {
return 0
}

a := p.Slope
b := p.StartAt.hitsPerNs() * 1e9
x := t.Seconds()

return (a*math.Pow(x, 2))/2 + b*x
}

// rate calculates the instantaneous rate of attack at
// t nanoseconds after the attack began.
func (p LinearPacer) rate(t time.Duration) float64 {
a := p.Slope
x := t.Seconds()
b := p.StartAt.hitsPerNs() * 1e9
return a*x + b
}
141 changes: 141 additions & 0 deletions lib/pacer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package vegeta

import (
"math"
"strconv"
"testing"
"testing/quick"
"time"
)

Expand Down Expand Up @@ -224,3 +226,142 @@ func TestSinePacerPace_Flat(t *testing.T) {
}
}
}

func TestLinearPacer(t *testing.T) {
t.Parallel()

for ti, tt := range []struct {
freq int
per time.Duration
slope float64
elapsed time.Duration
hits uint64
wait time.Duration
stop bool
}{
// :-( HAPPY PATH TESTS WITH slope=0 :-)
// 1 hit/sec, 0 hits sent, 1s elapsed => 0s until next hit
// (time.Sleep will return immediately in this case)
{1, time.Second, 0, time.Second, 0, 0, false},
// 1 hit/sec, 0 hits sent, 2s elapsed => 0s (-1s) until next hit
// (time.Sleep will return immediately in this case)
{1, time.Second, 0, 2 * time.Second, 0, 0, false},
// 1 hit/sec, 1 hit sent, 1s elapsed => 1s until next hit
{1, time.Second, 0, time.Second, 1, time.Second, false},
// 1 hit/sec, 2 hits sent, 1s elapsed => 2s until next hit
{1, time.Second, 0, time.Second, 2, 2 * time.Second, false},
// 1 hit/sec, 10 hits sent, 1s elapsed => 10s until next hit
{1, time.Second, 0, time.Second, 10, 10 * time.Second, false},
// 1 hit/sec, 10 hits sent, 11s elapsed => 0s until next hit
{1, time.Second, 0, 11 * time.Second, 10, 0, false},
// 2 hit/sec, 9 hits sent, 4.9s elapsed => 100ms until next hit
{2, time.Second, 0, (49 * time.Second) / 10, 9, 100 * time.Millisecond, false},

// :-( HAPPY PATH TESTS WITH slope > 0 :-)
{1, time.Second, 1, 0, 0, 0, false},
{1, time.Second, 1, time.Second, 0, 0, false}, // Running behind, no wait
{1, time.Second, 1, 1 * time.Second, 1, 250 * time.Millisecond, false},
{1, time.Second, 1, 2 * time.Second, 3, 0, false},
{1, time.Second, 1, 3 * time.Second, 6, 0, false},
{1, time.Second, 1, 4 * time.Second, 11, 0, false},
{1, time.Second, 1, 5 * time.Second, 16, 0, false},
{1, time.Second, 1, 6 * time.Second, 23, 0, false},

// :-( SAD PATH TESTS :-(
// Zero frequency.
{0, time.Second, 0, time.Second, 0, 0, false},
// Zero per.
{1, 0, 0, time.Second, 0, 0, false},
// Zero frequency + per.
{0, 0, 0, time.Second, 0, 0, false},
// Negative frequency.
{-1, time.Second, 0, time.Second, 0, 0, true},
// Negative per.
{1, -time.Second, 0, time.Second, 0, 0, true},
// Negative frequency + per.
{-1, -time.Second, 0, time.Second, 0, 0, true},
// Large per, overflow int64.
{1, time.Duration(math.MaxInt64) / 10, 0, time.Duration(math.MaxInt64), 11, 0, true},
// Large hits, overflow int64.
{1, time.Hour, 0, time.Duration(math.MaxInt64), 2562048, 0, true},
} {
t.Run(strconv.Itoa(ti), func(t *testing.T) {
p := LinearPacer{
StartAt: Rate{Freq: tt.freq, Per: tt.per},
Slope: tt.slope,
}

wait, stop := p.Pace(tt.elapsed, tt.hits)
if !durationEqual(wait, tt.wait) || stop != tt.stop {
t.Errorf("%d: %+v.Pace(%s, %d) = (%s, %t); want (%s, %t)",
ti, p, tt.elapsed, tt.hits, wait, stop, tt.wait, tt.stop)
}
})
}
}

func TestLinearPacer_hits(t *testing.T) {
p := LinearPacer{
StartAt: Rate{Freq: 100, Per: time.Second},
Slope: 10,
}

for _, tc := range []struct {
elapsed time.Duration
hits float64
}{
{0, 0},
{time.Second / 2, 51.25},
{1 * time.Second, 105},
{2 * time.Second, 220},
{4 * time.Second, 480},
{8 * time.Second, 1120},
{16 * time.Second, 2880},
{32 * time.Second, 8320},
{64 * time.Second, 26880},
{128 * time.Second, 94720},
} {
hits := p.hits(tc.elapsed)
if have, want := hits, tc.hits; !floatEqual(have, want) {
t.Errorf("%+v.hits(%v) = %v, want: %v", p, tc.elapsed, have, want)
}
}
}

func TestLinearPacer_rate(t *testing.T) {
prop := func(start uint16, slope int8, x1, x2 uint32) (ok bool) {
p := LinearPacer{
StartAt: Rate{Freq: int(start), Per: time.Second},
Slope: float64(slope),
}

if x1 > x2 {
x1, x2 = x2, x1
}

y1, y2 := p.rate(time.Duration(x1)), p.rate(time.Duration(x2))
direction := y2 - y1

switch {
case slope == 0 || x1 == x2:
ok = direction == 0 && y1 == y2 && floatEqual(y1, float64(start))
case slope > 0:
ok = direction > 0 && y1 >= float64(start) && y2 >= float64(start)
case slope < 0:
ok = direction < 0 && y1 <= float64(start) && y2 <= float64(start)
default:
panic("impossible condition")
}

if !ok {
t.Logf("\nslope: %d\nstart: %d\nrate(%v) = %v\nrate(%v) = %v", slope, start, x1, y1, x2, y2)
t.Fatalf("rate(%v) - rate(%v) = %v doesn't match slope direction %v", x2, x1, direction, slope)
}

return ok
}

if err := quick.Check(prop, &quick.Config{MaxCount: 1e6}); err != nil {
t.Fatal(err)
}
}

0 comments on commit b5f4fca

Please sign in to comment.