-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Replace Hammer for rate limiting with custom ets bucket #3571
Changes from all commits
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 |
---|---|---|
@@ -0,0 +1,82 @@ | ||
defmodule Plausible.RateLimit do | ||
@moduledoc """ | ||
Thin wrapper around `:ets.update_counter/4` and a | ||
clean-up process to act as a rate limiter. | ||
""" | ||
|
||
use GenServer | ||
|
||
@doc """ | ||
Starts the process that creates and cleans the ETS table. | ||
|
||
Accepts the following options: | ||
- `GenServer.options()` | ||
- `:table` for the ETS table name, defaults to `#{__MODULE__}` | ||
- `:clean_period` for how often to perform garbage collection | ||
""" | ||
@spec start_link([GenServer.option() | {:table, atom} | {:clean_period, pos_integer}]) :: | ||
GenServer.on_start() | ||
def start_link(opts) do | ||
{gen_opts, opts} = | ||
Keyword.split(opts, [:debug, :name, :timeout, :spawn_opt, :hibernate_after]) | ||
|
||
GenServer.start_link(__MODULE__, opts, gen_opts) | ||
end | ||
|
||
@doc """ | ||
Checks the rate-limit for a key. | ||
""" | ||
@spec check_rate(:ets.table(), key, scale, limit, increment) :: {:allow, count} | {:deny, limit} | ||
when key: term, | ||
scale: pos_integer, | ||
limit: pos_integer, | ||
increment: pos_integer, | ||
count: pos_integer | ||
def check_rate(table \\ __MODULE__, key, scale, limit, increment \\ 1) do | ||
bucket = div(now(), scale) | ||
full_key = {key, bucket} | ||
expires_at = (bucket + 1) * scale | ||
count = :ets.update_counter(table, full_key, increment, {full_key, 0, expires_at}) | ||
if count <= limit, do: {:allow, count}, else: {:deny, limit} | ||
end | ||
|
||
@impl true | ||
def init(opts) do | ||
clean_period = Keyword.fetch!(opts, :clean_period) | ||
table = Keyword.get(opts, :table, __MODULE__) | ||
|
||
^table = | ||
:ets.new(table, [ | ||
:named_table, | ||
:set, | ||
:public, | ||
{:read_concurrency, true}, | ||
{:write_concurrency, true}, | ||
{:decentralized_counters, true} | ||
]) | ||
|
||
schedule(clean_period) | ||
{:ok, %{table: table, clean_period: clean_period}} | ||
end | ||
|
||
@impl true | ||
def handle_info(:clean, state) do | ||
clean(state.table) | ||
schedule(state.clean_period) | ||
{:noreply, state} | ||
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. Would it make sense to hibernate the process, since all it's doing is waiting another 10 minutes for the next cleanup? 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 haven't used process hibernation before. I'll read up on that. 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. From a quick look, process hibernation forces a garbage collection (we have 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. Buckle up, we're going live :) 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. 😵 |
||
end | ||
|
||
defp schedule(clean_period) do | ||
Process.send_after(self(), :clean, clean_period) | ||
end | ||
|
||
defp clean(table) do | ||
ms = [{{{:_, :_}, :_, :"$1"}, [], [{:<, :"$1", {:const, now()}}]}] | ||
:ets.select_delete(table, ms) | ||
end | ||
|
||
@compile inline: [now: 0] | ||
defp now do | ||
System.system_time(:millisecond) | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
defmodule Plausible.RateLimitTest do | ||
use ExUnit.Case, async: true | ||
alias Plausible.RateLimit | ||
|
||
@table __MODULE__ | ||
|
||
defp key, do: "key:#{System.unique_integer([:positive])}" | ||
|
||
@tag :slow | ||
test "garbage collection" do | ||
start_supervised!({RateLimit, clean_period: _100_ms = 100, table: @table}) | ||
|
||
key = key() | ||
scale = _50_ms = 50 | ||
limit = 10 | ||
|
||
for _ <- 1..3 do | ||
assert {:allow, 1} = RateLimit.check_rate(@table, key, scale, limit) | ||
assert [{{^key, _bucket}, _count = 1, expires_at}] = :ets.tab2list(@table) | ||
|
||
assert expires_at > System.system_time(:millisecond) | ||
assert expires_at <= System.system_time(:millisecond) + 50 | ||
|
||
:timer.sleep(150) | ||
|
||
assert :ets.tab2list(@table) == [] | ||
end | ||
end | ||
|
||
describe "check_rate/3" do | ||
setup do | ||
start_supervised!({RateLimit, clean_period: :timer.minutes(1), table: @table}) | ||
:ok | ||
end | ||
|
||
test "increments" do | ||
key = key() | ||
scale = :timer.seconds(10) | ||
limit = 10 | ||
|
||
assert {:allow, 1} = RateLimit.check_rate(@table, key, scale, limit) | ||
assert {:allow, 2} = RateLimit.check_rate(@table, key, scale, limit) | ||
assert {:allow, 3} = RateLimit.check_rate(@table, key, scale, limit) | ||
end | ||
|
||
test "resets" do | ||
key = key() | ||
scale = 10 | ||
limit = 10 | ||
|
||
assert {:allow, 1} = RateLimit.check_rate(@table, key, scale, limit) | ||
:timer.sleep(scale * 2 + 1) | ||
assert {:allow, 1} = RateLimit.check_rate(@table, key, scale, limit) | ||
end | ||
|
||
test "denies" do | ||
key = key() | ||
scale = :timer.seconds(10) | ||
limit = 3 | ||
|
||
assert {:allow, 1} = RateLimit.check_rate(@table, key, scale, limit) | ||
assert {:allow, 2} = RateLimit.check_rate(@table, key, scale, limit) | ||
assert {:allow, 3} = RateLimit.check_rate(@table, key, scale, limit) | ||
assert {:deny, 3} = RateLimit.check_rate(@table, key, scale, limit) | ||
end | ||
end | ||
end |
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 looking at how Hammer's table is configured:
I'm sure you've benchmarked the hell out of it, what was the difference between using
set
andordered_set
?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've only benchmarked different implementations https://github.com/ruslandoga/rate_limit, and I haven't benchmarked set vs ordered-set directly. I'll do a benchmark now.
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 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.
PR: ruslandoga/rate_limit#1