-
Notifications
You must be signed in to change notification settings - Fork 0
Conversation
Let PropER try to shrink, give common-tests assertions instead of failures that will crash when trying to print, have all properties run the same number of tests.
Welcome to Codecov 🎉Once merged to your default branch, Codecov will compare your coverage reports and display the results in this comment. Thanks for integrating Codecov - We've got you covered ☂️ |
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 took a quick look and spotted issues. Since I think it is quite easy to find problems in this code, I think it needs unit tests. The logic in the update
function should be IMO extracted to a pure function (to make it independent from system clock) and unit-tested carefully. It is easy to just write a few basic unit tests, and I could literally come up with failing tests in a minute.
src/opuntia.erl
Outdated
new({MaximumTokens, Rate, TimeUnit}) | ||
when ?NON_NEG_INT(MaximumTokens), ?NON_NEG_INT(Rate), ?TU(TimeUnit) -> | ||
#token_bucket_shaper{shape = {MaximumTokens, Rate, TimeUnit}, | ||
available_tokens = 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 rings an alarm bell instantly, so I tested it:
23> f(Sh),Sh=opuntia:new({100000,10000,second}),opuntia:update(Sh,0).
{{token_bucket_shaper,{100000,10000,second},0,-576460334},0}
24> f(Sh),Sh=opuntia:new({100000,10000,second}),opuntia:update(Sh,1).
{{token_bucket_shaper,{100000,10000,second},0,-576460099},1}
And I was right - the delay kicks in instantly, and you need a second to unblock.
So to continue my review I needed to work around it:
available_tokens = Rate,
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.
The idea is that this wasn't necessarily wrong but whether you want the shapers to start charged or uncharged, it's a matter of deciding the API. Easy to change, only tests need to be adjusted.
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.
Charging them was just a workaround. the main issue here is that if unit is second
, then for up to a second I cannot use any tokens even though e.g. after 500 ms I should have the bucket halfway charged. So the unit starts acting like the counter resolution, which IMO shouldn't be the case.
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.
Hmm... good point, but, that's more or less the contract too and why I separated bucket size from the rate. In a way that's what we had implicitly in MongooseIM, we had for example 60k tokens (bytes) per second but recharging was happening in milliseconds resolution, up to a maximum of 60k tokens regardless of how much time it had passed.
Also, the delay should always be given in milliseconds, not in the resolution you requested, all BEAM timers work in milliseconds so that's why I chose that.
src/opuntia.erl
Outdated
RoundedDelay = ceil(MaybeDelay), | ||
|
||
NewShaper = Shaper#token_bucket_shaper{available_tokens = TokensAvailable, | ||
last_update = Now + RoundedDelay + 1}, |
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.
Oh, this looks incorrect instantly. You need to store the accumulated fraction, because otherwise it would block you right away. Let's see:
8> lists:foldl(fun(N, ShIn) -> {ShOut, 0} = opuntia:update(ShIn, N), ShOut end, opuntia:new({100000,10000,second}), [1,1]).
** exception error: no match of right hand side value {#token_bucket_shaper{shape = {100000,10000,second},
available_tokens = 0,last_update = -576460643},
1}
This also breaks for millisecond
because the artificial penalty of 1 is still too much:
31> lists:foldl(fun(N, ShIn) -> {ShOut, 0} = opuntia:update(ShIn, N), ShOut end, opuntia:new({100000,10000,millisecond}), [1,1]).
** exception error: no match of right hand side value {#token_bucket_shaper{shape = {100000,10000,millisecond},
available_tokens = 0,last_update = -576460202884},
1}
Only microsecond
would save the situation because the clock can actually tick in the meantime:
21> lists:foldl(fun(N, ShIn) -> {ShOut, 0} = opuntia:update(ShIn, N), ShOut end, opuntia:new({100000,10000,microsecond}), lists:duplicate(1000,1)).
#token_bucket_shaper{shape = {100000,10000,microsecond},
available_tokens = 89991,last_update = -576460237904693}
It still often breaks before filling the bucket, but of course the delay of 1 microsecond might not be noticeable in practice.
34> lists:foldl(fun(N, ShIn) -> {ShOut, 0} = opuntia:update(ShIn, N), ShOut end, opuntia:new({100000,10000,microsecond}), lists:duplicate(9990,1)).
#token_bucket_shaper{shape = {100000,10000,microsecond},
available_tokens = 99999,last_update = -576457771123282}
35> lists:foldl(fun(N, ShIn) -> {ShOut, 0} = opuntia:update(ShIn, N), ShOut end, opuntia:new({100000,10000,microsecond}), lists:duplicate(9990,1)).
** exception error: no match of right hand side value {#token_bucket_shaper{shape = {100000,10000,microsecond},
available_tokens = 0,last_update = -576457768622881},
1}
in function erl_eval:expr/6 (erl_eval.erl, line 498)
in call from erl_eval:exprs/6 (erl_eval.erl, line 136)
in call from lists:foldl_1/3 (lists.erl, line 1599)
For me the +1
is just wrong, and I would store the accumulated fractional error itself separately.
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.
A draft of what I meant by fractions is below. It passed my quick test in the console, but of course unit tests (and proper tests) would show if it works:
--- a/src/opuntia.erl
+++ b/src/opuntia.erl
@@ -60,8 +60,9 @@ new(0) ->
new({MaximumTokens, Rate, TimeUnit})
when ?NON_NEG_INT(MaximumTokens), ?NON_NEG_INT(Rate), ?TU(TimeUnit) ->
#token_bucket_shaper{shape = {MaximumTokens, Rate, TimeUnit},
- available_tokens = 0,
- last_update = erlang:monotonic_time(TimeUnit)}.
+ available_tokens = Rate,
+ last_update = erlang:monotonic_time(TimeUnit),
+ frac = 0.0}.
%% @doc Update shaper and return possible waiting time.
%%
@@ -73,11 +74,12 @@ update(none, _TokensNowUsed) ->
{none, 0};
update(#token_bucket_shaper{shape = {MaximumTokens, Rate, TimeUnit},
available_tokens = LastAvailableTokens,
- last_update = LastUpdate} = Shaper, TokensNowUsed) ->
+ last_update = LastUpdate,
+ frac = Frac} = Shaper, TokensNowUsed) ->
%% Time since last shape update
Now = erlang:monotonic_time(TimeUnit),
- TimeSinceLastUpdate = Now - LastUpdate,
+ TimeSinceLastUpdate = (Now - LastUpdate) + Frac,
%% How much we might have recovered since last time
AvailableAtGrowthRate = Rate * TimeSinceLastUpdate,
@@ -102,5 +104,6 @@ update(#token_bucket_shaper{shape = {MaximumTokens, Rate, TimeUnit},
RoundedDelay = ceil(MaybeDelay),
NewShaper = Shaper#token_bucket_shaper{available_tokens = TokensAvailable,
- last_update = Now + RoundedDelay + 1},
+ last_update = Now + RoundedDelay,
+ frac = RoundedDelay - MaybeDelay},
{NewShaper, RoundedDelay}.
diff --git a/src/opuntia.hrl b/src/opuntia.hrl
index aff644f..3ec40f1 100644
--- a/src/opuntia.hrl
+++ b/src/opuntia.hrl
@@ -2,9 +2,10 @@
-define(OPUNTIA, true).
-record(token_bucket_shaper, {
+ frac :: float(),
shape :: opuntia:shape(),
available_tokens :: opuntia:tokens(),
- last_update :: number()
+ last_update :: integer()
}).
8f9bbb8
to
83c6e8b
Compare
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.
Looks good, I added a few comments.
test/opuntia_SUITE.erl
Outdated
timer:sleep(DelayMs), | ||
run_shaper(NewShaper, TokensLeft - TokensConsumed). | ||
|
||
success_or_log_and_retuns(true, _S, _P) -> |
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.
"retuns"?
|
||
should_take_in_range(#{rate := Rate, start_full := false}, ToConsume) -> | ||
ExpectedMs = ToConsume / Rate, | ||
{ExpectedMs, ExpectedMs + 1}; |
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 it correct that this is returning floats?
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.
Yes, we want to know with a lot o precision the range with a tolerance of only 1ms more, so no rounding.
src/opuntia.erl
Outdated
%% Number of tokens accepted per millisecond. | ||
|
||
-type bucket_size() :: non_neg_integer(). | ||
%% Maximum capacity of the bucket regardless of how much time it passes. |
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.
%% Maximum capacity of the bucket regardless of how much time it passes. | |
%% Maximum capacity of the bucket regardless of how much time passes. |
src/opuntia.erl
Outdated
when ?NON_NEG_INT(MaximumTokens), | ||
?NON_NEG_INT(Rate), | ||
MaximumTokens >= Rate, | ||
?TU(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.
This check doesn't make much sense.
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.
Artifact of removing all the other code, removing this too 👌🏽
d7ed258
to
a16788b
Compare
Reimplement everything using specific bucket sizes and flexible time units.