Current section
Files
Jump to
Current section
Files
lib/pockets.ex
defmodule Pockets do
@moduledoc """
`Pockets` is a wrapper around Erlang [`:ets`](https://erlang.org/doc/man/ets.html)
and [`:dets``](https://erlang.org/doc/man/dets.html), built-in options for memory- and disk-based term storage.
It offers simple key/value storage using an interface similar to the `Map` or `Keyword` modules. This can be a useful
persistent cache for many use cases.
For those needing more power or versatility than what `:ets` or `:dets` can offer, Elixir includes
[`:mnesia`](http://erlang.org/doc/man/mnesia.html).
Note that this package and the libraries that underpin it may have limitations or specific behaviors that may affect
its suitability for various use-cases. For example, the limited support for concurrency provided by the `:ets(3)`
module is not yet provided by `:dets`.
Support for `:bag`, `:duplicate_bag` types has not yet been tested.
See also
- [stash](https://github.com/whitfin/stash)
- ["What is ETS in Elixir?"](https://culttt.com/2016/10/05/what-is-ets-in-elixir/)
"""
alias Pockets.{DetsInfo, EtsInfo, Table, Registry}
require Logger
@typedoc """
An alias is used to refer to a `Pockets` table: it is usually an atom, but in some cases
it may be a reference.
"""
@type alias :: atom | reference
defguard is_alias(value) when is_atom(value) or is_reference(value)
# @table_types [:bag, :duplicate_bag, :set]
@default_table_type :set
@default_dets_opts [type: @default_table_type]
# Here is an Elixir-friendly rendering of some (not all!) of the :ets.new/2 options.
# These have to be converted into a list of mixed types before they are
# passed to :ets.new/2
@default_ets_opts [
type: @default_table_type,
access: :public,
# if false, :ets.new/2 returns a reference not an atom
named_table: true,
keypos: 1,
# Tweaks
read_concurrency: true,
write_concurrency: true,
decentralized_counters: false,
compressed: false
]
# From https://erlang.org/doc/man/dets.html#info-2
@dets_info_items [
:access,
:auto_save,
:bchunk_format,
:hash,
:file_size,
:filename,
:keypos,
:memory,
:no_keys,
:no_objects,
:no_slots,
:owner,
:ram_file,
:safe_fixed,
:safe_fixed_monotonic_time,
:size,
:type
]
# From https://erlang.org/doc/man/ets.html#info-2
@ets_info_items [
:binary,
:compressed,
:decentralized_counters,
:fixed,
:heir,
:id,
:keypos,
:memory,
:name,
:named_table,
:node,
:owner,
:protection,
:safe_fixed,
:safe_fixed_monotonic_time,
:size,
:stats,
:type,
:write_concurrency,
:read_concurrency
]
# TODO:
# inc, dec
# map (apply function to each element in a table) :dets.traverse/2 | :ets.??? fun2ms + select_replace
# detect() -- all/0 -- census/0 load all known :ets and :dets tables into Pocket for reference/inspection
@doc """
Deletes the entry in table for a specific `key`
## Examples
iex> Pockets.new(:my_cache)
{:ok, :my_cache}
iex> Pockets.merge(:my_cache, %{a: "apple", b: "boy", c: "cat"})
:my_cache
iex> Pockets.to_map(:my_cache)
%{a: "apple", b: "boy", c: "cat"}
iex> Pockets.delete(:my_cache, :b)
:my_cache
iex> Pockets.to_map(:my_cache)
%{a: "apple", c: "cat"}
"""
@spec delete(table_alias :: alias, any) :: alias | {:error, any}
def delete(table_alias, key) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> case do
nil -> {:error, "Pockets table not found: #{table_alias}"}
table -> do_delete(table, key)
end
end
@doc """
Destroys the given table.
For disk-based (`:dets`) tables, this will delete the backing file.
For memory-based (`:ets`) tables, this destroys the table and its contents.
## Examples
iex> Pockets.new(:my_cache, "/tmp/cache.dets")
{:ok, :my_cache}
iex> Pockets.destroy(:my_cache)
:ok
"""
@spec destroy(table_alias :: alias) :: :ok | {:error, any}
def destroy(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_destroy()
|> case do
:ok -> Registry.unregister(table_alias)
{:error, error} -> {:error, error}
end
end
@doc """
Checks if the given table is empty.
## Examples
iex> {:ok, tid} = Pockets.new(:my_cache)
{:ok, :my_cache}
iex> Pockets.empty?(tid)
true
"""
@spec empty?(table_alias :: Pockets.alias()) :: boolean
def empty?(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_info(:size) == 0
end
@doc """
Gets the value for a specific `key` in the table.
"""
@spec get(table_alias :: alias, any, any) :: alias
def get(table_alias, key, default \\ nil) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_get(key, default)
end
@doc """
Checks if the table has the given key.
"""
@spec has_key?(table_alias :: alias, any) :: boolean
def has_key?(table_alias, key) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_has_key?(key)
end
@doc """
Gets info about the given table.
"""
@spec info(table_alias :: alias) :: EtsInfo.t() | DetsInfo.t()
def info(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_info()
end
@doc """
Gets info about the given `item` in the table. The available items depend on the type of table.
"""
@spec info(table_alias :: alias, atom) :: any
def info(table_alias, item) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_info(item)
end
@doc """
Gets a list of keys in the given table. For larger tables, consider `keys_stream/1`
"""
@spec keys(table_alias :: alias) :: list
def keys(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> get_keys_lazy()
|> Enum.to_list()
end
@doc """
Gets a list of keys in the given table as a stream.
"""
def keys_stream(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> get_keys_lazy()
end
@doc """
This is a powerful function that lets you merge `input` into an open table.
All data in the input will be added to the table: the keys in the `input` "have precedence"
over pre-existing keys in the table.
When the `input` to be merged is...
- an alias for another table, the contents from that table are added to the given `table_alias`
- a map, the contents from the map are added into the given `table_alias`
- a list, the contents from the list are added into the given `table_alias`
## Examples
# Merging a map into a table:
iex> Pockets.new(:my_cache)
{:ok, :my_cache}
iex> Pockets.merge(:my_cache, %{a: "apple", b: "boy", c: "cat"})
:my_cache
iex> Pockets.to_map(:my_cache)
%{a: "apple", b: "boy", c: "cat"}
# Merging two tables:
iex> Pockets.new(:my_first)
{:ok, :my_first}
iex> Pockets.merge(:my_first, %{a: "apple", b: "boy", c: "cat"})
:my_first
iex> Pockets.new(:my_second)
{:ok, :my_second}
iex> Pockets.merge(:my_second, %{x: "xray", y: "yellow", z: "zebra"})
:my_second
iex> Pockets.merge(:my_first, :my_second)
:my_first
iex> Pockets.to_map(:my_first)
%{a: "apple", b: "boy", c: "cat", x: "xray", y: "yellow", z: "zebra"}
"""
@spec merge(alias(), input :: alias | list | map) :: alias
def merge(table_alias, input) when is_alias(table_alias) and is_alias(input) do
table2 = Registry.lookup(input)
table_alias
|> Registry.lookup()
|> do_merge_tables(table2)
end
def merge(table_alias, input) when is_alias(table_alias) when is_map(input) or is_list(input) do
table_alias
|> Registry.lookup()
|> do_merge_enum(input)
end
@doc """
Creates a new table either in memory (default) or on disk.
The second argument specifies the storage mechanism for the table, either a path to a file (as a string)
for disk-backed tables (`:dets`), or in `:memory` for memory-backed tables (`:ets`).
The `opts` pertains to the type table that is being opened:
- `:memory` : default arguments: #{inspect(@default_ets_opts)}
- filepath : default arguments: #{inspect(@default_dets_opts)}
## Examples
iex> Pockets.new(:ram_cache, :memory)
{:ok, :ram_cache}
"""
@spec new(table_alias :: alias, :memory | binary, opts :: keyword) ::
{:ok, alias} | {:error, any}
def new(table_alias, storage \\ :memory, opts \\ [type: @default_table_type])
def new(table_alias, :memory, opts) when is_alias(table_alias) do
table_alias
|> Registry.exists?()
|> case do
true -> {:noop, "Table #{table_alias} already exists"}
false -> create_table(table_alias, opts, :ets)
end
end
def new(table_alias, file, opts) when is_alias(table_alias) and is_binary(file) do
opts = Keyword.merge(@default_dets_opts, opts)
type = Keyword.get(opts, :type, @default_table_type)
file
|> File.exists?()
|> case do
true ->
{:error, "File already exists"}
false ->
with :ok <- prepare_directory(file),
{:ok, tid} <- create_table(file, opts, :dets) do
Registry.register(
table_alias,
%Table{
library: :dets,
alias: table_alias,
tid: tid,
type: type,
opts: opts
}
)
else
{:error, error} -> {:error, error}
end
end
end
@doc """
Open a table for use. If the table does not exist, it will be created with the `opts` provided.
If the table has already been opened, a warning is issued.
The second argument specifies the storage mechanism for the table, either a path to a file (as a string)
if the table is to be stored in a file (i.e. a DETS table), or `:memory` if the table is to be kept only in memory
(i.e. an ETS table).
The `opts` pertains to the type table that is being opened:
- `:memory` : default arguments: #{inspect(@default_ets_opts)}
- filepath : default arguments: #{inspect(@default_dets_opts)}
## Examples
iex> Pockets.open(:my_cache)
{:ok, :my_cache}
iex> Pockets.open(:my_cache, :memory)
{:ok, :my_cache}
iex> Pockets.open(:disk_cache, "/tmp/cache.dets")
{:ok, :disk_cache}
"""
@spec open(Pocekts.alias(), :memory | binary, opts :: keyword) :: any
def open(table_alias, storage \\ :memory, opts \\ [type: @default_table_type])
# :ets.new/2 will throw an ArgumentError if you try to create the same named table more than once
# :ets
def open(table_alias, :memory, opts) when is_alias(table_alias) do
table_alias
|> :ets.info()
|> case do
:undefined -> create_table(table_alias, opts, :ets)
_ -> {:exists, table_alias}
end
end
# :dets
def open(table_alias, file, opts) when is_alias(table_alias) and is_binary(file) do
{create?, opts} =
@default_dets_opts
|> Keyword.merge(opts)
|> Keyword.pop(:create?, false)
file
|> File.exists?()
|> case do
true -> open_and_register_dets_file(table_alias, file, opts)
false -> maybe_create_and_register_dets_file(create?, table_alias, file, opts)
end
end
@doc """
Puts the given `value` under `key` in given table.
"""
@spec put(table_alias :: alias, any, any) :: alias
def put(table_alias, key, value) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_put(key, value)
end
@doc """
Both `:ets` and `:dets` files can be saved to disk. You can use this function to persist an in-memory `:ets`
file to disk for later use, or you can use it to make a copy of an existing `:dets` table.
The target file must not be in use by another table; if the target file exists this will return an error
unless the `:overwrite?` option is set to true.
Options:
- overwrite? default: `false`
"""
@spec save_as(table_alias :: alias, binary, keyword) :: :ok | {:error, any}
def save_as(table_alias, target_file, opts \\ [])
when is_alias(table_alias) and is_binary(target_file) do
table_alias
|> Registry.lookup()
|> case do
nil -> {:error, "Table alias not found #{table_alias}"}
table -> do_save_as(table, target_file, opts)
end
end
@doc """
Show all registered `Pockets` tables.
## Examples
iex> Pockets.show_tables()
[%Pockets.Table{alias: :my_cache, library: :ets, tid: :my_cache, type: :set}]
"""
def show_tables(_opts \\ []) do
Registry.list()
|> Map.values()
end
@doc """
Returns the size of the given table, measured by the number of entries.
"""
@spec size(table_alias :: Pockets.alias()) :: integer
def size(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> do_info(:size)
end
@doc """
Outputs the contents of the given table to a list.
Although this is useful for debugging purposes, for larger data sets consider using `to_stream/1` instead.
"""
@spec to_list(table_alias :: alias) :: list
def to_list(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> get_contents_lazy()
|> Enum.to_list()
end
@doc """
Outputs the contents of the table to a map.
Although this is useful for debugging purposes, for larger data sets consider using `to_stream/1` instead.
"""
@spec to_map(table_alias :: alias) :: map
def to_map(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> get_contents_lazy()
|> Enum.into(%{})
end
@doc """
Outputs the contents of the table to a stream for lazy evaluation.
"""
def to_stream(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> get_contents_lazy()
end
@doc """
Truncates the given table; this removes all entries from the table while leaving its options in tact.
## Examples
iex> Pockets.put(:my_cache, :a, "Apple") |> Pockets.put(:b, "boy") |> Pockets.put(:c, "Charlie")
:my_cache
iex> Pockets.truncate(:my_cache)
:my_cache
iex> Pockets.to_map(:my_cache)
%{}
"""
@spec truncate(table_alias :: alias) :: alias | {:error, any}
def truncate(table_alias) when is_alias(table_alias) do
table_alias
|> Registry.lookup()
|> case do
%{library: library, tid: tid} ->
case library.delete_all_objects(tid) do
:ok -> table_alias
true -> table_alias
{:error, error} -> {:error, error}
end
nil ->
{:error, "Table not found: #{table_alias}"}
end
end
# -----------------------------------------------------------------------------
defp open_and_register_dets_file(table_alias, file, opts) do
file_as_atom = String.to_atom(file)
with true <- :dets.is_dets_file(file_as_atom),
{:ok, tid} <- :dets.open_file(file_as_atom, opts) do
Registry.register(
table_alias,
%Table{
library: :dets,
alias: table_alias,
tid: tid,
type: Keyword.get(opts, :type, @default_table_type),
opts: opts
}
)
else
false -> {:error, "File is not a :dets file: #{file}"}
{:error, error} -> {:error, error}
end
end
defp maybe_create_and_register_dets_file(false, _, file, _) do
{
:error,
"File not found: #{file}. Will not create file unless `create?: true` given as option."
}
end
defp maybe_create_and_register_dets_file(true, table_alias, file, opts) do
with false <- is_file_already_in_use?(file),
:ok <- prepare_directory(file),
{:ok, tid} <- create_table(file, opts, :dets) do
Registry.register(
table_alias,
%Table{
library: :dets,
alias: table_alias,
tid: tid,
type: Keyword.get(opts, :type, @default_table_type),
opts: opts
}
)
else
true -> {:error, "File is already in use by another :dets table: #{file}"}
{:error, error} -> {:error, error}
end
end
defp create_table(file, opts, :dets) do
file
|> String.to_atom()
|> :dets.open_file(opts)
end
defp create_table(table_alias, opts, :ets) do
type = Keyword.get(opts, :type, @default_table_type)
opts =
@default_ets_opts
|> Keyword.merge(opts)
|> prepare_ets_options()
tid = :ets.new(table_alias, opts)
Registry.register(table_alias, %Table{
library: :ets,
alias: table_alias,
tid: tid,
type: type,
opts: opts
})
rescue
e in ArgumentError -> {:error, "Error creating :ets table: #{inspect(e)}"}
end
# Adapted from https://stackoverflow.com/questions/35122608/how-to-retrieve-a-list-of-ets-keys-without-scanning-entire-table
defp get_keys_lazy(%{tid: tid, library: library}) do
Stream.resource(
fn -> [] end,
fn acc ->
case acc do
[] ->
case library.first(tid) do
:"$end_of_table" -> {:halt, acc}
first_key -> {[first_key], first_key}
end
acc ->
case library.next(tid, acc) do
:"$end_of_table" -> {:halt, acc}
next_key -> {[next_key], next_key}
end
end
end,
fn _acc -> :ok end
)
end
defp get_contents_lazy(%{tid: tid, library: library}) do
Stream.resource(
fn -> [] end,
fn acc ->
case acc do
[] ->
case library.first(tid) do
:"$end_of_table" -> {:halt, acc}
first_key -> {library.lookup(tid, first_key), first_key}
end
acc ->
case library.next(tid, acc) do
:"$end_of_table" -> {:halt, acc}
next_key -> {library.lookup(tid, next_key), next_key}
end
end
end,
fn _acc -> :ok end
)
end
# :ets.new/2 does not take a nice keyword list as its options: it takes a mix of values. Yuck.
defp prepare_ets_options(opts) do
relevant_opts = Keyword.take(opts, Keyword.keys(@default_ets_opts))
prepped =
Keyword.take(
relevant_opts,
[
:read_concurrency,
:write_concurrency,
:decentralized_counters,
:keypos
]
) ++
[Keyword.get(opts, :type, @default_table_type)] ++
[Keyword.get(opts, :access, :public)]
[:compressed, :named_table]
|> Enum.reduce(
prepped,
fn x, acc ->
case Keyword.get(relevant_opts, x, false) do
false -> acc
true -> acc ++ [x]
end
end
)
end
@spec prepare_directory(binary) :: :ok | {:error, any}
defp prepare_directory(file) do
file
|> Path.dirname()
|> File.mkdir_p()
end
# :dets.delete returns :ok on success, {:error, reason} on fail
defp do_delete(%Table{library: :dets, alias: alias, tid: tid}, key) do
case :dets.delete(tid, key) do
:ok -> alias
{:error, error} -> {:error, error}
end
end
# :ets.delete returns true on success
defp do_delete(%Table{library: :ets, alias: alias, tid: tid}, key) do
case :ets.delete(tid, key) do
true -> alias
_ -> {:error, "There was a problem deleting the key #{key} from the table #{alias}"}
end
end
defp do_destroy(%Table{library: :dets, tid: tid} = table) do
with %DetsInfo{filename: filename} <- do_info(table),
:ok <- :dets.close(tid) do
filename
# <-- req'd???
|> to_string()
|> File.rm()
else
{:error, error} -> {:error, error}
end
end
defp do_destroy(%Table{library: :ets, alias: alias, tid: tid}) do
case :ets.delete(tid) do
true -> :ok
_ -> {:error, "There was a problem destroying the table #{alias}"}
end
end
# Shared functionality!
defp do_get(%Table{library: library, tid: tid}, key, default) do
# TODO: if :bag, value might be like `[x: 1, x: 2, x: 3]`
tid
|> library.lookup(key)
|> case do
[{^key, value}] -> value
_unrecognised_val -> default
end
end
defp do_has_key?(%Table{library: library, tid: tid}, key) do
library.member(tid, key)
end
defp do_info(%Table{library: :dets, tid: tid}) do
tid
|> :dets.info()
|> DetsInfo.new()
end
defp do_info(%Table{library: :ets, tid: tid}) do
tid
|> :ets.info()
|> EtsInfo.new()
end
defp do_info(%Table{library: :dets, tid: tid}, item) when item in @dets_info_items do
:dets.info(tid, item)
end
defp do_info(%Table{library: :ets, tid: tid}, item) when item in @ets_info_items do
:ets.info(tid, item)
end
defp do_merge_tables(t1, t2) do
t2
|> get_contents_lazy()
|> Enum.each(fn {k, v} -> do_put(t1, k, v) end)
t1.alias
end
defp do_merge_enum(%Table{alias: table_alias} = table, enumerable) do
enumerable
|> Enum.each(fn {k, v} ->
do_put(table, k, v)
end)
table_alias
end
# :dets.insert returns :ok on success, {:error, reason} on fail
defp do_put(%Table{library: :dets, tid: tid, alias: alias}, key, value) do
case :dets.insert(tid, {key, value}) do
:ok -> alias
{:error, error} -> {:error, error}
end
end
defp do_put(%Table{library: :ets, tid: tid, alias: alias}, key, value) do
case :ets.insert(tid, {key, value}) do
true -> alias
_ -> {:error, "There was a problem putting into your table #{alias}"}
end
end
# Save a copy : do a file operation
defp do_save_as(%Table{library: :dets, tid: tid, opts: dets_opts}, target_file, opts) do
source_file = to_string(:dets.info(tid)[:filename])
case source_file == target_file do
true ->
:dets.sync(tid)
false ->
with false <- is_file_already_in_use?(target_file),
:ok <- ok_to_write(target_file, opts),
:ok <- :dets.close(tid),
:ok <- File.cp(source_file, target_file),
{:ok, _} <- :dets.open_file(tid, dets_opts) do
:ok
else
true -> {:error, "File is in use by another open :dets table #{target_file}"}
{:error, error} -> {:error, error}
end
end
end
# Persist an :ets table to disk
defp do_save_as(%Table{library: :ets, tid: tid, type: type}, target_file, _opts) do
target_tid = String.to_atom(target_file)
case :dets.open_file(target_tid, type: type) do
{:ok, ^target_tid} ->
:dets.from_ets(target_tid, tid)
:dets.close(target_tid)
{:error, error} ->
{:error, error}
end
end
defp ok_to_write(file, opts) do
case File.exists?(file) do
true ->
case Keyword.get(opts, :overwrite?, false) do
true -> :ok
false -> {:error, "File exists: #{file} - will not overwrite unless `overwrite?: true`"}
end
false ->
:ok
end
end
# Is the file already used by any :dets table?
defp is_file_already_in_use?(file) do
file_as_atom = String.to_atom(file)
:dets.all()
|> Enum.find(
false,
fn x ->
x
|> :dets.info()
|> DetsInfo.new()
|> case do
%DetsInfo{filename: ^file_as_atom} -> true
_ -> false
end
end
)
end
end