-
Notifications
You must be signed in to change notification settings - Fork 304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix test approach for detecting issues #93
base: main
Are you sure you want to change the base?
Changes from all commits
1053ca8
abdb9ce
c6476cc
47a6978
99e7b2c
86916e3
260ee11
4e9eb9d
6ca7158
41e59b7
e2a95a9
31dcb6c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -53,4 +53,4 @@ staticcheck: bin/staticcheck | |
|
||
.PHONY: test | ||
test: | ||
go test -race ./... | ||
go test -v -race ./... |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
package ratelimit | ||
|
||
/** | ||
This fake time implementation is a modification of time mocking | ||
the mechanism used by Ian Lance Taylor in https://github.com/golang/time project | ||
https://github.com/golang/time/commit/579cf78fd858857c0d766e0d63eb2b0ccf29f436 | ||
|
||
Modified parts: | ||
- timers are sorted on every addition, and then we relly of that order, | ||
we could use heap data structure, but sorting is OK for now. | ||
- advance accepts backoffDuration to sleep without lock held after every timer triggering | ||
- advanceUnlocked method yields the processor, after every timer triggering, | ||
allowing other goroutines to run | ||
*/ | ||
|
||
import ( | ||
"runtime" | ||
"sort" | ||
"sync" | ||
"time" | ||
) | ||
|
||
// testTime is a fake time used for testing. | ||
type testTime struct { | ||
mu sync.Mutex | ||
cur time.Time // current fake time | ||
timers []testTimer // fake timers | ||
} | ||
|
||
// makeTestTime hooks the testTimer into the package. | ||
func makeTestTime() *testTime { | ||
return &testTime{ | ||
cur: time.Now(), | ||
} | ||
} | ||
|
||
// testTimer is a fake timer. | ||
type testTimer struct { | ||
when time.Time | ||
ch chan<- time.Time | ||
} | ||
|
||
// now returns the current fake time. | ||
func (tt *testTime) now() time.Time { | ||
tt.mu.Lock() | ||
defer tt.mu.Unlock() | ||
return tt.cur | ||
} | ||
|
||
// newTimer creates a fake timer. It returns the channel, | ||
// a function to stop the timer (which we don't care about), | ||
// and a function to advance to the next timer. | ||
func (tt *testTime) newTimer(dur time.Duration) (<-chan time.Time, func() bool) { | ||
tt.mu.Lock() | ||
defer tt.mu.Unlock() | ||
ch := make(chan time.Time, 1) | ||
timer := testTimer{ | ||
when: tt.cur.Add(dur), | ||
ch: ch, | ||
} | ||
tt.timers = append(tt.timers, timer) | ||
sort.Slice(tt.timers, func(i, j int) bool { | ||
return tt.timers[i].when.Before(tt.timers[j].when) | ||
}) | ||
return ch, func() bool { return true } | ||
} | ||
|
||
// advance advances the fake time. | ||
func (tt *testTime) advance(dur time.Duration, backoffDuration time.Duration) { | ||
tt.mu.Lock() | ||
defer tt.mu.Unlock() | ||
|
||
targetTime := tt.cur.Add(dur) | ||
for { | ||
if len(tt.timers) == 0 || tt.timers[0].when.After(targetTime) { | ||
tt.cur = targetTime | ||
return | ||
} | ||
if tt.advanceUnlocked(tt.timers[0].when.Sub(tt.cur)) && backoffDuration > 0 { | ||
// after every timer triggering, we release our mutex | ||
// and give time for other goroutines to run | ||
tt.mu.Unlock() | ||
time.Sleep(backoffDuration) | ||
tt.mu.Lock() | ||
} | ||
} | ||
} | ||
|
||
// advanceUnlock advances the fake time, assuming it is already locked. | ||
func (tt *testTime) advanceUnlocked(dur time.Duration) bool { | ||
tt.cur = tt.cur.Add(dur) | ||
if len(tt.timers) == 0 || tt.timers[0].when.After(tt.cur) { | ||
return false | ||
} | ||
|
||
i := 0 | ||
for i < len(tt.timers) { | ||
if tt.timers[i].when.After(tt.cur) { | ||
break | ||
} | ||
tt.timers[i].ch <- tt.cur | ||
i++ | ||
// calculate how many goroutines we currently have in runtime | ||
// and yield the processor, after every timer triggering, | ||
// allowing all other goroutines to run | ||
numOfAllRunningGoroutines := runtime.NumGoroutine() | ||
for j := 0; j < numOfAllRunningGoroutines; j++ { | ||
runtime.Gosched() | ||
} | ||
} | ||
|
||
tt.timers = tt.timers[i:] | ||
return true | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,10 +7,20 @@ import ( | |
|
||
"go.uber.org/atomic" | ||
|
||
"github.com/benbjohnson/clock" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
const advanceBackoffDuration = 5 * time.Millisecond | ||
|
||
func (tt *testTime) Now() time.Time { | ||
rabbbit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return tt.now() | ||
} | ||
|
||
func (tt *testTime) Sleep(duration time.Duration) { | ||
timer, _ := tt.newTimer(duration) | ||
<-timer | ||
} | ||
|
||
type testRunner interface { | ||
// createLimiter builds a limiter with given options. | ||
createLimiter(int, ...Option) Limiter | ||
|
@@ -23,13 +33,13 @@ type testRunner interface { | |
// not using clock.AfterFunc because andres-erbsen/clock misses a nap there. | ||
afterFunc(d time.Duration, fn func()) | ||
// some tests want raw access to the clock. | ||
getClock() *clock.Mock | ||
getClock() *testTime | ||
} | ||
|
||
type runnerImpl struct { | ||
t *testing.T | ||
|
||
clock *clock.Mock | ||
clock *testTime | ||
constructor func(int, ...Option) Limiter | ||
count atomic.Int32 | ||
// maxDuration is the time we need to move into the future for a test. | ||
|
@@ -66,21 +76,17 @@ func runTest(t *testing.T, fn func(testRunner)) { | |
|
||
for _, tt := range impls { | ||
t.Run(tt.name, func(t *testing.T) { | ||
// Set a non-default time.Time since some limiters (int64 in particular) use | ||
// the default value as "non-initialized" state. | ||
clockMock := clock.NewMock() | ||
clockMock.Set(time.Now()) | ||
r := runnerImpl{ | ||
t: t, | ||
clock: clockMock, | ||
clock: makeTestTime(), | ||
constructor: tt.constructor, | ||
doneCh: make(chan struct{}), | ||
} | ||
defer close(r.doneCh) | ||
defer r.wg.Wait() | ||
|
||
fn(&r) | ||
r.clock.Add(r.maxDuration) | ||
r.clock.advance(r.maxDuration, advanceBackoffDuration) | ||
}) | ||
} | ||
} | ||
|
@@ -91,7 +97,7 @@ func (r *runnerImpl) createLimiter(rate int, opts ...Option) Limiter { | |
return r.constructor(rate, opts...) | ||
} | ||
|
||
func (r *runnerImpl) getClock() *clock.Mock { | ||
func (r *runnerImpl) getClock() *testTime { | ||
return r.clock | ||
} | ||
|
||
|
@@ -102,6 +108,7 @@ func (r *runnerImpl) startTaking(rls ...Limiter) { | |
for _, rl := range rls { | ||
rl.Take() | ||
} | ||
r.clock.advance(time.Nanosecond, 0) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and we need this one as well? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this one imitates time progressions between |
||
r.count.Inc() | ||
select { | ||
case <-r.doneCh: | ||
|
@@ -126,14 +133,14 @@ func (r *runnerImpl) afterFunc(d time.Duration, fn func()) { | |
if d > r.maxDuration { | ||
r.maxDuration = d | ||
} | ||
|
||
timer, _ := r.clock.newTimer(d) | ||
r.goWait(func() { | ||
select { | ||
case <-r.doneCh: | ||
return | ||
case <-r.clock.After(d): | ||
case <-timer: | ||
fn() | ||
} | ||
fn() | ||
}) | ||
} | ||
|
||
|
@@ -237,17 +244,17 @@ func TestInitial(t *testing.T) { | |
have []time.Duration | ||
startWg sync.WaitGroup | ||
) | ||
startWg.Add(3) | ||
|
||
startWg.Add(3) | ||
for i := 0; i < 3; i++ { | ||
go func() { | ||
startWg.Done() | ||
results <- rl.Take() | ||
}() | ||
} | ||
|
||
startWg.Wait() | ||
clk.Add(time.Second) | ||
|
||
r.getClock().advance(time.Second, advanceBackoffDuration) | ||
|
||
for i := 0; i < 3; i++ { | ||
ts := <-results | ||
|
@@ -262,7 +269,7 @@ func TestInitial(t *testing.T) { | |
time.Millisecond * 100, | ||
}, | ||
have, | ||
"bad timestamps for inital takes", | ||
"bad timestamps for initial takes", | ||
) | ||
}) | ||
}) | ||
|
@@ -342,6 +349,7 @@ func TestSlack(t *testing.T) { | |
|
||
for _, tt := range tests { | ||
t.Run(tt.msg, func(t *testing.T) { | ||
t.Parallel() | ||
runTest(t, func(r testRunner) { | ||
slow := r.createLimiter(10, WithoutSlack) | ||
fast := r.createLimiter(100, tt.opt...) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you know why we need this? Is the
gosched
not enough?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can notice that
gosched
calls happen under lock. It means that all goroutines that would like to get the current time from this clock or register some additional timer won't be able to do so, even if we run these gosched calls. In our case, it means that most "startTaking" goroutines will never work while we step through timers, but they need the most time resource in our tests run.So we actually need both types of pauses between runs. One is using
gosched
under lock for all goroutines that were waiting for the timer to click and are not blocked on mutex of this clock.The other one is for all goroutines that are blocked on mutex of this clock and need to make some progress between timer advances.