Current section

Files

Jump to
finitomata lib finitomata cache.ex
Raw

lib/finitomata/cache.ex

defmodule Finitomata.Cache do
@moduledoc since: "0.26.0"
@moduledoc """
The self-curing cache based on `Finitomata` implementation.
This implementation should not be chosen for typical caching scenarios,
use [`cachex`](https://hexdocs.pm/cachex) and/or [`con_cache`](https://hexdocs.pm/con_cache)
instead.
The use-case for this implementation would be somewhat like a self-updated local replica
of the remote data. Unlike typical cache implementations, this one might keep the cached
values up-to-date, configured by `ttl:` argument. Bsaed on processes (backed by `Finitomata`,)
this implementation updates itself periodically, making the value retrieval almost instant.
Consider a remote service supplying currency exchange rates by polling. One might instruct
`Finitomata.Cache` to retrieve values periodically (say, once per a minute,) and then
the consumers of this cache would be able to retrieve the up-to-date values locally without
a penalty of getting a value after a long period (cache miss.)
First of all, the `Finitomata.Cache` implementation should be added to a supervision tree
```elixir
{Finitomata.Cache, [
[id: MyCache, ttl: 60_000, live?: true, type: Infinitomata, getter: &MyMod.getter/1]]}
```
Once the supervisor is started, the values might be retrieven as
```elixir
Finitomata.Cache.get(MyCache, :my_key_1, live?: false) # use default getter
Finitomata.Cache.get(MyCache, :my_key, getter: fn _ -> ExtService.get(:my_key) end)
```
"""
defmodule Config do
@moduledoc false
@spec init(id :: Finitomata.id(), opts :: keyword()) :: :ok
def init(id, opts \\ []) do
:persistent_term.put({__MODULE__, id}, opts)
end
@spec get(id :: Finitomata.id()) :: keyword()
def get(id), do: :persistent_term.get({__MODULE__, id}, [])
end
defmodule Value do
@moduledoc false
@fsm """
idle --> |init!| ready
ready --> |set| set
set --> |ready!| ready
ready --> |stop| done
"""
use Finitomata,
fsm: @fsm,
auto_terminate: true,
timer: 1,
impl_for: [:on_transition],
listener: :mox
defstruct key: nil,
value: :error,
since: nil,
getter: nil,
live?: false,
ttl: Application.compile_env(:finitomata, :cache_ttl, 5_000)
@impl Finitomata
def on_transition(:idle, :init!, _nil, %Value{} = state) do
{:ok, :ready, state}
end
def on_transition(:idle, :init!, _nil, state) do
{:ok, :ready, struct!(__MODULE__, state)}
end
@impl Finitomata
def on_transition(:ready, :set, {getter, live?, value}, %__MODULE__{} = state)
when is_function(getter, 1) do
{:ok, :set,
%__MODULE__{
state
| since: DateTime.utc_now(),
value: {:ok, value},
getter: getter,
live?: live?
}}
end
def on_transition(:ready, :set, getter, %__MODULE__{key: key} = state)
when is_function(getter, 1) do
on_transition(:ready, :set, {getter, state.live?, getter.(key)}, state)
end
def on_transition(:ready, :set, _, %__MODULE__{} = state) do
{:ok, :set, %__MODULE__{state | since: DateTime.utc_now(), value: :error}}
end
@impl Finitomata
def on_timer(:ready, %{timer: {_, 1}, payload: %__MODULE__{ttl: ttl}}) do
{:reschedule, ttl}
end
def on_timer(:ready, %{payload: %__MODULE__{getter: getter, live?: true} = payload} = state) do
Logger.debug(
"Cache value for " <>
inspect(Finitomata.State.human_readable_name(state, false)) <> " is to be renewed"
)
{:transition, {:set, getter}, payload}
end
def on_timer(:ready, %{payload: %__MODULE__{live?: false} = payload} = state) do
Logger.debug(
"Cache value for " <>
inspect(Finitomata.State.human_readable_name(state, false)) <> " is to be unset"
)
{:transition, {:set, :error}, payload}
end
def on_timer(_, %{payload: payload}), do: {:ok, payload}
end
require Logger
schema = [
id: [
required: true,
type: :any,
doc:
"The unique `ID` of this _Finitomata_ “branch,” when `nil` the `#{inspect(__MODULE__)}` value would be used"
],
type: [
required: false,
default: Infinitomata,
type: {:custom, Finitomata, :behaviour, [Finitomata.Supervisor]},
doc:
"The actual `Finitomata.Supervisor` implementation (typically, `Finitomata` or `Infinitomata`)"
],
ttl: [
required: true,
type: :pos_integer,
doc:
"The default time-to-live value in seconds, after which the value would be either revalidated or discarded"
],
live?: [
required: false,
default: false,
type: :boolean,
doc:
"When `true`, the value will be automatically renewed upon expiration (and discarded otherwise)"
],
getter: [
required: false,
default: nil,
type: {:or, [{:fun, 1}, {:in, [nil]}]},
doc:
"The shared for all instances getter returning a value based on the name of the instance, used as a key"
]
]
@schema NimbleOptions.new!(schema)
@doc """
Supervision tree embedder.
## Options to `Finitomata.Cache.start_link/1`
#{NimbleOptions.docs(@schema)}
"""
@spec start_link([unquote(NimbleOptions.option_typespec(@schema))]) :: Supervisor.on_start()
def start_link(opts \\ []) do
opts = NimbleOptions.validate!(opts, @schema)
id = Keyword.fetch!(opts, :id)
type = Keyword.fetch!(opts, :type)
Config.init(id, opts)
type.start_link(id)
end
@spec child_spec([unquote(NimbleOptions.option_typespec(@schema))]) :: Supervisor.child_spec()
def child_spec(opts \\ []) do
opts = NimbleOptions.validate!(opts, @schema)
id = Keyword.fetch!(opts, :id)
%{
id: {__MODULE__, id},
start: {__MODULE__, :start_link, [opts]}
}
end
@spec opts(id :: Finitomata.id()) :: [unquote(NimbleOptions.option_typespec(@schema))]
@doc false
defp opts(id), do: Config.get(id)
@doc """
Retrieves the value either cached or via `getter/1` anonymous function and caches it.
Spawns the respective _Finitomata_ instance if needed.
"""
@spec get(
id :: Finitomata.id(),
key :: key,
opts :: [
{:getter, (key -> value)}
| {:live?, boolean()}
| {:reset, boolean()}
| {:ttl, pos_integer()}
]
) :: {DateTime.t(), value} | {:instant, value} | :error
when key: any(), value: any()
def get(id, key, opts \\ []) do
opts = id |> opts() |> Keyword.merge(opts)
{reset, opts} = Keyword.pop(opts, :reset, false)
{getter, opts} = Keyword.pop(opts, :getter, nil)
ttl = Keyword.fetch!(opts, :ttl)
live? = Keyword.fetch!(opts, :live?)
type = Keyword.fetch!(opts, :type)
maybe_start =
if not type.alive?(id, key) do
type.start_fsm(
id,
key,
Value,
struct!(Value, key: key, getter: getter, live?: live?, ttl: ttl)
)
end
case maybe_start do
{:ok, _pid} ->
if is_function(getter, 1) do
{:created, tap(getter.(key), &type.transition(id, key, {:set, {getter, live?, &1}}))}
else
Logger.error("Initial call to `Finitomata.Cache.get/3` must contain a getter")
type.transition(id, key, :stop)
:error
end
{:error, error} when not is_tuple(error) ->
Logger.warning("Could not start FSM. Error: " <> inspect(error))
:error
# nil | {:error, {:already_started, _pid}}
_ ->
case {reset, getter, type.state(id, key, :payload)} do
{false, nil, %Value{since: since, value: {:ok, value}}} ->
{since, value}
{false, getter, %Value{since: since, value: {:ok, value}}} ->
Logger.warning(
"Setting a `getter` without `reset` does not make any sense, got: " <>
inspect(getter)
)
{since, value}
{_, getter, %Value{}} when is_function(getter, 1) ->
{:instant, tap(getter.(key), &type.transition(id, key, {:set, {getter, live?, &1}}))}
{_, nil, %Value{getter: getter}} when is_function(getter, 1) ->
{:instant, tap(getter.(key), &type.transition(id, key, {:set, {getter, live?, &1}}))}
_ ->
Logger.warning("`getter` must be either a function of arity `0` or `nil`")
:error
end
end
end
@doc false
@spec get_naive(
id :: Finitomata.id(),
key :: key,
opts :: [
{:getter, (key -> value)}
| {:live?, boolean()}
| {:reset, boolean()}
| {:ttl, pos_integer()}
]
) :: {DateTime.t(), value} | {:instant, value} | :error
when key: any(), value: any()
def get_naive(id, key, opts \\ []) do
opts = id |> opts() |> Keyword.merge(opts)
{reset, opts} = Keyword.pop(opts, :reset, false)
{getter, opts} = Keyword.pop(opts, :getter, nil)
ttl = Keyword.fetch!(opts, :ttl)
live? = Keyword.fetch!(opts, :live?)
type = Keyword.fetch!(opts, :type)
id
|> type.start_fsm(
key,
Value,
struct!(Value, key: key, getter: getter, live?: live?, ttl: ttl)
)
|> case do
{:ok, _pid} ->
if is_function(getter, 1) do
{:created, tap(getter.(key), &type.transition(id, key, {:set, {getter, live?, &1}}))}
else
Logger.warning("Initial call to `Finitomata.Cache.get/3` must contain a getter")
type.transition(id, key, :stop)
:error
end
{:error, {:already_started, _pid}} ->
case {reset, getter, type.state(id, key, :payload)} do
{false, nil, %Value{since: since, value: {:ok, value}}} ->
{since, value}
{false, getter, %Value{since: since, value: {:ok, value}}} ->
Logger.warning(
"Setting a `getter` without `reset` does not make any sense, got: " <>
inspect(getter)
)
{since, value}
{_, getter, %Value{}} when is_function(getter, 1) ->
{:instant, tap(getter.(key), &type.transition(id, key, {:set, {getter, live?, &1}}))}
{_, nil, %Value{getter: getter}} when is_function(getter, 1) ->
{:instant, tap(getter.(key), &type.transition(id, key, {:set, {getter, live?, &1}}))}
_ ->
Logger.warning("`getter` must be either a function of arity `0` or `nil`")
:error
end
{:error, error} ->
Logger.warning("Could not start FSM. Error: " <> inspect(error))
:error
end
end
@doc """
Erases the cache assotiated with `key`.
"""
@spec erase(id :: Finitomata.id(), key :: any()) :: :ok
def erase(id, key) do
type = id |> opts() |> Keyword.fetch!(:type)
type.transition(id, key, :stop)
end
end