Current section
Files
Jump to
Current section
Files
lib/retort/client/state_machine.ex
defmodule Retort.Client.StateMachine do
@moduledoc """
Manipulates the remote state machines over RPC
"""
alias Retort.Client.StateMachine.Transition
# Constants
@default_timeout 5_000 # milliseconds
# Struct
defstruct current_goal: nil,
data: nil,
pid: nil,
pid_by_transition: %{},
predecessor_by_state: %{},
previous_goals: [],
update: &__MODULE__.update/1,
timeout: @default_timeout
# Types
@typedoc """
State in `data` struct.
"""
@type state :: String.t
@typedoc """
Data
"""
@type data :: %{
required(:__struct__) => atom,
required(:state) => state,
optional(atom) => any
}
@typedoc """
Maps next transition from the current state to the current goal to the `pid` that is authorized to do that transition.
"""
@type pid_by_transition :: %{optional(Transition.t) => pid}
@typedoc """
* `current_goal` - the current `state` we want `data` to have.
* `data` - the `data` for `pid` that has a `state` field.
* `pid` - the fallback RPC client pid, used to update the state of `data` towards the `current_goal` if
`pid_by_transition` does not have an entry for transition from `data` `state` to `current_goal`.
* `pid_by_transition` - Looks up pid to use to update from `data` `state` to `current_goal`. If `pid_by_transition`
does not have an entry for `data` `state` to `current_goal`, then `pid` is used.
* `predecessor_by_state` - the `state` that can transition to key `state`
* `previous_goals` - a list of the previous `current_goal`s that couldn't be transitioned to directly because the
predecessor in `precessor_by_state` was not `data`'s `state`.
* `update` - callback that takes `t` and updates the state to `current_goal`.
"""
@type t :: %__MODULE__{
current_goal: String.t,
data: data,
pid: pid | nil,
pid_by_transition: pid_by_transition,
predecessor_by_state: %{},
previous_goals: [state],
update: (t -> ({:ok, data} | Retort.Client.Generic.error))
}
@doc """
Transitions `data`'s `state` to `current_goal`. If `data`'s `state` is a not direct predecessor of `current_goal`,
then a path is found to get `data`'s `state` to `current_goal` using `predecessor_by_state`.
"""
@spec transition(t) :: {:ok, data} | Retort.Client.Generic.error
def transition(%__MODULE__{data: data = %_{state: goal}, current_goal: goal, previous_goals: []}) do
{:ok, data}
end
def transition(
state_machine = %__MODULE__{
current_goal: goal,
data: %_{
state: current
},
predecessor_by_state: predecessor_by_state,
previous_goals: [],
update: update
}
) do
case Map.fetch!(predecessor_by_state, goal) do
^current ->
update.(state_machine)
predecessor ->
transition(%{state_machine | current_goal: predecessor, previous_goals: [goal]})
end
end
def transition(
state_machine = %__MODULE__{
data: %_{
state: current
},
current_goal: current_goal,
predecessor_by_state: predecessor_by_state,
previous_goals: previous_goals,
update: update
}
) do
case Map.fetch!(predecessor_by_state, current_goal) do
^current ->
with {:ok, current_goal_data} <- update.(state_machine) do
[new_goal | new_previous_goals] = previous_goals
transition(
%{state_machine | current_goal: new_goal, data: current_goal_data, previous_goals: new_previous_goals}
)
end
predecessor ->
transition(%{state_machine | current_goal: predecessor, previous_goals: [current_goal | previous_goals]})
end
end
@spec update(t) :: {:ok, data} | Retort.Client.Generic.error | no_return
def update(
%__MODULE__{
current_goal: current_goal,
data: %_{
id: id,
state: state
},
pid: pid,
pid_by_transition: pid_by_transition,
timeout: timeout
}
) do
update_pid = Map.get(pid_by_transition, %Transition{from: state, to: current_goal}, pid)
if is_nil(update_pid) do
raise ArgumentError,
"There is no entry in `pid_by_transition` (#{inspect(pid_by_transition)}) for transition " <>
"from `state` (#{inspect(state)}) to `current_goal` (#{inspect(current_goal)}) and " <>
"there is no fallback `pid` (#{inspect(pid)})"
end
Retort.Client.Generic.update(
update_pid,
to_string(id),
%{"state" => current_goal},
%{},
timeout
)
end
end