Decouple publish fanout and use ETS ingest counters
This commit is contained in:
@@ -5,13 +5,13 @@ defmodule Parrhesia.API.Events do
|
|||||||
|
|
||||||
alias Parrhesia.API.Events.PublishResult
|
alias Parrhesia.API.Events.PublishResult
|
||||||
alias Parrhesia.API.RequestContext
|
alias Parrhesia.API.RequestContext
|
||||||
|
alias Parrhesia.Fanout.Dispatcher
|
||||||
alias Parrhesia.Fanout.MultiNode
|
alias Parrhesia.Fanout.MultiNode
|
||||||
alias Parrhesia.Groups.Flow
|
alias Parrhesia.Groups.Flow
|
||||||
alias Parrhesia.Policy.EventPolicy
|
alias Parrhesia.Policy.EventPolicy
|
||||||
alias Parrhesia.Protocol
|
alias Parrhesia.Protocol
|
||||||
alias Parrhesia.Protocol.Filter
|
alias Parrhesia.Protocol.Filter
|
||||||
alias Parrhesia.Storage
|
alias Parrhesia.Storage
|
||||||
alias Parrhesia.Subscriptions.Index
|
|
||||||
alias Parrhesia.Telemetry
|
alias Parrhesia.Telemetry
|
||||||
|
|
||||||
@default_max_event_bytes 262_144
|
@default_max_event_bytes 262_144
|
||||||
@@ -48,7 +48,7 @@ defmodule Parrhesia.API.Events do
|
|||||||
telemetry_metadata_for_event(event)
|
telemetry_metadata_for_event(event)
|
||||||
)
|
)
|
||||||
|
|
||||||
fanout_event(event)
|
Dispatcher.dispatch(event)
|
||||||
maybe_publish_multi_node(event)
|
maybe_publish_multi_node(event)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
@@ -230,20 +230,6 @@ defmodule Parrhesia.API.Events do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fanout_event(event) do
|
|
||||||
case Index.candidate_subscription_keys(event) do
|
|
||||||
candidates when is_list(candidates) ->
|
|
||||||
Enum.each(candidates, fn {owner_pid, subscription_id} ->
|
|
||||||
send(owner_pid, {:fanout_event, subscription_id, event})
|
|
||||||
end)
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
catch
|
|
||||||
:exit, _reason -> :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_publish_multi_node(event) do
|
defp maybe_publish_multi_node(event) do
|
||||||
MultiNode.publish(event)
|
MultiNode.publish(event)
|
||||||
:ok
|
:ok
|
||||||
|
|||||||
46
lib/parrhesia/fanout/dispatcher.ex
Normal file
46
lib/parrhesia/fanout/dispatcher.ex
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
defmodule Parrhesia.Fanout.Dispatcher do
|
||||||
|
@moduledoc """
|
||||||
|
Asynchronous local fanout dispatcher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
alias Parrhesia.Subscriptions.Index
|
||||||
|
|
||||||
|
@spec start_link(keyword()) :: GenServer.on_start()
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
name = Keyword.get(opts, :name, __MODULE__)
|
||||||
|
GenServer.start_link(__MODULE__, :ok, name: name)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec dispatch(map()) :: :ok
|
||||||
|
def dispatch(event), do: dispatch(__MODULE__, event)
|
||||||
|
|
||||||
|
@spec dispatch(GenServer.server(), map()) :: :ok
|
||||||
|
def dispatch(server, event) when is_map(event) do
|
||||||
|
GenServer.cast(server, {:dispatch, event})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(:ok), do: {:ok, %{}}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_cast({:dispatch, event}, state) do
|
||||||
|
dispatch_to_candidates(event)
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp dispatch_to_candidates(event) do
|
||||||
|
case Index.candidate_subscription_keys(event) do
|
||||||
|
candidates when is_list(candidates) ->
|
||||||
|
Enum.each(candidates, fn {owner_pid, subscription_id} ->
|
||||||
|
send(owner_pid, {:fanout_event, subscription_id, event})
|
||||||
|
end)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,7 +5,7 @@ defmodule Parrhesia.Fanout.MultiNode do
|
|||||||
|
|
||||||
use GenServer
|
use GenServer
|
||||||
|
|
||||||
alias Parrhesia.Subscriptions.Index
|
alias Parrhesia.Fanout.Dispatcher
|
||||||
|
|
||||||
@group __MODULE__
|
@group __MODULE__
|
||||||
|
|
||||||
@@ -44,11 +44,7 @@ defmodule Parrhesia.Fanout.MultiNode do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:remote_fanout_event, event}, state) do
|
def handle_info({:remote_fanout_event, event}, state) do
|
||||||
Index.candidate_subscription_keys(event)
|
Dispatcher.dispatch(event)
|
||||||
|> Enum.each(fn {owner_pid, subscription_id} ->
|
|
||||||
send(owner_pid, {:fanout_event, subscription_id, event})
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ defmodule Parrhesia.Subscriptions.Supervisor do
|
|||||||
children =
|
children =
|
||||||
[
|
[
|
||||||
{Parrhesia.Subscriptions.Index, name: Parrhesia.Subscriptions.Index},
|
{Parrhesia.Subscriptions.Index, name: Parrhesia.Subscriptions.Index},
|
||||||
|
{Parrhesia.Fanout.Dispatcher, name: Parrhesia.Fanout.Dispatcher},
|
||||||
{Registry, keys: :unique, name: Parrhesia.API.Stream.Registry},
|
{Registry, keys: :unique, name: Parrhesia.API.Stream.Registry},
|
||||||
{DynamicSupervisor, strategy: :one_for_one, name: Parrhesia.API.Stream.Supervisor}
|
{DynamicSupervisor, strategy: :one_for_one, name: Parrhesia.API.Stream.Supervisor}
|
||||||
] ++
|
] ++
|
||||||
|
|||||||
@@ -7,57 +7,121 @@ defmodule Parrhesia.Web.EventIngestLimiter do
|
|||||||
|
|
||||||
@default_max_events_per_window 10_000
|
@default_max_events_per_window 10_000
|
||||||
@default_window_seconds 1
|
@default_window_seconds 1
|
||||||
|
@named_table :parrhesia_event_ingest_limiter
|
||||||
|
@config_key :config
|
||||||
|
|
||||||
@spec start_link(keyword()) :: GenServer.on_start()
|
@spec start_link(keyword()) :: GenServer.on_start()
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
|
max_events_per_window =
|
||||||
|
normalize_positive_integer(
|
||||||
|
Keyword.get(opts, :max_events_per_window),
|
||||||
|
max_events_per_window()
|
||||||
|
)
|
||||||
|
|
||||||
|
window_ms =
|
||||||
|
normalize_positive_integer(Keyword.get(opts, :window_seconds), window_seconds()) * 1000
|
||||||
|
|
||||||
|
init_arg = %{
|
||||||
|
max_events_per_window: max_events_per_window,
|
||||||
|
window_ms: window_ms,
|
||||||
|
named_table?: Keyword.get(opts, :name, __MODULE__) == __MODULE__
|
||||||
|
}
|
||||||
|
|
||||||
case Keyword.get(opts, :name, __MODULE__) do
|
case Keyword.get(opts, :name, __MODULE__) do
|
||||||
nil -> GenServer.start_link(__MODULE__, opts)
|
nil -> GenServer.start_link(__MODULE__, init_arg)
|
||||||
name -> GenServer.start_link(__MODULE__, opts, name: name)
|
name -> GenServer.start_link(__MODULE__, init_arg, name: name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec allow(GenServer.server()) :: :ok | {:error, :relay_event_rate_limited}
|
@spec allow(GenServer.server()) :: :ok | {:error, :relay_event_rate_limited}
|
||||||
def allow(server \\ __MODULE__) do
|
def allow(server \\ __MODULE__)
|
||||||
GenServer.call(server, :allow)
|
|
||||||
|
def allow(__MODULE__) do
|
||||||
|
case fetch_named_config() do
|
||||||
|
{:ok, max_events_per_window, window_ms} ->
|
||||||
|
allow_counter(@named_table, max_events_per_window, window_ms)
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allow(server), do: GenServer.call(server, :allow)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(opts) do
|
def init(%{
|
||||||
|
max_events_per_window: max_events_per_window,
|
||||||
|
window_ms: window_ms,
|
||||||
|
named_table?: named_table?
|
||||||
|
}) do
|
||||||
|
table = create_table(named_table?)
|
||||||
|
|
||||||
|
true = :ets.insert(table, {@config_key, max_events_per_window, window_ms})
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
max_events_per_window:
|
table: table,
|
||||||
normalize_positive_integer(
|
max_events_per_window: max_events_per_window,
|
||||||
Keyword.get(opts, :max_events_per_window),
|
window_ms: window_ms
|
||||||
max_events_per_window()
|
|
||||||
),
|
|
||||||
window_ms:
|
|
||||||
normalize_positive_integer(Keyword.get(opts, :window_seconds), window_seconds()) * 1000,
|
|
||||||
window_started_at_ms: System.monotonic_time(:millisecond),
|
|
||||||
count: 0
|
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call(:allow, _from, state) do
|
def handle_call(:allow, _from, state) do
|
||||||
now_ms = System.monotonic_time(:millisecond)
|
{:reply, allow_counter(state.table, state.max_events_per_window, state.window_ms), state}
|
||||||
|
|
||||||
cond do
|
|
||||||
now_ms - state.window_started_at_ms >= state.window_ms ->
|
|
||||||
next_state = %{state | window_started_at_ms: now_ms, count: 1}
|
|
||||||
{:reply, :ok, next_state}
|
|
||||||
|
|
||||||
state.count < state.max_events_per_window ->
|
|
||||||
next_state = %{state | count: state.count + 1}
|
|
||||||
{:reply, :ok, next_state}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
{:reply, {:error, :relay_event_rate_limited}, state}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
|
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
|
||||||
defp normalize_positive_integer(_value, default), do: default
|
defp normalize_positive_integer(_value, default), do: default
|
||||||
|
|
||||||
|
defp create_table(true) do
|
||||||
|
:ets.new(@named_table, [
|
||||||
|
:named_table,
|
||||||
|
:set,
|
||||||
|
:public,
|
||||||
|
{:read_concurrency, true},
|
||||||
|
{:write_concurrency, true}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_table(false) do
|
||||||
|
:ets.new(__MODULE__, [:set, :public, {:read_concurrency, true}, {:write_concurrency, true}])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_named_config do
|
||||||
|
case :ets.lookup(@named_table, @config_key) do
|
||||||
|
[{@config_key, max_events_per_window, window_ms}] -> {:ok, max_events_per_window, window_ms}
|
||||||
|
_other -> :error
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
ArgumentError -> :error
|
||||||
|
end
|
||||||
|
|
||||||
|
defp allow_counter(table, max_events_per_window, window_ms) do
|
||||||
|
window_id = System.monotonic_time(:millisecond) |> div(window_ms)
|
||||||
|
key = {:window, window_id}
|
||||||
|
|
||||||
|
count = :ets.update_counter(table, key, {2, 1}, {key, 0})
|
||||||
|
|
||||||
|
if count == 1 do
|
||||||
|
prune_expired_windows(table, window_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if count <= max_events_per_window do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, :relay_event_rate_limited}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
ArgumentError -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prune_expired_windows(table, window_id) do
|
||||||
|
:ets.select_delete(table, [
|
||||||
|
{{{:window, :"$1"}, :_}, [{:<, :"$1", window_id}], [true]}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
defp max_events_per_window do
|
defp max_events_per_window do
|
||||||
case Application.get_env(:parrhesia, :limits, [])
|
case Application.get_env(:parrhesia, :limits, [])
|
||||||
|> Keyword.get(:relay_max_event_ingest_per_window) do
|
|> Keyword.get(:relay_max_event_ingest_per_window) do
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ defmodule Parrhesia.ApplicationTest do
|
|||||||
assert is_pid(Process.whereis(Parrhesia.Web.EventIngestLimiter))
|
assert is_pid(Process.whereis(Parrhesia.Web.EventIngestLimiter))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Storage.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Storage.Supervisor))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Subscriptions.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Subscriptions.Supervisor))
|
||||||
|
assert is_pid(Process.whereis(Parrhesia.Fanout.Dispatcher))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Auth.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Auth.Supervisor))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Sync.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Sync.Supervisor))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Policy.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Policy.Supervisor))
|
||||||
|
|||||||
Reference in New Issue
Block a user