defmodule Pow.Store.Backend.EtsCache do
@moduledoc """
GenServer based key value ETS cache store with auto expiration.
This module is not recommended for production, but mostly used as an example
for how to build a cache. All data is stored in-memory, so cached values are
not shared between machines.
## Configuration options
* `:ttl` - integer value in milliseconds for ttl of records. If this value
is not provided, or is set to nil, the records will never expire.
* `:namespace` - value to use for namespacing keys. Defaults to "cache".
* `:writes` - set to `:async` to do asynchronous writes. Defauts to
`:sync`.
"""
use GenServer
alias Pow.{Config, Store.Backend.Base}
@behaviour Base
@ets_cache_tab __MODULE__
@spec start_link(Base.config()) :: GenServer.on_start()
def start_link(config) do
GenServer.start_link(__MODULE__, config, name: __MODULE__)
end
@impl Base
def put(config, record_or_records) do
case Config.get(config, :writes, :sync) do
:sync ->
GenServer.call(__MODULE__, {:cache, config, record_or_records})
:async ->
GenServer.cast(__MODULE__, {:cache, config, record_or_records})
end
end
@impl Base
def delete(config, key) do
case Config.get(config, :writes, :sync) do
:sync ->
GenServer.call(__MODULE__, {:delete, config, key})
:async ->
GenServer.cast(__MODULE__, {:delete, config, key})
end
end
@impl Base
def get(config, key) do
table_get(key, config)
end
@impl Base
def all(config, match) do
table_all(match, config)
end
# Callbacks
@impl GenServer
@spec init(Base.config()) :: {:ok, map()}
def init(_config) do
init_table()
{:ok, %{invalidators: %{}}}
end
@impl GenServer
@spec handle_call({:cache, Base.config(), Base.record() | [Base.record()]}, GenServer.from(), map()) :: {:noreply, map()}
def handle_call({:cache, config, record_or_records}, _from, state) do
{:noreply, state} = handle_cast({:cache, config, record_or_records}, state)
{:reply, :ok, state}
end
@spec handle_call({:delete, Base.config(), Base.key()}, GenServer.from(), map()) :: {:noreply, map()}
def handle_call({:delete, config, key}, _from, state) do
{:noreply, state} = handle_cast({:delete, config, key}, state)
{:reply, :ok, state}
end
@impl GenServer
@spec handle_cast({:cache, Base.config(), Base.record() | [Base.record()]}, map()) :: {:noreply, map()}
def handle_cast({:cache, config, record_or_records}, %{invalidators: invalidators} = state) do
invalidators =
record_or_records
|> table_insert(config)
|> Enum.reduce(invalidators, &append_invalidator(elem(&1, 0), &2, config))
{:noreply, %{state | invalidators: invalidators}}
end
@spec handle_cast({:delete, Base.config(), Base.key()}, map()) :: {:noreply, map()}
def handle_cast({:delete, config, key}, %{invalidators: invalidators} = state) do
invalidators =
key
|> table_delete(config)
|> clear_invalidator(invalidators)
{:noreply, %{state | invalidators: invalidators}}
end
@impl GenServer
@spec handle_info({:invalidate, Base.config(), Base.key()}, map()) :: {:noreply, map()}
def handle_info({:invalidate, config, key}, %{invalidators: invalidators} = state) do
invalidators =
key
|> table_delete(config)
|> clear_invalidator(invalidators)
{:noreply, %{state | invalidators: invalidators}}
end
defp table_get(key, config) do
ets_key = ets_key(config, key)
case :ets.lookup(@ets_cache_tab, ets_key) do
[{^ets_key, value}] -> value
[] -> :not_found
end
end
defp table_all(key_match, config) do
ets_key_match = ets_key(config, key_match)
@ets_cache_tab
|> :ets.select([{{ets_key_match, :_}, [], [:"$_"]}])
|> Enum.map(fn {key, value} -> {unwrap(key), value} end)
end
defp unwrap([_namespace, key]), do: key
defp unwrap([_namespace | key]), do: key
defp table_insert(record_or_records, config) do
records = List.wrap(record_or_records)
ets_records = Enum.map(records, fn {key, value} ->
{ets_key(config, key), value}
end)
:ets.insert(@ets_cache_tab, ets_records)
records
end
defp table_delete(key, config) do
ets_key = ets_key(config, key)
:ets.delete(@ets_cache_tab, ets_key)
key
end
defp init_table do
:ets.new(@ets_cache_tab, [:ordered_set, :protected, :named_table])
end
defp ets_key(config, key) do
[namespace(config) | List.wrap(key)]
end
defp namespace(config), do: Config.get(config, :namespace, "cache")
defp append_invalidator(key, invalidators, config) do
case Config.get(config, :ttl) do
nil ->
invalidators
ttl ->
invalidators = clear_invalidator(key, invalidators)
invalidator = trigger_ttl(key, ttl, config)
Map.put(invalidators, key, invalidator)
end
end
defp trigger_ttl(key, ttl, config) do
Process.send_after(self(), {:invalidate, config, key}, ttl)
end
defp clear_invalidator(key, invalidators) do
case Map.get(invalidators, key) do
nil -> nil
invalidator -> Process.cancel_timer(invalidator)
end
Map.delete(invalidators, key)
end
# TODO: Remove by 1.1.0
@deprecated "Use `put/2` instead"
@doc false
def put(config, key, value), do: put(config, {key, value})
# TODO: Remove by 1.1.0
@deprecated "Use `all/2` instead"
@doc false
def keys(config), do: all(config, :_)
end