Current section
Files
Jump to
Current section
Files
lib/perpetual.ex
defmodule Perpetual do
@moduledoc """
Perpetual is a simple abstraction around repeatedly iterating state.
It is similar to Elixir's `Agent` module in that it can share or store state
that must be accessed from different processes or by the same process at
different points in time, and in additiion to that, `Perpetual` lets you
define a function for repeatedly updating the stored state for as long as the
process is kept running.
The `Perpetual` module provides a basic server implementation that defines an
update function to be repeatedly applied, and allows current state to be
retrieved and updated manually via a simple API.
## Examples
For example, the following server implements an infinite counter:
defmodule Counter do
use Perpetual
def start_link(initial_count) do
args = [init_fun: fn -> initial_count end, next_fun: &(&1 + 1)]
Perpetual.start_link(args, name: __MODULE__)
end
def get_count do
Perpetual.get(__MODULE__, &(&1))
end
def stop do
Perpetual.stop(__MODULE__)
end
end
Usage would be:
Counter.start_link(0)
#=> {:ok, #PID<0.123.0>}
current_value = Counter.get_count
later_value = Counter.get_count
Counter.stop
#=> :ok
In the counter example above, the server will keep counting until the process
is stopped. Each call to `Counter.value()` would retrieve the current count.
Perpetual provides a segregation between the client and server APIs (similar to
`GenServer`s). In particular, the functions passed as arguments to the calls to
`Perpetual` functions are invoked inside the server. This distinction is
important because you may want to avoid expensive operations inside the
server for calls to get the current value, as they will effectively block the
server until the request is fulfilled. However, it is reasonable to do
expensive work as necessary in the `next_fun` function as that function's
work is the whole point of iterating perpetually--just be aware of the
blocking effect is has on other messages. `Perpetual` is designed to be
long-running and for clients to request the current state only periodically.
## How to supervise
A `Perpetual` server is most commonly started under a supervision tree.
When we invoke `use Perpetual`, it automatically defines a `child_spec/1`
function that allows us to start the server directly under a supervisor.
To start the Counter example under a supervisor with an initial counter of 0,
one may do:
children = [
{Counter, 0}
]
Supervisor.start_link(children, strategy: :one_for_all)
While one could also simply pass the `Counter` as a child to the supervisor,
such as:
children = [
Counter # Same as {Counter, []}
]
Supervisor.start_link(children, strategy: :one_for_all)
The definition above wouldn't work for this particular example,
as it would attempt to start the counter with an initial value
of an empty list. However, this may be a viable option in your
own servers. A common approach is to use a keyword list, as that
would allow setting the initial value and giving a name to the
counter process, for example:
def start_link(opts \\ []) do
{initial_count, opts} = Keyword.pop(opts, :initial_count, 0)
args = [init_fun: fn -> initial_count end, next_fun: &(&1 + 1)]
Perpetual.start_link(args, opts)
end
and then you can use `Counter`, `{Counter, name: :my_counter}` or
even `{Counter, initial_count: 0, name: :my_counter}` as a child
specification.
`use Perpetual` also accepts a list of options which configures the
child specification and therefore how it runs under a supervisor.
The generated `child_spec/1` can be customized with the following options:
* `:id` - the child specification identifier, defaults to the current module
* `:restart` - when the child should be restarted, defaults to `:permanent`
* `:shutdown` - how to shut down the child, either immediately or by giving it time to shut down
For example:
use Perpetual, restart: :transient, shutdown: 10_000
See the "Child specification" section in the `Supervisor` module for more
detailed information. The `@doc` annotation immediately preceding
`use Perpetual` will be attached to the generated `child_spec/1` function.
## Name registration
A perpetual server is bound to the same name registration rules as GenServers.
Read more about it in the `GenServer` documentation.
## A word on distributed perpetual servers
It is important to consider the limitations of distributed perpetual servers.
Like `Agent`s, `Perpetual` provides two APIs, one that works with anonymous
functions and another that expects an explicit module, function, and
arguments.
In a distributed setup with multiple nodes, the API that accepts anonymous
functions only works if the caller (client) and the server have the same
version of the caller module.
Keep in mind this issue also shows up when performing "rolling upgrades"
with perpetual servers. By rolling upgrades we mean the following situation:
you wish to deploy a new version of your software by *shutting down* some of
your nodes and replacing them with nodes running a new version of the
software. In this setup, part of your environment will have one version of a
given module and the other part another version (the newer one) of the same
module.
The best solution is to simply use the explicit module, function, and arguments
APIs when working with distributed perpetual servers.
## Hot code swapping
A perpetual server can have its code hot swapped live by simply passing a
module, function, and arguments tuple to the update instruction. For example,
imagine you have a server named `:sample` and you want to convert its inner
value from a keyword list to a map. It can be done with the following
instruction:
{:update, :sample, {:advanced, {Enum, :into, [%{}]}}}
The server's current value will be added to the given list of arguments
(`[%{}]`) as the first argument.
"""
@typedoc "The perpetual server's initial state function"
@type init_fun_or_mfa :: (() -> term) | {module, atom, [any]}
@typedoc "The perpetual server's next state function"
@type next_fun_or_mfa :: ((term) -> term) | {module, atom, [any]}
@typedoc "Return values of `start*` functions"
@type on_start :: {:ok, pid} | {:error, {:already_started, pid} | term}
@typedoc "The perpetual server name"
@type name :: atom | {:global, term} | {:via, module, term}
@typedoc "The perpetual server reference"
@type perpetual :: pid | {atom, node} | name
@typedoc "The perpetual server value"
@type state :: term
@doc """
Returns a specification to start a perpetual server under a supervisor.
See the "Child specification" section in the `Supervisor` module for more
detailed information.
"""
def child_spec(arg) do
%{
id: Perpetual,
start: {Perpetual, :start_link, [arg]}
}
end
@doc false
defmacro __using__(opts) do
quote location: :keep, bind_quoted: [opts: opts] do
unless Module.has_attribute?(__MODULE__, :doc) do
@doc """
Returns a specification to start this module under a supervisor.
See `Supervisor`.
"""
end
def child_spec(arg) do
default = %{
id: __MODULE__,
start: {__MODULE__, :start_link, [arg]}
}
Supervisor.child_spec(default, unquote(Macro.escape(opts)))
end
defoverridable child_spec: 1
end
end
@doc """
Starts a perpetual server linked to the current process with the given
function.
This is often used to start the server as part of a supervision tree.
Once the server is spawned, the given function `init_fun` is invoked in the
server process, and should return the initial server state. Note that
`start_link/2` does not return until the given function has returned.
Once the server is running, the given function `next_fun` is invoked
repeatedly in the server process in a loop. This function will be passed the
current state and should return the next state.
## Options
The `:name` option is used for registration as described in the module
documentation.
If the `:timeout` option is present, the server is allowed to spend at most
the given number of milliseconds on initialization or it will be terminated
and the start function will return `{:error, :timeout}`.
If the `:debug` option is present, the corresponding function in the
[`:sys` module](http://www.erlang.org/doc/man/sys.html) will be invoked.
If the `:spawn_opt` option is present, its value will be passed as options
to the underlying process as in `Process.spawn/4`.
## Return values
If the server is successfully created and initialized, the function returns
`{:ok, pid}`, where `pid` is the PID of the server. If an server with the
specified name already exists, the function returns
`{:error, {:already_started, pid}}` with the PID of that process.
If the given function callback fails, the function returns `{:error, reason}`.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> _value = Perpetual.get(pid, fn state -> state end)
iex> {:error, {exception, _stacktrace}} = Perpetual.start(init_fun: fn -> raise "oops" end, next_fun: &(&1 + 1))
iex> exception
%RuntimeError{message: "oops"}
"""
@spec start_link([init_fun: init_fun_or_mfa, next_fun: next_fun_or_mfa], GenServer.options()) :: on_start
def start_link(args, options \\ []) do
args = Keyword.take(args, [:init_fun, :next_fun])
GenServer.start_link(Perpetual.Server, args, options)
end
@doc """
Starts a perpetual server process without links (outside of a supervision
tree).
See `start_link/2` for more information.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> _value = Perpetual.get(pid, fn state -> state end)
"""
@spec start([init_fun: init_fun_or_mfa, next_fun: next_fun_or_mfa], GenServer.options()) :: on_start
def start(args, options \\ []) do
args = Keyword.take(args, [:init_fun, :next_fun])
GenServer.start(Perpetual.Server, args, options)
end
@doc """
Gets a perpetual server's value via the given anonymous function.
The function `fun` is sent to the `perpetual` which invokes the function
passing the server's state. The result of the function invocation is
returned from this function.
`timeout` is an integer greater than zero which specifies how many
milliseconds are allowed before the server executes the function and returns
the result value, or the atom `:infinity` to wait indefinitely. If no result
is received within the specified time, the function call fails and the caller
exits.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> _value = Perpetual.get(pid, fn state -> state end)
"""
@spec get(perpetual, (state -> a), timeout) :: a when a: var
def get(perpetual, fun, timeout \\ 5000) when is_function(fun, 1) do
GenServer.call(perpetual, {:get, fun}, timeout)
end
@doc """
Gets a perpetual server's value via the given function.
Same as `get/3` but a module, function, and arguments are expected
instead of an anonymous function. The state is added as first
argument to the given list of arguments.
"""
@spec get(perpetual, module, atom, [term], timeout) :: any
def get(perpetual, module, fun, args, timeout \\ 5000) do
GenServer.call(perpetual, {:get, {module, fun, args}}, timeout)
end
@doc """
Gets and updates the perpetual server's state in one operation via the given
anonymous function.
The function `fun` is sent to the `perpetual` which invokes the function
passing the current state. The function must return a tuple with two
elements, the first being the value to return (that is, the "get" value)
and the second one being the new state of the perpetual server.
`timeout` is an integer greater than zero which specifies how many
milliseconds are allowed before the server executes the function and returns
the result value, or the atom `:infinity` to wait indefinitely. If no result
is received within the specified time, the function call fails and the caller
exits.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> _current_value = Perpetual.get_and_update(pid, fn state -> {state, -1 * state} end)
iex> _later_value = Perpetual.get(pid, fn state -> state end)
"""
@spec get_and_update(perpetual, (state -> {a, state}), timeout) :: a when a: var
def get_and_update(perpetual, fun, timeout \\ 5000) when is_function(fun, 1) do
GenServer.call(perpetual, {:get_and_update, fun}, timeout)
end
@doc """
Gets and updates the perpetual state in one operation via the given function.
Same as `get_and_update/3` but a module, function, and arguments are expected
instead of an anonymous function. The state is added as first
argument to the given list of arguments.
"""
@spec get_and_update(perpetual, module, atom, [term], timeout) :: any
def get_and_update(perpetual, module, fun, args, timeout \\ 5000) do
GenServer.call(perpetual, {:get_and_update, {module, fun, args}}, timeout)
end
@doc """
Updates the perpetual server's state via the given anonymous function.
The function `fun` is sent to the `perpetual` which invokes the function
passing the current state. The return value of `fun` becomes the new
state of the server.
This function always returns `:ok`.
`timeout` is an integer greater than zero which specifies how many
milliseconds are allowed before the perpetual executes the function and returns
the result value, or the atom `:infinity` to wait indefinitely. If no result
is received within the specified time, the function call fails and the caller
exits.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> Perpetual.update(pid, fn state -> -1 * state end)
:ok
iex> _value = Perpetual.get(pid, fn state -> state end)
"""
@spec update(perpetual, (state -> state), timeout) :: :ok
def update(perpetual, fun, timeout \\ 5000) when is_function(fun, 1) do
GenServer.call(perpetual, {:update, fun}, timeout)
end
@doc """
Updates the perpetual server's state via the given function.
Same as `update/3` but a module, function, and arguments are expected
instead of an anonymous function. The state is added as first
argument to the given list of arguments.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> Perpetual.update(pid, Kernel, :*, [-1])
:ok
iex> _value = Perpetual.get(pid, fn state -> state end)
"""
@spec update(perpetual, module, atom, [term], timeout) :: :ok
def update(perpetual, module, fun, args, timeout \\ 5000) do
GenServer.call(perpetual, {:update, {module, fun, args}}, timeout)
end
@doc """
Performs a cast (*fire and forget*) operation on the perpetual server's
state.
The function `fun` is sent to the `perpetual` which invokes the function
passing the current state. The return value of `fun` becomes the new
state of the server.
Note that `cast` returns `:ok` immediately, regardless of whether `perpetual`
(or the node it should live on) exists.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> Perpetual.cast(pid, fn state -> -1 * state end)
:ok
iex> _value = Perpetual.get(pid, fn state -> state end)
"""
@spec cast(perpetual, (state -> state)) :: :ok
def cast(perpetual, fun) when is_function(fun, 1) do
GenServer.cast(perpetual, {:cast, fun})
end
@doc """
Performs a cast (*fire and forget*) operation on the perpetual server's
state.
Same as `cast/2` but a module, function, and arguments are expected
instead of an anonymous function. The state is added as first
argument to the given list of arguments.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> Perpetual.cast(pid, Kernel, :*, [-1])
:ok
iex> _value = Perpetual.get(pid, fn state -> state end)
"""
@spec cast(perpetual, module, atom, [term]) :: :ok
def cast(perpetual, module, fun, args) do
GenServer.cast(perpetual, {:cast, {module, fun, args}})
end
@doc """
Synchronously stops the perpetual server with the given `reason`.
It returns `:ok` if the server terminates with the given reason. If the
server terminates with another reason, the call will exit.
This function keeps OTP semantics regarding error reporting.
If the reason is any other than `:normal`, `:shutdown` or
`{:shutdown, _}`, an error report will be logged.
## Examples
iex> {:ok, pid} = Perpetual.start_link(init_fun: fn -> 0 end, next_fun: &(&1 + 1))
iex> Perpetual.stop(pid)
:ok
"""
@spec stop(perpetual, reason :: term, timeout) :: :ok
def stop(perpetual, reason \\ :normal, timeout \\ :infinity) do
GenServer.stop(perpetual, reason, timeout)
end
end