Packages
finitomata
0.40.0
0.41.0
0.40.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.30.3
0.30.2
0.30.1
0.30.0
0.29.10
0.29.9
0.29.8
0.29.7
0.29.6
0.29.5
0.29.4
0.29.3
0.29.2
0.29.1
0.29.0
0.28.1
0.28.0
0.27.1
0.27.0
0.26.4
0.26.3
0.26.2
0.26.1
0.26.0
0.25.0
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.7
0.23.6
0.23.5
0.23.4
0.23.3
0.23.2
0.23.1
0.23.0
0.22.1
0.22.0
0.21.4
0.21.3
0.21.2
0.21.1
0.21.0
0.20.2
0.20.1
0.20.0
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.1
0.17.0
0.16.0
0.15.1
0.15.0
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.0
0.12.1
0.12.0
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.0
0.3.0
0.2.0
0.1.1
0.1.0
The FSM implementation generated from PlantUML textual representation.
Current section
Files
Jump to
Current section
Files
lib/infinitomata.ex
defmodule Infinitomata do
@moduledoc """
The sibling of `Finitomata`, but runs transparently in the cluster.
If you want to use a _stateful consistent hash ring_ like [`libring`](https://hexdocs.pm/libring),
implement the behaviour `Finitomata.ClusterInfo` wrapping calls to it and
invoke `Finitomata.ClusterInfo.init(Impl)` before using `Infinitomata.start_fsm/4`.
The example of such an implementation for `libring` (assuming the named ring `@ring`
has been started in the supervision tree) follows.
```elixir
defmodule MyApp.ClusterInfo do
@moduledoc false
@behaviour Finitomata.ClusterInfo
@impl Finitomata.ClusterInfo
def nodes(_fini_id), do: HashRing.nodes(@ring) -- [node()]
@impl Finitomata.ClusterInfo
def whois(_fini_id, id), do: HashRing.key_to_node(@ring, id)
end
```
## Consistency model
`Infinitomata` keeps a per-node view of the cluster-wide _FSM_ registry that is
**eventually consistent**. Membership changes are propagated via `:pg` and reconciled
by `synch/2`, which merges local and remote views (last writer wins per _FSM_ name).
A call addressed to an _FSM_ that has not propagated to the local view yet is retried;
on a failed remote call the node re-synchronizes and backs off before retrying. Tune
the retry behaviour via the `:infinitomata_attempts`, `:infinitomata_poll_interval`,
`:infinitomata_rpc_timeout`, `:infinitomata_backoff_base`, and `:infinitomata_backoff_max`
application config keys.
"""
@moduledoc since: "0.15.0"
@max_attempts Application.compile_env(:finitomata, :infinitomata_attempts, 1_000)
@rpc_timeout Application.compile_env(:finitomata, :infinitomata_rpc_timeout, 5_000)
@poll_interval Application.compile_env(:finitomata, :infinitomata_poll_interval, 1)
@backoff_base Application.compile_env(:finitomata, :infinitomata_backoff_base, 5)
@backoff_max Application.compile_env(:finitomata, :infinitomata_backoff_max, 1_000)
require Logger
alias Finitomata.{ClusterInfo, State, Transition}
alias Finitomata.Distributed.GroupMonitor, as: InfMon
alias Finitomata.Distributed.Supervisor, as: InfSup
alias Finitomata.Supervisor, as: FinSup
@behaviour Finitomata.Supervisor
@doc since: "0.16.0"
def start_link(id \\ nil, nodes \\ Node.list()) do
InfSup.start_link(id, nodes)
end
@doc since: "0.16.0"
@impl Finitomata.Supervisor
def child_spec(id \\ nil) do
Supervisor.child_spec({InfSup, id}, id: {InfSup, id})
end
defp distributed_call(fun, id, target, args \\ []) do
do_distributed_call(node(), fun, FinSup.infinitomata_name(id), target, args, @max_attempts)
end
defp do_distributed_call(:nonode@nohost, fun, id, target, args, _attempts) do
apply(Finitomata, fun, [id, target | args])
end
defp do_distributed_call(_this, _fun, _id, _target, _args, 0), do: {:error, :exhausted}
defp do_distributed_call(this, fun, id, target, args, attempts) do
case InfSup.get(id, target) do
%{node: node} ->
with {:badrpc, error} <-
:rpc.call(node, Finitomata, fun, [id, target | args], @rpc_timeout) do
Logger.error(
"[♻️] Distributed: " <> inspect(id: id, node: node, target: target, error: error)
)
:ok = synch(id)
backoff_sleep(attempts)
do_distributed_call(this, fun, id, target, args, attempts - 1)
end
nil ->
Process.sleep(@poll_interval)
do_distributed_call(this, fun, id, target, args, attempts - 1)
_ ->
{:error, :not_started}
end
end
# Exponentially backs off (with jitter, capped at `@backoff_max`) between retries that
# follow a failed RPC, so a struggling cluster is not hammered. The poll for a
# not-yet-synced FSM (the `nil` branch above) stays a tight loop because local sync
# usually completes near-instantly.
defp backoff_sleep(attempts) do
elapsed = max(0, @max_attempts - attempts)
base = min(@backoff_max, @backoff_base * Integer.pow(2, min(elapsed, 16)))
Process.sleep(base + :rand.uniform(max(1, div(base, 2))))
end
@doc since: "0.16.0"
@doc "Count of children"
def count(id \\ nil) do
id
|> FinSup.infinitomata_name()
|> InfMon.count()
end
@doc since: "0.16.0"
@impl Finitomata.Supervisor
@spec all(Finitomata.id()) :: %{
optional(Finitomata.fsm_name()) => %{pid: pid(), node: node(), reference: reference()}
}
def all(id \\ nil) do
id
|> FinSup.infinitomata_name()
|> InfSup.all()
end
@doc since: "0.18.0"
@doc "Returns the random _FSM_ from the pool"
def random(id \\ nil) do
id
|> all()
|> Map.keys()
|> case do
[] ->
Process.sleep(200)
random(id)
[_ | _] = nodes ->
Enum.random(nodes)
end
end
@doc since: "0.19.0"
@doc "Synchronizes the local `Infinitomata` instance with the cluster"
@spec synch(Finitomata.id(), [node()] | false) :: :ok
def synch(id \\ nil, nodes \\ false)
def synch(id, false) do
InfSup.synch(id, ClusterInfo.nodes(id), FinSup.infinitomata_name(id))
end
def synch(id, nodes) when is_list(nodes) do
InfSup.synch(id, nodes, FinSup.infinitomata_name(id))
end
@doc since: "0.15.0"
@doc """
Starts the _FSM_ in the distributed environment. See `Finitomata.start_fsm/4` for docs and options
"""
@impl Finitomata.Supervisor
@spec start_fsm(Finitomata.id(), Finitomata.fsm_name(), module(), any()) ::
DynamicSupervisor.on_start_child()
def start_fsm(fini_id \\ nil, target, implementation, payload) do
id = FinSup.infinitomata_name(fini_id)
case InfSup.get(id, target) do
nil ->
node = ClusterInfo.whois(fini_id, {id, target})
do_start_fsm(node == node(), node, id, target, implementation, payload)
%{node: node, pid: pid} ->
{:error, {:already_started, {node, pid}}}
end
end
@spec do_start_fsm(boolean(), node(), Finitomata.id(), Finitomata.fsm_name(), module(), any()) ::
DynamicSupervisor.on_start_child()
# NOTE: `:rpc.block_call/5` is used deliberately (not `:erpc.call/5`). `block_call` runs the
# MFA in the callee's long-lived `rex` process, so a remotely started/linked `Finitomata`
# supervision tree survives the call; `:erpc` runs in a transient process that would take the
# freshly linked processes down with it.
defp do_start_fsm(false, node, id, target, implementation, payload) do
case :rpc.block_call(
node,
Finitomata,
:start_fsm,
[id, target, implementation, payload],
@rpc_timeout
) do
{:ok, pid} ->
:ok = :rpc.block_call(node, :pg, :join, [InfSup.group(id), pid], @rpc_timeout)
{:ok, pid}
{:error, {:already_started, pid}} ->
{:error, {:already_started, {node, pid}}}
{error, reason} when error in [:error, :badrpc] ->
{:error, reason}
end
end
defp do_start_fsm(true, node, id, target, implementation, payload) do
case Finitomata.start_fsm(id, target, implementation, payload) do
{:ok, pid} ->
:ok = :pg.join(InfSup.group(id), pid)
{:ok, pid}
{:error, {:already_started, pid}} ->
{:error, {:already_started, {node, pid}}}
{:error, reason} ->
{:error, reason}
end
end
@doc since: "0.19.0"
@impl Finitomata.Supervisor
@spec timer_tick(Finitomata.id(), Finitomata.fsm_name()) :: :ok
def timer_tick(id \\ nil, target) do
distributed_call(:timer_tick, id, target)
end
@doc since: "0.15.0"
@impl Finitomata.Supervisor
@spec transition(
Finitomata.id(),
Finitomata.fsm_name(),
Transition.event() | {Transition.event(), State.payload()},
non_neg_integer()
) ::
:ok
def transition(id \\ nil, target, event_payload, delay \\ 0) do
distributed_call(:transition, id, target, [event_payload, delay])
end
@doc since: "0.15.0"
@impl Finitomata.Supervisor
def state(id \\ nil, target, reload? \\ :full) do
distributed_call(:state, id, target, [reload?])
end
@doc since: "0.26.0"
@impl Finitomata.Supervisor
def alive?(id \\ nil, target) do
distributed_call(:alive?, id, target)
end
end