Current section

Files

Jump to
speed_trap src speed_trap_token_bucket.erl
Raw

src/speed_trap_token_bucket.erl

%%%=============================================================================
%%% @doc speed_trap_token_bucket implementation with atomics.
%%%
%%% The current number of tokens is stored as a mutable atomic
%%% variable. When a token is taken from the bucket, the counter is
%%% decremented. It is allowed for the counter to become negative.
%%%
%%% Adding tokens to the counter requires multiple operations. Since
%%% there is no operation to add with a threshold (contrary to
%%% {@link ets:update_counter/3} for example), the value is first read
%%% and then incremented if necessary. It is possible that the value
%%% will be decremented between the two operations, but it cannot be
%%% incremented (given there's only one updater running at a time), so
%%% there's no risk of adding tokens above the bucket's capacity. On
%%% the other hand, the counter going below 0 is an issue that needs
%%% special handling: whenever the update operation finds a negative
%%% value, it sets the counter to 1. Incrementing it is not safe in
%%% this case, as concurrent processes may try to get tokens from the
%%% bucket, pushing its value to even lower between the read and the
%%% update.
%%%
%%% It's worth mentioning that although counters can underflow, it is
%%% not a practical risk to call {@link get_token/1} 2^63 times in a
%%% single refill interval.
%%%
%%% For simplicity, the actual updates are scheduled via
%%% `timer:apply_interval/4', however an interval timer is linked to
%%% the process creating it. Therefore we need a simple server to own
%%% and manage these timers.
%%%
%%% @copyright 2023 Klarna Bank AB
%%% @end
%%%=============================================================================
-module(speed_trap_token_bucket).
-export([start_link/0, bucket/1, new/2, delete/2, modify/2, get_token/1, return_token/1, options/1,
active_buckets/0, get_override/1, delete_override/1, delete_overrides/0]).
%% Timer callback
-export([add_token/4]).
-behaviour(gen_server).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-type token_bucket() :: atomics:atomics_ref().
-export_type([token_bucket/0]).
-define(SERVER, ?MODULE).
-define(ETS_SPEED_TRAPS, speed_traps).
-define(ETS_TEMPLATE_BASED_TRAP_OVERRIDES, speed_trap_template_based_overrides).
-define(BUCKET_IDX, 1).
-define(not_found, not_found).
-type state() :: #{speed_trap:id() => timer:tref()}.
-include_lib("stdlib/include/ms_transform.hrl").
%%-----------------------------------------------------------------------------
%% API
%%-----------------------------------------------------------------------------
%% @doc Start the scheduler server.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
%% @doc Add a new function to the scheduler. The provided MFA will be
%% registered under the given id and applied every refill interval
%% milliseconds.
-spec new(speed_trap:id(), speed_trap:options()) -> ok | {error, speed_trap:already_exists()}.
new(Id, Options) ->
gen_server:call(?SERVER, {register, Id, Options}).
%% @doc Deletes a scheduled function by its id. The second parameter indicates whether to try
%% deleting a store template based token bucket override by id or not.
-spec delete(speed_trap:id(), boolean()) -> ok | {error, speed_trap:no_such_speed_trap()}.
delete(Id, DeleteOverride) ->
gen_server:call(?SERVER, {delete, Id, DeleteOverride}).
%% @doc Modifies the scheduled function already registered under the
%% given id.
-spec modify(speed_trap:id(), speed_trap:modify_options()) ->
ok | {error, speed_trap_options:bad_options() | speed_trap:no_such_speed_trap()}.
modify(Id, Options) ->
gen_server:call(?SERVER, {modify, Id, Options}).
-spec get_token(speed_trap:id()) ->
{ok, speed_trap:try_pass_success()} |
{error,
speed_trap:no_such_speed_trap() |
speed_trap:too_many_requests() |
speed_trap:blocked()}.
get_token(Id) ->
case bucket(Id) of
{error, no_such_speed_trap} = E ->
E;
{ok, {Options, Bucket}} ->
do_get_token(Options, Bucket)
end.
-spec return_token(speed_trap:id()) -> ok | {error, speed_trap:no_such_speed_trap()}.
return_token(Id) ->
case bucket(Id) of
{error, no_such_speed_trap} = E ->
E;
{ok, {_Options, Bucket}} ->
atomics:add(Bucket, ?BUCKET_IDX, 1)
end.
-spec options(speed_trap:id()) ->
{ok, speed_trap:stored_options()} | {error, speed_trap:no_such_speed_trap()}.
options(Id) ->
case bucket(Id) of
{error, no_such_speed_trap} = E ->
E;
{ok, {Options, _Bucket}} ->
{ok, Options}
end.
-spec bucket(speed_trap:id()) ->
{ok, {speed_trap:stored_options(), token_bucket()}} |
{error, speed_trap:no_such_speed_trap()}.
bucket(Id) ->
case ets:lookup(?ETS_SPEED_TRAPS, Id) of
[] ->
{error, no_such_speed_trap};
[{_Id, {Options, Bucket}}] ->
{ok, {Options, Bucket}}
end.
-spec active_buckets() -> [{speed_trap:id(), speed_trap:stored_options()}].
active_buckets() ->
[{Id, Options#{tokens => atomics:get(Bucket, ?BUCKET_IDX)}}
|| {Id, {Options, Bucket}} <- ets:tab2list(?ETS_SPEED_TRAPS)].
%% @doc Gets an override from the overrides store.
-spec get_override(speed_trap:id()) -> {ok, speed_trap:modify_options()} | {error, ?not_found}.
get_override(Id) ->
case ets:lookup(?ETS_TEMPLATE_BASED_TRAP_OVERRIDES, Id) of
[] ->
{error, ?not_found};
[{_, Options}] ->
{ok, Options}
end.
%% @doc Deletes a possibly stored token bucket override from the store.
-spec delete_override(speed_trap:id()) -> ok.
delete_override(Id) ->
gen_server:call(?SERVER, {delete_override, Id}).
%% @doc Cleans up the overrides store.
-spec delete_overrides() -> ok.
delete_overrides() ->
gen_server:call(?SERVER, delete_overrides).
%%-----------------------------------------------------------------------------
%% gen_server callbacks
%%-----------------------------------------------------------------------------
-spec init([]) -> {ok, state()}.
init([]) ->
?ETS_SPEED_TRAPS =
ets:new(?ETS_SPEED_TRAPS, [set, protected, named_table, {read_concurrency, true}]),
?ETS_TEMPLATE_BASED_TRAP_OVERRIDES =
ets:new(?ETS_TEMPLATE_BASED_TRAP_OVERRIDES,
[set, protected, named_table, {read_concurrency, true}]),
{ok, #{}}.
-spec handle_call(term(), {pid(), term()}, state()) -> {reply, ok | {error, term()}, state()}.
handle_call({register, Id, Options}, _From, Timers) ->
case bucket(Id) of
{ok, _} ->
{reply, {error, already_exists}, Timers};
{error, no_such_speed_trap} ->
Bucket = atomics:new(1, [{signed, true}]),
NewTimers = apply_options(Id, Options, Bucket, Timers),
{reply, ok, NewTimers}
end;
handle_call({delete, Id, DeleteTemplateModification}, _From, Timers) ->
case DeleteTemplateModification of
true ->
true = ets:delete(?ETS_TEMPLATE_BASED_TRAP_OVERRIDES, Id);
_ ->
ok
end,
case bucket(Id) of
{ok, _} ->
RemainingTimers = cancel_timer_if_exists(Id, Timers),
true = ets:delete(?ETS_SPEED_TRAPS, Id),
{reply, ok, RemainingTimers};
{error, _NoSuchSpeedTrap} = Error ->
{reply, Error, Timers}
end;
handle_call({modify, Id, NewOptions}, _From, Timers) ->
case bucket(Id) of
{ok, {OldOptions, Bucket}} ->
ApplyOptsFn = fun(Opts) -> apply_options(Id, Opts, Bucket, Timers) end,
modify_options_and_reply(Id, OldOptions, NewOptions, ApplyOptsFn, Timers);
{error, _NoSuchSpeedTrap} = Error ->
case try_get_options_from_templates(Id) of
{ok, OldOptions} ->
modify_options_and_reply(Id, OldOptions, NewOptions, undefined, Timers);
{error, ?not_found} ->
{reply, Error, Timers}
end
end;
handle_call({delete_override, Id}, _From, Timers) ->
true = ets:delete(?ETS_TEMPLATE_BASED_TRAP_OVERRIDES, Id),
{reply, ok, Timers};
handle_call(delete_overrides, _From, Timers) ->
true = ets:delete_all_objects(?ETS_TEMPLATE_BASED_TRAP_OVERRIDES),
{reply, ok, Timers};
handle_call(_Request, _From, State) ->
{reply, {error, no_such_call}, State}.
-spec handle_cast(any(), state()) -> {noreply, state()}.
handle_cast(_Request, Timers) ->
{noreply, Timers}.
-spec handle_info(any(), state()) -> {noreply, state()}.
handle_info(_Request, Timers) ->
{noreply, Timers}.
%-----------------------------------------------------------------------------
%% Timer callback
%%-----------------------------------------------------------------------------
-spec add_token(token_bucket(), speed_trap:bucket_size(), speed_trap:refill_count(), boolean()) ->
ok.
add_token(Bucket, BucketSize, RefillCount, DeleteWhenFull) ->
case atomics:get(Bucket, ?BUCKET_IDX) of
N when N >= BucketSize andalso DeleteWhenFull ->
Id = bucket_to_id(Bucket),
%% Do not try to delete a template modification
case delete(Id, false) of
ok ->
ok;
{error, no_such_speed_trap} ->
ok
end;
N when N =< 0 ->
atomics:put(Bucket, ?BUCKET_IDX, RefillCount);
N when N < BucketSize ->
%% Do not overflow the bucket
RefillN = min(RefillCount, BucketSize - N),
case atomics:add_get(Bucket, ?BUCKET_IDX, RefillN) of
M when M < RefillCount ->
%% After a refill the number of tokens in the bucket should be in
%% the [RefillCount, BucketSize] range. If it is lower than RefillCount
%% right after the refill it means that a user has consumed some tokens
%% concurrently between our first get and the add_get the bucket.
atomics:put(Bucket, ?BUCKET_IDX, RefillCount);
_M ->
ok
end;
_N ->
ok % the bucket is already full
end.
%%-----------------------------------------------------------------------------
%% Internal functions
%%-----------------------------------------------------------------------------
-spec apply_options(speed_trap:id(), speed_trap:options(), token_bucket(), state()) -> state().
apply_options(Id, Options, Bucket, State) ->
NewState = cancel_timer_if_exists(Id, State),
ets:insert(?ETS_SPEED_TRAPS, {Id, {Options, Bucket}}),
case Options of
#{override := blocked} ->
atomics:put(Bucket, ?BUCKET_IDX, 0),
NewState;
#{bucket_size := BucketSize,
refill_interval := RefillInterval,
refill_count := RefillCount,
delete_when_full := DeleteWhenFull} ->
atomics:put(Bucket, ?BUCKET_IDX, BucketSize),
{ok, Timer} =
timer:apply_interval(RefillInterval,
?MODULE,
add_token,
[Bucket, BucketSize, RefillCount, DeleteWhenFull]),
State#{Id => Timer}
end.
-spec cancel_timer_if_exists(speed_trap:id(), state()) -> state().
cancel_timer_if_exists(Id, State) ->
case maps:take(Id, State) of
{Timer, NewState} ->
timer:cancel(Timer),
NewState;
error ->
State
end.
bucket_to_id(Bucket) ->
[Id] =
ets:select(?ETS_SPEED_TRAPS, ets:fun2ms(fun({Id, {_Options, B}}) when B =:= Bucket -> Id end)),
Id.
do_get_token(#{override := blocked}, _Bucket) ->
{error, blocked};
do_get_token(#{override := Override}, Bucket) ->
case atomics:sub_get(Bucket, ?BUCKET_IDX, 1) of
N when N >= 0 ->
{ok, N};
_ when Override =:= not_enforced ->
{ok, rate_limit_not_enforced};
_ ->
{error, too_many_requests}
end.
-spec update_override(speed_trap:id(), speed_trap:modify_options()) -> true.
update_override(Id, Override) ->
NewOverride =
case get_override(Id) of
{ok, StoredOverride} ->
maps:merge(StoredOverride, Override);
{error, ?not_found} ->
Override
end,
true = ets:insert(?ETS_TEMPLATE_BASED_TRAP_OVERRIDES, {Id, NewOverride}).
-spec try_get_options_from_templates(speed_trap:id()) ->
{ok, speed_trap:options()} | {error, ?not_found}.
try_get_options_from_templates(Id) ->
case speed_trap_template:options_from_id(Id) of
{ok, TemplateId, TemplateOptions} ->
Options =
case get_override(Id) of
{ok, Override} ->
maps:merge(TemplateOptions, Override);
{error, ?not_found} ->
TemplateOptions
end,
{ok, Options#{template_id => TemplateId}};
?not_found ->
{error, ?not_found}
end.
-spec modify_options_and_reply(speed_trap:id(),
speed_trap:options(),
speed_trap:modify_options(),
fun((speed_trap:options()) -> state()) | undefined,
state()) ->
{reply, ok, state()} |
{reply, {error, speed_trap_options:bad_options()}, state()}.
modify_options_and_reply(Id, OldOptions, NewOptions, ApplyOptsFn, Timers) ->
case maps:merge(OldOptions, NewOptions) of
OldOptions ->
{reply, ok, Timers};
Options ->
case speed_trap_options:validate(Options, _Required = false) of
ok ->
NewTimers = do_modify_options(Id, OldOptions, NewOptions, ApplyOptsFn, Timers, Options),
{reply, ok, NewTimers};
{error, _BadOptions} = Error ->
{reply, Error, Timers}
end
end.
do_modify_options(Id, OldOptions, NewOptions, ApplyOptsFn, Timers, Options) ->
NewTimers =
case ApplyOptsFn of
_ when is_function(ApplyOptsFn) ->
ApplyOptsFn(Options);
undefined ->
Timers
end,
case maps:is_key(template_id, OldOptions) of
true ->
update_override(Id, NewOptions);
false ->
ok
end,
NewTimers.