Current section

Files

Jump to
mentat lib mentat.ex
Raw

lib/mentat.ex

defmodule Mentat do
@external_resource readme = "README.md"
@moduledoc readme
|> File.read!()
|> String.split("<!--MDOC !-->")
|> Enum.fetch!(1)
use Supervisor
use Oath
@type cache_opts() :: Keyword.t()
@type name :: atom()
@type key :: term()
@type value :: term()
@type put_opts :: [
{:ttl, pos_integer() | :infinity},
]
@default_limit %{reclaim: 0.1}
alias Mentat.Janitor
defp cache_opts do
import Norm
coll_of(
one_of([
{:name, spec(is_atom)},
{:cleanup_interval, spec(is_integer and & &1 > 0)},
{:ets_args, spec(is_list)},
{:ttl, one_of([spec(is_integer and & &1 > 0), :infinity])},
{:clock, spec(is_atom)},
{:limit, coll_of(one_of([
{:size, spec(is_integer and & &1 > 0)},
{:reclaim, spec(is_float)},
]))}
])
)
end
@doc false
def child_spec(opts) do
name = opts[:name] || raise ArgumentError, ":name is required"
%{
id: name,
type: :supervisor,
start: {__MODULE__, :start_link, [opts]},
}
end
@doc """
Starts a new cache.
Options:
* `:name` - the cache name as an atom. required.
* `:cleanup_interval` - How often the janitor process will remove old keys. Defaults to 5_000.
* `:ets_args` - Additional arguments to pass to `:ets.new/2`.
* `:ttl` - The default ttl for all keys. Default `:infinity`.
* `:limit` - Limits to the number of keys a cache will store. Defaults to `:none`.
* `:size` - The maximum number of values to store in the cache.
* `:reclaim` - The percentage of keys to reclaim if the limit is exceeded. Defaults to 0.1.
"""
@spec start_link(cache_opts()) :: Supervisor.on_start()
def start_link(args) do
args = Norm.conform!(args, cache_opts())
name = args[:name]
Supervisor.start_link(__MODULE__, args, name: name)
end
@doc """
Fetches a value or executes the fallback function. The function can return
either `{:commit, term()}` or `{:ignore, term()}`. If `{:commit, term()}` is
returned, the value will be stored in the cache before its returned. See the
"TTLs" section for a list of options.
Since `fetch` is logically the same as a `get` (and also a `put` if
`{:commit, term()}` is returned from the fallback function), it shares the
same Telemetry events (`[:mentat, :get]` and `[:mentat, :put]`).
## Example
```
Mentat.fetch(:cache, user_id, fn user_id ->
case get_user(user_id) do
{:ok, user} ->
{:commit, user}
error ->
{:ignore, error}
end
end)
```
"""
@spec fetch(name(), key(), put_opts(), (key() -> {:commit, value()} | {:ignore, value()})) :: value()
def fetch(cache, key, opts \\ [], fallback) do
{status, value} = lookup(cache, key)
if status == :hit do
value
else
case fallback.(key) do
{:commit, value} -> put(cache, key, value, opts)
{:ignore, value} -> value
end
end
end
@doc """
Retrieves a value from a the cache. Returns `nil` if the key is not found.
"""
@spec get(name(), key()) :: value()
def get(cache, key) do
{_status, value} = lookup(cache, key)
value
end
@doc """
Puts a new key into the cache. See the "TTLs" section for a list of
options.
"""
@spec put(name(), key(), value(), put_opts()) :: value() | no_return()
@decorate pre("ttls are positive", fn _, _, _, opts ->
if opts[:ttl], do: opts[:ttl] > 0, else: true
end)
@decorate post("value is returned", fn _, _, value, _, return ->
value == return
end)
def put(cache, key, value, opts \\ [])
def put(cache, key, value, opts) do
config = get_config(cache)
:telemetry.execute([:mentat, :put], %{}, %{key: key, cache: cache})
now = ms_time(config.clock)
ttl = opts[:ttl] || config.ttl
if ttl < 0 do
raise ArgumentError, "`:ttl` must be greater than 0"
end
true = :ets.insert(cache, {key, value, now, ttl})
# If we've reached the limit on the table, we need to purge a number of old
# keys. We do this by calling the janitor process and telling it to purge.
# This will, in turn call immediately back into the remove_oldest function.
# The back and forth here is confusing to follow, but its necessary because
# we want to do the purging in a different process.
if config.limit != :none && :ets.info(cache, :size) > config.limit.size do
count = ceil(config.limit.size * config.limit.reclaim)
Janitor.reclaim(janitor(cache), count)
end
value
end
@doc """
Updates a keys inserted at time. This is useful in conjunction with limits
when you want to evict the oldest keys. Returns `true` if the key was found
and `false` if it was not.
"""
@spec touch(name(), key()) :: boolean()
def touch(cache, key) do
config = get_config(cache)
now = ms_time(config.clock)
:ets.update_element(cache, key, {3, now})
end
@doc """
Deletes a key from the cache
"""
@spec delete(name(), key()) :: true
def delete(cache, key) do
:ets.delete(cache, key)
end
@doc """
Returns a list of all keys. By default this function only returns keys
that have no exceeded their TTL. You can pass the `all: true` option to the function
in order to return all present keys, which may include keys that have exceeded
their TTL but have not been purged yet.
"""
@spec keys(name()) :: [key()]
def keys(cache, opts \\ []) do
ms = if opts[:all] == true do
[{{:"$1", :_, :_, :_}, [], [:"$1"]}]
else
config = get_config(cache)
now = ms_time(config.clock)
[
{{:"$1", :_, :"$2", :"$3"},
[
{:orelse,
{:andalso, {:is_integer, :"$3"}, {:>, {:+, :"$2", :"$3"}, now}},
{:==, :"$3", :infinity}}
], [:"$1"]}
]
end
:ets.select(cache, ms)
end
@doc """
Removes all keys from the cache.
"""
@spec purge(name()) :: true
def purge(cache) do
:ets.delete_all_objects(cache)
end
@doc false
def remove_expired(cache) do
config = get_config(cache)
now = ms_time(config.clock)
# Find all expired keys by selecting the timestamp and ttl, adding them together
# and finding the keys that are lower than the current time
ms = [
{{:_, :_, :"$1", :"$2"},
[{:andalso, {:is_integer, :"$2"}, {:<, {:+, :"$1", :"$2"}, now}}], [true]}
]
:ets.select_delete(cache, ms)
end
@doc false
def remove_oldest(cache, count) do
ms = [{{:_, :_, :"$1", :_}, [], [:"$1"]}]
entries = :ets.select(cache, ms)
oldest =
entries
|> Enum.sort()
|> Enum.take(count)
|> List.last()
delete_ms = [{{:_, :_, :"$1", :_}, [{:"=<", :"$1", oldest}], [true]}]
:ets.select_delete(cache, delete_ms)
end
def init(args) do
name = args[:name]
interval = args[:cleanup_interval] || 5_000
limit = args[:limit] || :none
limit = if limit != :none, do: Map.merge(@default_limit, Map.new(limit)), else: limit
ets_args = args[:ets_args] || []
clock = args[:clock] || System
ttl = args[:ttl] || :infinity
^name = :ets.new(name, [:set, :named_table, :public] ++ ets_args)
put_config(name, %{limit: limit, ttl: ttl, clock: clock})
janitor_opts = [
name: janitor(name),
interval: interval,
cache: name
]
children = [
{Mentat.Janitor, janitor_opts}
]
Supervisor.init(children, strategy: :one_for_one)
end
def stop(name) do
Supervisor.stop(name)
end
defp lookup(cache, key) do
config = get_config(cache)
now = ms_time(config.clock)
{status, value} =
case :ets.lookup(cache, key) do
[] -> {:miss, nil}
[{^key, _val, ts, ttl}] when is_integer(ttl) and ts + ttl <= now -> {:miss, nil}
[{^key, val, _ts, _expire_at}] -> {:hit, val}
end
:telemetry.execute([:mentat, :get], %{status: status}, %{key: key, cache: cache})
{status, value}
end
defp put_config(cache, config) do
:persistent_term.put({__MODULE__, cache}, config)
end
defp get_config(cache) do
:persistent_term.get({__MODULE__, cache})
end
defp ms_time(clock) do
# Clock is going `System` in most cases and is set inside the init function
clock.monotonic_time(:millisecond)
end
defp janitor(name) do
:"#{name}_janitor"
end
end