Current section

Files

Jump to
attesto lib attesto code_store ets.ex
Raw

lib/attesto/code_store/ets.ex

defmodule Attesto.CodeStore.ETS do
@moduledoc """
Single-node ETS implementation of `Attesto.CodeStore`.
Codes live in a public ETS table owned by a `GenServer` that sweeps
expired rows on a fixed interval. `take/1` uses `:ets.take/2`, which
fetches and deletes a row in one atomic step, so the single-use
guarantee holds against concurrent redemptions on a node. This is a
per-node store: a multi-node deployment MUST back `Attesto.CodeStore`
with a shared store (e.g. Postgres `DELETE ... RETURNING`) so a code
issued on one node can be redeemed (once) on another.
## Start options
* `:sweep_interval_ms` (default `30_000`) - how often expired rows are
bulk-deleted. Correctness does not depend on sweeping (`take/1`
returns the row and `Attesto.AuthorizationCode` re-checks expiry);
the sweeper only bounds table size.
## Wiring
children = [Attesto.CodeStore.ETS]
then pass the module as the store:
Attesto.AuthorizationCode.issue(Attesto.CodeStore.ETS, attrs)
"""
@behaviour Attesto.CodeStore
use GenServer
@table __MODULE__
@default_sweep_interval_ms 30_000
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc false
def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker}
end
@impl Attesto.CodeStore
def put(%{code_hash: code_hash, expires_at: expires_at} = record)
when is_binary(code_hash) and is_integer(expires_at) do
# expires_at is hoisted into its own tuple element so the sweep
# match spec is a plain guard, never a map pattern.
true = :ets.insert(@table, {code_hash, expires_at, record})
:ok
end
@impl Attesto.CodeStore
def take(code_hash) when is_binary(code_hash) do
case :ets.take(@table, code_hash) do
[{^code_hash, _expires_at, record}] -> {:ok, record}
[] -> :error
end
end
@impl Attesto.CodeStore
def get(code_hash) when is_binary(code_hash) do
case :ets.lookup(@table, code_hash) do
[{^code_hash, _expires_at, record}] -> {:ok, record}
[] -> :error
end
end
@doc "Clear every entry. Test-facing."
@spec reset() :: :ok
def reset do
if :ets.whereis(@table) != :undefined, do: :ets.delete_all_objects(@table)
:ok
end
@impl GenServer
def init(opts) do
Attesto.ClusterGuard.assert_single_node!(
__MODULE__,
Keyword.get(opts, :multi_node_acknowledged?, false)
)
sweep_interval_ms = Keyword.get(opts, :sweep_interval_ms, @default_sweep_interval_ms)
:ets.new(@table, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
schedule_sweep(sweep_interval_ms)
{:ok, %{sweep_interval_ms: sweep_interval_ms}}
end
@impl GenServer
def handle_info(:sweep, state) do
now = System.system_time(:second)
# Delete every row whose expiry is strictly in the past.
:ets.select_delete(@table, [{{:"$1", :"$2", :"$3"}, [{:<, :"$2", now}], [true]}])
schedule_sweep(state.sweep_interval_ms)
{:noreply, state}
end
defp schedule_sweep(interval_ms), do: Process.send_after(self(), :sweep, interval_ms)
end