defmodule MicrowavepropWeb.Api.RateLimiter.Sweeper do @moduledoc """ Periodic GC for the API rate limiter ETS table. The limiter writes one row per `{bucket, window}` and never overwrites — without a sweeper the table accumulates one row per distinct caller per window for the lifetime of the BEAM. This process scans the table on a fixed cadence and deletes any row whose window has fully expired (window index strictly less than the current window index). Defaults to one sweep per 5 minutes. Configure via: config :microwaveprop, MicrowavepropWeb.Api.RateLimiter.Sweeper, interval_ms: 300_000 Test helper `sweep_now/1` runs a sweep synchronously. """ use GenServer alias MicrowavepropWeb.Api.RateLimiter @default_interval_ms 5 * 60 * 1_000 @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do name = Keyword.get(opts, :name, __MODULE__) GenServer.start_link(__MODULE__, opts, name: name) end @doc "Run a sweep now. Returns the number of rows deleted." @spec sweep_now(GenServer.server()) :: non_neg_integer() def sweep_now(server \\ __MODULE__) do GenServer.call(server, :sweep_now) end @impl true def init(opts) do interval_ms = opts |> Keyword.get(:interval_ms) |> Kernel.||( :microwaveprop |> Application.get_env(__MODULE__, []) |> Keyword.get(:interval_ms, @default_interval_ms) ) # Own the ETS table from this long-lived process. ETS named tables # die with their owner — having a transient request task or # short-lived test process create the table leaves the system # without a backing store the moment that process exits. The # application boot path also calls `RateLimiter.init_table/0` for # the cold-start safety net; both paths are idempotent. :ok = RateLimiter.init_table() schedule(interval_ms) {:ok, %{interval_ms: interval_ms}} end @impl true def handle_info(:sweep, state) do _ = sweep() schedule(state.interval_ms) {:noreply, state} end @impl true def handle_call(:sweep_now, _from, state) do {:reply, sweep(), state} end defp schedule(interval_ms) do Process.send_after(self(), :sweep, interval_ms) end defp sweep do table = RateLimiter.table() # Defensive: if the table is missing (test that hasn't booted the # app, etc.) there is nothing to prune. if :ets.whereis(table) == :undefined do 0 else window_ms = RateLimiter.default_window_ms() current_window = div(System.system_time(:millisecond), window_ms) # Match rows whose window index is strictly less than the # current window — those windows can no longer be hit (even a # request arriving exactly at the boundary uses the new # window). `:ets.select_delete/2` returns the count. match_spec = [ { {{:_, :"$1"}, :_}, [{:<, :"$1", current_window}], [true] } ] :ets.select_delete(table, match_spec) end end end