-
Notifications
You must be signed in to change notification settings - Fork 309
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 2 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,111 @@ | ||
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: | ||
- advanceUnlocked method uses in-place filtering of timers | ||
instead of a full copy on every remove. | ||
Since we have 100s of timers in our tests current linear | ||
the complexity of this operation is OK | ||
If going to have 1000s in the future, we can use heap to store timers. | ||
- advanceUnlocked method yields the processor, after every timer triggering, | ||
allowing other goroutines to run | ||
*/ | ||
|
||
import ( | ||
"runtime" | ||
"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, func()) { | ||
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) | ||
return ch, func() bool { return true }, tt.advanceToTimer | ||
} | ||
|
||
// advance advances the fake time. | ||
func (tt *testTime) advance(dur time.Duration) { | ||
tt.mu.Lock() | ||
defer tt.mu.Unlock() | ||
tt.advanceUnlocked(dur) | ||
} | ||
|
||
// advanceUnlock advances the fake time, assuming it is already locked. | ||
func (tt *testTime) advanceUnlocked(dur time.Duration) { | ||
tt.cur = tt.cur.Add(dur) | ||
|
||
i := 0 | ||
j := 0 | ||
for i < len(tt.timers) { | ||
if tt.timers[i].when.After(tt.cur) { | ||
if i != j { | ||
tt.timers[j] = tt.timers[i] | ||
} | ||
i++ | ||
j++ | ||
} else { | ||
tt.timers[i].ch <- tt.cur | ||
for i := 0; i < 16; i++ { | ||
runtime.Gosched() | ||
} | ||
rabbbit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
i++ | ||
} | ||
} | ||
tt.timers = tt.timers[0:j] | ||
} | ||
|
||
// advanceToTimer advances the time to the next timer. | ||
func (tt *testTime) advanceToTimer() { | ||
tt.mu.Lock() | ||
defer tt.mu.Unlock() | ||
if len(tt.timers) == 0 { | ||
panic("no timer") | ||
} | ||
when := tt.timers[0].when | ||
for _, timer := range tt.timers[1:] { | ||
if timer.when.Before(when) { | ||
when = timer.when | ||
} | ||
} | ||
tt.advanceUnlocked(when.Sub(tt.cur)) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,10 +7,18 @@ import ( | |
|
||
"go.uber.org/atomic" | ||
|
||
"github.com/benbjohnson/clock" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
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 | ||
|
@@ -27,7 +35,7 @@ type testRunner interface { | |
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. | ||
|
@@ -64,21 +72,28 @@ 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) | ||
go func() { | ||
move := func() { | ||
defer func() { | ||
_ = recover() | ||
time.Sleep(10 * time.Millisecond) | ||
}() | ||
r.clock.advanceToTimer() | ||
} | ||
for { | ||
move() | ||
} | ||
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. Sorry, but I think this needs a much larger explanation - perhaps as comments in code? Am I reading this right that this is an infinite loop, trying to move to infinitely move to the next timer ... by recovering from a panic? Irrespective of goroutine leaks, using panics for signaling - do you think the 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.
I think 1 goroutine leak per test case is not a big deal, but if you want, I can add some exit logic here.
Code from Ian just panics when you try to run the next timer, and there is nothing to run. We can potentially change that behavior to not handle panics, but I wanted to limit changes in that code.
I'll write some description here first and when we clarify all questions about it, I'll move it into code comments:
If testTime.advanceToTimer() panics, it means that there are no timers to run, and it can happen if the test is already finished or it didn't manage to set up the full test case, so we just sleep a bit to allow and try again to run timers.
No, we can use that if we want to get out of the currently infinite loop, we can check on every iteration if testTime is already passed maxDuration.
Yes, but since we advance time in different goroutines in this approach, it is necessary for us to do it one step at a time and give other goroutines the opportunity to make their steps as well. There also can be situations where we start to advance time in this goroutine before test goroutines spawned all permission consumers and asserting goroutines, so we need to just wait for everything to setup, that is what we do when panic happens. 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. So perhaps naively, but do we need both
Our test runner knows exactly how much time needs to move ( And soit seem to me that I understand this kinda modifies the code we copy-paste, but I'd feel much more comfortable with we decreased the API by 50% here. 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. Right now, it is hard for me to make changes because tests do not pass until we merge #95 I agree with the comments and changes that you propose right now, but they don't change the approach in general. 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. Merged #95. I might still want to re-review it later on, it feels like some simplification is possible. 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. I'll update this branch with new version and this should potentially fix all tests, event though I'm not sure how your new test for initial calls will work 🤞 |
||
}() | ||
}) | ||
} | ||
} | ||
|
@@ -96,6 +111,7 @@ func (r *runnerImpl) startTaking(rls ...Limiter) { | |
for _, rl := range rls { | ||
rl.Take() | ||
} | ||
r.clock.advance(time.Nanosecond) | ||
r.count.Inc() | ||
select { | ||
case <-r.doneCh: | ||
|
@@ -120,14 +136,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() | ||
}) | ||
} | ||
|
||
|
@@ -143,6 +159,7 @@ func (r *runnerImpl) goWait(fn func()) { | |
} | ||
|
||
func TestUnlimited(t *testing.T) { | ||
t.Parallel() | ||
now := time.Now() | ||
rl := NewUnlimited() | ||
for i := 0; i < 1000; i++ { | ||
|
@@ -152,6 +169,7 @@ func TestUnlimited(t *testing.T) { | |
} | ||
|
||
func TestRateLimiter(t *testing.T) { | ||
t.Parallel() | ||
rabbbit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
runTest(t, func(r testRunner) { | ||
rl := r.createLimiter(100, WithoutSlack) | ||
|
||
|
@@ -168,6 +186,7 @@ func TestRateLimiter(t *testing.T) { | |
} | ||
|
||
func TestDelayedRateLimiter(t *testing.T) { | ||
t.Parallel() | ||
runTest(t, func(r testRunner) { | ||
slow := r.createLimiter(10, WithoutSlack) | ||
fast := r.createLimiter(100, WithoutSlack) | ||
|
@@ -186,6 +205,7 @@ func TestDelayedRateLimiter(t *testing.T) { | |
} | ||
|
||
func TestPer(t *testing.T) { | ||
t.Parallel() | ||
runTest(t, func(r testRunner) { | ||
rl := r.createLimiter(7, WithoutSlack, Per(time.Minute)) | ||
|
||
|
@@ -199,6 +219,7 @@ func TestPer(t *testing.T) { | |
} | ||
|
||
func TestSlack(t *testing.T) { | ||
t.Parallel() | ||
// To simulate slack, we combine two limiters. | ||
// - First, we start a single goroutine with both of them, | ||
// during this time the slow limiter will dominate, | ||
|
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.
is the goal of in-place filtering just performance of the fake-clock?
I don't think this feels very important (?) - I'd rather have simpler code that's slightly slower?
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.
For me, this approach looks simpler as well as more performant, but check the original and give me your opinion, if you think we should keep the original, I don't mind.
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.
It's just that the whole
i
,j
looks like we would need tests for tests (off by one errors).I think I would rather pull the other version as is, and then push a separate PR to fix the copying with clear changes. If we worried about the performance though, wouldn't we want to sort once on insert, rather than
advance
?But:
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.
OK, I can go back to the prev version.