Packages

GenPETS - small wrapper around ETS with data persistence.

Current section

Files

Jump to
gen_pets lib gen_pets.ex
Raw

lib/gen_pets.ex

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