defmodule GenPETS do use GenServer alias :ets, as: ETS require Logger @type key() :: term() @type value() :: term() @type key_value() :: {key(), value()} @doc """ Starts a GenPETS process without links. See `start_link/1` for more information """ def start(args \\ []) do args = prepare_args(args) server_name = server_name(args.table_name) GenServer.start(__MODULE__, args, name: server_name) end @doc """ Starts a GenPETS process linked to the current process. # Start a new GenPETS: iex(1)> {:ok, pid} = GenPETS.start(table_name: :some_table_name, file_path: "some_file_path") {:ok, #PID<0.140.0>} # Control using the provided PID: iex(2)> GenPETS.write(pid, :key, :value) :ok # Alternatively, you can use your table_name as well: iex(3)> GenPETS.read(:some_table_name, :key) [key: :value] """ def start_link(args \\ []) do args = args |> prepare_args() |> validate_args() server_name = server_name(args.table_name) GenServer.start_link(__MODULE__, args, name: server_name) end @spec exists?(ETS.table(), key()) :: boolean() def direct_exists?(table, key) do case ETS.lookup(table, key) do [_ | _] -> true _ -> false end end @spec direct_delete(ETS.table(), key()) :: :ok def direct_delete(table, key) do true = ETS.delete(table, key) :ok end @spec direct_write(ETS.table(), key(), value()) :: :ok def direct_write(table, key, value) do true = ETS.insert(table, {key, value}) :ok end @spec direct_read(ETS.table(), key()) :: [key_value()] def direct_read(table, key) do ETS.lookup(table, key) end @spec direct_prune(ETS.table()) :: :ok def direct_prune(table) do true = ETS.delete_all_objects(table) :ok end @spec prune(GenServer.server()) :: :ok def prune(pid) when is_pid(pid) do GenServer.call(pid, :prune) end def prune(table_name) do GenServer.call(server_name(table_name), :prune) end @spec read(GenServer.server(), key()) :: [key_value()] def read(pid, key) when is_pid(pid) do GenServer.call(pid, {:read, key}) end def read(table_name, key) do GenServer.call(server_name(table_name), {:read, key}) end @spec write(GenServer.server(), key(), value()) :: :ok def write(pid, key, value) when is_pid(pid) do GenServer.call(pid, {:write, key, value}) end def write(table_name, key, value) do GenServer.call(server_name(table_name), {:write, key, value}) end @spec delete(GenServer.server(), key()) :: :ok def delete(pid, key) when is_pid(pid) do GenServer.call(pid, {:delete, key}) end def delete(table_name, key) do GenServer.call(server_name(table_name), {:delete, key}) end @spec exists?(GenServer.server(), key()) :: boolean() def exists?(pid, key) when is_pid(pid) do GenServer.call(pid, {:exists?, key}) end def exists?(table_name, key) do GenServer.call(server_name(table_name), {:exists?, key}) end @impl true def init(args) do %{ table_name: table_name, table_args: table_args, file_path: file_path, period: period, mode: mode } = args table = new_ets!(file_path, table_name, table_args) if mode == :soft, do: schedule_save(period) state = %{ table_name: table_name, file_path: file_path, period: period, mode: mode, table: table } {:ok, state} end @impl true def handle_call({:read, key}, _from, state) do result = direct_read(state.table, key) {:reply, result, state} end @impl true def handle_call({:write, key, value}, _from, state) do :ok = direct_write(state.table, key, value) maybe_save_ets(state) {:reply, :ok, state} end @impl true def handle_call({:delete, key}, _from, state) do :ok = direct_delete(state.table, key) maybe_save_ets(state) {:reply, :ok, state} end @impl true def handle_call({:exists?, key}, _from, state) do result = direct_exists?(state.table, key) {:reply, result, state} end @impl true def handle_call(:prune, _from, state) do :ok = direct_prune(state.table) maybe_save_ets(state) {:reply, :ok, state} end @impl true def handle_call(message, _from, state) do Logger.warning("Got unexpected message in handle_call/3: #{inspect(message)}") {:reply, {:error, :badarg}, state} end @impl true def handle_cast(message, state) do Logger.warning("Got unexpected message in handle_cast/2: #{inspect(message)}") {:noreply, state} end @impl true def handle_info(:save, state) do save_ets(state.file_path, state.table) schedule_save(state.period) {:noreply, state} end @impl true def handle_info(message, state) do Logger.warning("Got unexpected message in handle_info/2: #{inspect(message)}") {:noreply, state} end # # Helpers # defp new_ets!(file_path, table_name, table_args) do if File.regular?(file_path) do case open_ets(file_path) do {:ok, table} -> table {:error, reason} -> raise ArgumentError, "Failed to load file to ETS: #{inspect(file_path)}, " <> "Reason: #{inspect(reason)}" end else table = ETS.new(table_name, table_args) with( :ok <- File.write(file_path, ""), :ok <- save_ets(file_path, table) ) do table else {:error, reason} -> true = ETS.delete(table) raise ArgumentError, "Failed to sync file with ETS: #{inspect(file_path)}, " <> "Reason: #{inspect(reason)}" end end end defp open_ets(file_path) do ETS.file2tab(String.to_charlist(file_path), verify: true) end defp maybe_save_ets(%{mode: :hard} = state) do save_ets(state.file_path, state.table) end defp maybe_save_ets(_state) do :ok end defp save_ets(file_path, table) do ETS.tab2file(table, String.to_charlist(file_path)) end defp schedule_save(period) do Process.send_after(self(), :save, period) end defp server_name(table_name), do: :"GenPETS.#{table_name}" defp validate_args(args) do if args[:mode] not in [:soft, :hard] do raise ArgumentError, "Expected :mode to be one of [:soft, :hard], got: #{inspect(args.mode)}" end if not is_integer(args[:period]) do raise ArgumentError, "Expected :period to be one positive integer, got: #{inspect(args.period)}" end args end defp prepare_args(args) do %{ table_name: Keyword.fetch!(args, :table_name), table_args: Keyword.get(args, :table_args, [:set]), file_path: Keyword.fetch!(args, :file_path), mode: Keyword.get(args, :mode, :soft), period: Keyword.get(args, :period, 5000) } end end