-
Notifications
You must be signed in to change notification settings - Fork 774
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
Refactor rate converter separating scheduler from converter logic to improve testability #1394
Conversation
clock/clock.go
Outdated
"time" | ||
) | ||
|
||
type Clock interface { |
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.
I have a few recommendations:
-
The code in this PR is only using the
Now
method. I recommend removing theSince
andUntil
methods from the interface to keep things simple. -
Since we're used to seeing
time.Now()
, consider a name closer to that.. such as theTime
interface. -
Consider moving this code to the
util
package. The packages with 'top billing' help describe the main functions of the app. I don't think this warrants a visible top level billing.
... after those changes, I don't think we need to credit the glock repo.
clock/mock_clock.go
Outdated
@@ -0,0 +1,69 @@ | |||
// parts copied from: https://github.com/efritz/glock | |||
|
|||
package clock |
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.
I think we can remove this mock entirely. It's a simple enough interface to create a simple 'static clock' or some such where it's needed. The use of a mutex and the mock tests is overkill for our use case, IMHO.
currencies/rate_converter.go
Outdated
updateNotifier chan<- int | ||
fetchingInterval time.Duration | ||
staleRatesThreshold time.Duration | ||
syncSourceURL string | ||
rates atomic.Value // Should only hold Rates struct | ||
lastUpdated atomic.Value // Should only hold time.Time | ||
constantRates Conversions | ||
pbsClock clock.Clock |
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.
There's no need to prefix with pbs. A simple name like clock
would be preferred.
currencies/rate_converter.go
Outdated
) *RateConverter { | ||
return NewRateConverterWithNotifier( | ||
httpClient, | ||
syncSourceURL, | ||
fetchingInterval, | ||
staleRatesThreshold, | ||
pbsClock, |
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.
I think you can hardcode a RealClock here. We only really need to inject a fake clock for the unit tests.
currencies/rate_converter.go
Outdated
updateNotifier: updateNotifier, | ||
fetchingInterval: fetchingInterval, | ||
staleRatesThreshold: staleRatesThreshold, | ||
syncSourceURL: syncSourceURL, | ||
rates: atomic.Value{}, | ||
lastUpdated: atomic.Value{}, | ||
constantRates: NewConstantRates(), | ||
pbsClock: pbsClock, |
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.
There's no need to prefix with pbs. A simple name like clock would be preferred.
currencies/ticker_task.go
Outdated
case <-tt.done: | ||
if ticker != nil { | ||
ticker.Stop() | ||
ticker = nil |
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.
There's no need to set the ticker to nil. Once the loop ends, the ticker is out of scope.
currencies/ticker_task_test.go
Outdated
func TestStop(t *testing.T) { | ||
// Setup: | ||
calledURLs := []string{} | ||
mockedHttpServer := httptest.NewServer(http.HandlerFunc( |
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.
Now that this is only testing TickerTask, I recommend removing everything related to the currency converter to keep the setup small.
currencies/ticker_task_test.go
Outdated
// Execute: | ||
expectedTicks := 2 | ||
ticks := make(chan int) | ||
fetchingInterval := time.Duration(100) * time.Millisecond |
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.
Yeah, I agree there's no way to remove the dependence on the real system clock without also injecting a mock timer. If we have to keep with this approach, please use a much shorter interval. 100ms is way to long for a unit test IMHO, where 1ms should be fine. Similarly, the 1 second sleep is extremely too long for a unit test.
Stopping after the second tick seems fine. Then you sleep for another 2ms perhaps to ensure the timer has stopped and assert the count is still 2.
exchange/bidder_test.go
Outdated
) | ||
time.Sleep(time.Duration(500) * time.Millisecond) | ||
currencyConverterTickerTask := currencies.NewTickerTask(time.Duration(10)*time.Second, currencyConverter) |
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.
What is this test testing? I don't see why it needs a timer running.
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.
This test is verifying the requestBid
function when provided with valid rate conversions. We don't need to run the timer here. We can set up our conversions by creating a new rate converter, call the converter method Run()
once so that the mocked rates are stored in the converter state and then calling Rates()
on the converter to retrieve the conversions to be passed into requestBid
.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
currencies/rate_converter.go
Outdated
fetchingInterval time.Duration | ||
staleRatesThreshold time.Duration | ||
syncSourceURL string | ||
rates atomic.Value // Should only hold Rates struct | ||
lastUpdated atomic.Value // Should only hold time.Time | ||
constantRates Conversions | ||
clock timeutil.Time |
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.
Consider renaming clock
to time
to match.
currencies/rate_converter.go
Outdated
@@ -55,30 +59,20 @@ func NewRateConverterWithNotifier( | |||
syncSourceURL string, | |||
fetchingInterval time.Duration, | |||
staleRatesThreshold time.Duration, | |||
clock timeutil.Time, |
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.
Consider renaming clock to time to match.
currencies/rate_converter.go
Outdated
@@ -93,6 +87,10 @@ func (rc *RateConverter) fetch() (*Rates, error) { | |||
return nil, err | |||
} | |||
|
|||
if response.StatusCode >= 400 { | |||
return nil, &errortypes.BadServerResponse{Message: "error"} |
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.
Let's get a bit more descriptive than "error". Possibly include the status code.
currencies/ticker_task.go
Outdated
@@ -0,0 +1,61 @@ | |||
package currencies |
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.
Let's move this to the util\task
package or something like that.
currencies/ticker_task.go
Outdated
|
||
type Runner interface { | ||
Run() error | ||
Notify() |
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.
Let's delete the notifier. Looks like it's just used for tests.
util/timeutil/real_clock.go
Outdated
return time.Now() | ||
} | ||
|
||
func (c *RealClock) Sleep(duration time.Duration) { |
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.
No longer used. Please remove.
currencies/rate_converter_test.go
Outdated
) | ||
|
||
defer mockedHttpServer.Close() | ||
type MockClock struct { |
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.
Consider naming this FakeClock
since it doesn't have any mocking behavior and change fakeTime
to just time
.
currencies/ticker_task_test.go
Outdated
} | ||
|
||
func (mcc *MockRunner) Run() error { return nil } | ||
func (mcc *MockRunner) Notify() { mcc.runCount++; mcc.updateNotifier <- mcc.runCount } |
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.
Let's remove Notify
and have the Run
method increase the count for the test to inspect.
currencies/ticker_task_test.go
Outdated
// Setup: | ||
ticks := make(chan int) | ||
mockRunner := &MockRunner{updateNotifier: ticks} | ||
interval := time.Duration(1) * time.Millisecond |
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.
Can this be simplified as 1 * time.Millisecond
.
currencies/ticker_task_test.go
Outdated
|
||
// Execute: | ||
interval := time.Duration(0) | ||
currencyConverter := currencies.NewRateConverter( |
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.
No need to use the NewRateConverter
in this test.
currencies/rate_converter.go
Outdated
func NewRateConverterWithNotifier( | ||
// NewConfiguredRateConverter returns a new RateConverter | ||
// Do not call this method directly; it is to improve testability only | ||
func NewConfiguredRateConverter( |
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 we need the separate constructor? The purpose for the time dependency is to enable unit tests. The unit tests can construct a rate converter normally and then change the time to a mocked version since the tests are part of the same package.. or just omit the constructor entirely and directly set the struct for testing.
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.
That's a good point. I was trying to keep time
private but you're right, we only added it to enable UTs so I should just make it public and then we can overwrite it in tests.
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 don't need to make it public for the unit tests. Access modifiers in Go are only at the package level and unit tests run within the same package.
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.
Yeah I could make it private but I wanted to continue following the practice of testing the module from a _test
package to ensure we are only touching the API and publicly accessible fields.
util/task/ticker_task.go
Outdated
|
||
// run creates a ticker that ticks at the specified interval. On each tick, | ||
// the task is executed | ||
func (t *TickerTask) run() { |
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.
Consider renaming to runRecurring
to better differentiate from the t.runner.Run()
call.
|
||
// Verify rates are valid and last update ts is correct | ||
assert.Equal(t, expectedRates, currencyConverter.Rates(), "Conversions.Rates weren't the expected ones") | ||
assert.Equal(t, initialFakeTime, currencyConverter.LastUpdated(), "LastUpdated should be set") | ||
} | ||
|
||
func TestRace(t *testing.T) { |
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.
I guess we can leave the logic alone. I don't follow it's design. 50 clients seems like a lot and the random intervals only decrease the change of a race condition and lower the effectiveness of the test. Since order of operations will be impossible to predict here, this looks like a stub for Go's memory race detector to report on unprotected access.
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.
Yeah I don't understand it either. I took a look at the issue that resulted in this test but I still hesitated to edit this because I was afraid I would be removing coverage of some edge case I'm not seeing. I'll take a stab at writing a simpler race test given it seems overcomplicated to both of us.
// Setup: | ||
runner := &MockRunner{RunCount: 0} | ||
interval := 0 * time.Millisecond | ||
ticker := task.NewTickerTask(interval, runner) |
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.
TestStop
does make a ticker.Stop()
call. Are we missing that call here?
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.
We shouldn't need it here because when Start
is called, the interval is zero so the go routine that instantiates the ticker is never started. Since it never starts, there is no need to send a signal on the done
channel because we don't need to stop the ticker.
In TestStop
, we are testing the Stop
method which is why it is called. Also, in that test the go routine starts and the ticker is instantiated.
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.
This is perhaps a bit of overkill for the tests, but if we think it would prevent issues then you have my support.
That's a very good point though. We we properly handling graceful shutdowns? When PBS receives a quit signal from the OS, are we stopping the fetchers?
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.
I can open a story to ensure we are gracefully shutting down.
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.
I have seen the shutdown pattern in fetchers and I was thinking that since those goroutines maintain no external state to gracefully sync, are there any drawbacks to just letting them be terminated ? Their connections are going to be closed when the process ends anyway, and that seems to be the only time this happens. We don't reload configs, where this could be useful.
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.
Perhaps it might not be needed. We created an internal story to check if there's a need for a graceful shutdown.
|
||
// Execute: | ||
ticker.Start() | ||
time.Sleep(25 * time.Millisecond) |
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.
TestStop
does make a ticker.Stop()
call. Are we missing that call here?
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.
Adding Stop()
here makes sense as it would lead to more consistent test results. However, I think we can just combine this test with TestStop
since testing the stop method is dependent on a task running periodically.
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.
I vote to leave the tests separate. TestStartWithPeriodicRun
is validating an expected run count and adding Stop()
is just for cleanup. The TestStop
test has additional wait time to ensure the stop call actually worked. Leaving them separate should point us more clearly to a potential issue.
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.
👍 I'll leave the two tests separate and add Stop()
.
currencyConverter := currencies.NewRateConverter(&http.Client{}, cfg.CurrencyConverter.FetchURL, fetchingInterval, staleRatesThreshold) | ||
|
||
currencyConverterTickerTask := task.NewTickerTask(fetchingInterval, currencyConverter) | ||
currencyConverterTickerTask.Start() |
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.
I'm not sure if its necessary but, should we also add defer currencyConverterTickerTask.Stop()
?
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.
I don't think it's necessary; Stop()
is just closing a channel which halts the timer. I guess I don't see any risk in an extra tick or two occurring while in the process of a shutdown, especially since the ticker is just being used for rate conversions at this time.
From the golang docs: "Channels aren't like files; you don't usually need to close them. Closing is only necessary when the receiver must be told there are no more values coming, such as to terminate a range loop."
Also deferred calls don't run when the main go routine exits. I think we would need to use sync waitgroups for this sort of thing.
currencies/rate_converter.go
Outdated
// By default there will be no currencies conversions done. | ||
// `currencies.ConstantRate` will be used. | ||
func NewRateConverterDefault() *RateConverter { | ||
return NewRateConverter(&http.Client{}, "", time.Duration(0), time.Duration(0)) | ||
} |
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.
This function seems to not be used anywhere else but in _test.go
after your refactor. Do you think we can safely get rid of it?
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.
I don't think this function was ever used in production code. I deleted it and replaced the calls with calls to NewRateConverter
.
…age or needed for testing
Simplify clock interface and implementation Simplify variable names Further isolate ticker task TestStop test by using mock runner instead of rate converter instance
…ogic is task specific
No description provided.