View Source Protean behaviour (Protean v0.0.3)
Caveat emptor: Protean is a library for personal learning and exploration, not (yet) for doing Serious Work.
An experimental Elixir library for managing state and side-effects through the use of event-driven statecharts. It is heavily inspired by XState, a robust JavaScript/TypeScript statechart implementation, but strays to adhere to Elixir idioms and OTP conventions. Protean also attempts to follow the SCXML standard, though not completely.
What are statecharts? They are an extension to finite state machines that allow you to model complex behavior in a declarative, data-driven manner. They include nested and parallel states, enhanced/augmented state (through context), side-effects (through actions), process management (through invoke), and more. To learn more about statecharts, I recommend statecharts.dev.
goals
Goals
This project is currently an exploration of statecharts as they fit into the context of Elixir and OTP. XState adopted the actor model in its implementation, so Elixir seemed like a natural fit. However, it may be that Elixir/OTP makes these abstractions unnecessary.
example
Example
Add Protean.Supervisor
under your application supervisor.
This starts a supervisor that is used by Protean internally to manage subprocesses.
children = [
Protean.Supervisor,
# ...
]
This simple statechart has a single state that defines the behavior of a counter with an optional maximum and minimum.
defmodule Counter do
use Protean
alias Protean.Action
@machine [
initial: "active",
context: [
count: 0,
min: nil,
max: nil
],
states: [
active: [
on: [
{"Inc", actions: :increment, guard: [not: :at_max]},
{"Dec", actions: :decrement, guard: [not: :at_min]},
{match({"Set", _}), actions: :set_min_or_max},
{match({"Log", _}), actions: :log}
]
]
]
]
@impl true
def handle_action(:increment, state, _event), do: Action.assign_in(state, [:count], & &1 + 1)
def handle_action(:decrement, state, _event), do: Action.assign_in(state, [:count], & &1 - 1)
def handle_action(:set_min_or_max, state, {"Set", {key, val}}) do
state
|> Action.assign(key, val)
end
def handle_action(:log, state, {"Log", attribute}) do
%{context: context} = state
IO.puts("#{attribute}: #{context[attribute]}")
state
end
@impl true
def guard(:at_max, %{context: %{max: max, count: count}}, _event) do
max && count >= max
end
def guard(:at_min, %{context: %{min: min, count: count}}, _event) do
min && count <= min
end
end
It can be started under a supervisor, but we'll start it directly.
{:ok, pid} = Protean.start_link(Counter)
Protean.current(pid).context
# %{count: 0, min: nil, max: nil}
Protean.send(pid, "Inc")
# :ok
Enum.each(1..4, fn _ -> Protean.send(pid, "Inc") end)
Protean.current(pid).context
# %{count: 5, min: nil, max: nil}
Protean.call(pid, {"Set", {:max, 10}})
# %Protean.State{
# context: %{count: 5, max: 10, min: nil},
# event: {"Set", {:max, 10}},
# value: MapSet.new([["active", "#"]])
# }
Enum.each(1..20, fn _ -> Protean.send(pid, "Inc") end)
Protean.send(pid, {"Log", :count})
# count: 10
defining-a-statechart
Defining a statechart
Protean machines are event-driven statecharts, which means that, unlike ordinary finite-state machines, they can have complex, nested, potentially parallel states. This is more easily visualized than read, and I highly recommend looking at XState's introduction to state machines and statecharts for that reason.
todo-states
TODO: States
todo-transitions
TODO: Transitions
todo-guards-and-automatic-transitions
TODO: Guards and automatic transitions
todo-actions
TODO: Actions
todo-invoked-processes
TODO: Invoked processes
starting-supervised-machines
Starting supervised machines
Just like GenServer
, Protean machines will be most often started under a supervision tree.
Invoking use Protean
will automatically define a child_spec/1
function that allows you to start the process directly under a supervisor.
children = [
Counter
]
Supervisor.start_link(children, strategy: :one_for_one)
Protean machines also accept the same options as Protean.start_link/2
.
See those docs for more details.
For instance, here's how you could start the Counter
with a custom name:
children = [
# Start the Counter machine
{Counter, name: MyCounter}
]
Supervisor.start_link(children, strategy: :one_for_one)
Protean.current(MyCounter)
# %Protean.State{
# context: %{count: 0, max: nil, min: nil},
# event: "$protean.init",
# value: MapSet.new([["active", "#"]])
# }
interacting-with-protean-machines
Interacting with Protean machines
Under the hood, a Protean machine is a GenServer
, and Protean
exposes a similar set of functions for interacting with one.
You can see the individual docs for the functions in this module for details on their behavior, but here are some highlights.
familiar-functions
Familiar functions
call/3
- Send an event synchronously to a Protean machine and receive the machine state and any replies resulting from transition.send/2
- Send an event asynchronously to a Protean machine. Always returns:ok
.send_after/3
- Send an event to a Protean machine after a given delay. LikeProcess.send_after/4
, returns a timer reference so that the send can be canceled withProcess.cancel_timer/2
.
additional-functions-specific-to-protean-machines
Additional functions specific to Protean machines
current/1
- Get the current machine state of a running Protean machine.matches?/2
- Query the currently active state(s) of a machine.subscribe/2
(andunsubscribe/2
) - Subscribes the calling process to receive a message on every state transition.
protean-supervisor
Protean Supervisor
Protean uses a DynamicSupervisor
to manage internally spawned processes (often spawned through the use of :invoke
).
The simplest thing to do is to add Protean.Supervisor
in your application supervision tree:
def start(_type, _args) do
children = [
Protean.Supervisor,
# ...
]
Supervisor.start_link(children, strategy: :one_for_one)
end
This will start the supervisor under the name Protean.Supervisor
and no additional configuration will be required.
If you would like to start multiple supervisors, or a different type of supervisor (like a fancy PartitionSupervisor
), you can pass the new name as an option when starting a machine.
Here's how that might look using the counter example from before.
# in your supervision tree
children = [
{Protean.Supervisor, name: ProteanSupervisor1},
{Protean.Supervisor, name: ProteanSupervisor2}
]
# starting the counter
Protean.start_link(Counter, supervisor: ProteanSupervisor1)
In the above example, any processes that are spawned by the Protean interpreter running Counter
will use ProteanSupervisor1
.
Link to this section Summary
Types
Any message sent to a Protean machine.
Option values for Protean machines.
A running Protean machine process.
Option values for start*
functions.
Option values for subscribe/2
.
Option values for use Protean
.
Callbacks
Optional callback for defining dynamic delays.
Optional callback to determine whether a conditional transition should occur.
Optional callback for actions specified in response to a transition.
Optional callback for invoked processes specified during machine execution.
Functions
Makes a synchronous call to the machine, awaiting any transitions that result.
Synchronously retrieve the current machine state.
Helper macro to allow match expressions on events during machine definition.
Returns true if the machine is currently in the given state.
Sends an asynchronous event to the machine.
Sends an event to the machine after time
in milliseconds has passed.
Start a Protean machine linked to the current process.
Subscribes the caller to a running machine, returning a reference.
Unsubscribes the caller from the machine.
Link to this section Types
@type event() :: term()
Any message sent to a Protean machine.
@type machine_option() :: {:context, Protean.State.context()} | {:supervisor, Supervisor.name()} | {:machine, Protean.MachineConfig.t()} | {:module, module()} | {:parent, server() | pid()}
Option values for Protean machines.
@type server() :: GenServer.server()
A running Protean machine process.
@type start_option() :: machine_option() | GenServer.option()
Option values for start*
functions.
@type subscribe_option() :: {:monitor, boolean()} | {:to, subscribe_to_option()}
Option values for subscribe/2
.
@type subscribe_to_option() :: :all | :answer
@type using_option() :: {:callback_module, module()}
Option values for use Protean
.
Link to this section Callbacks
@callback delay(term(), Protean.State.t(), event()) :: non_neg_integer()
Optional callback for defining dynamic delays.
example
Example
@machine [
# ...
states: [
will_transition: [
after: [
delay: "my_delay",
target: "new_state"
]
],
new_state: [
# ...
]
]
]
@impl true
def delay("my_delay", state, _) do
state.context[:configured_delay] || 1000
end
@callback guard(term(), Protean.State.t(), event()) :: boolean()
Optional callback to determine whether a conditional transition should occur.
example
Example
@machine [
# ...
states: [
editing_user: [
on: [
{
{:user_commit, _},
guard: :valid_user?,
actions: ["broadcast"],
target: "viewing_user"
},
{
{:user_commit, _},
guard: {:not, :valid_user?},
actions: ["show_invalid_user_error"]
}
]
]
]
]
@impl true
def guard(:valid_user?, state, {_, user}) do
User.changeset(%User{}, user).valid?
end
@callback handle_action(term(), Protean.State.t(), event()) :: Protean.State.t()
Optional callback for actions specified in response to a transition.
Receives the current machine state and event triggering the action as arguments. Returns one of:
state
- same as{:noreply, state}
{:noreply, state}
- the machine state with any new actions{:reply, reply, state}
- a reply and the machine state with any new actions
example
Example
@machine [
# ...
on: [
{
match({:data, _any}),
target: :data_received,
actions: [:assign_data, :broadcast_data]
}
]
]
@impl true
def handle_action(:assign_data, state, {:data, data}) do
state
|> Protean.Action.assign(:last_received, data)
end
def handle_action(:broadcast_data, state, _) do
%{notify: pid, last_received: data} = state.context
PubSub.broadcast!(@pubsub, @topic, data)
state =
state
|> Protean.Action.send({:data, data}, to: pid)
{:reply, data, state}
end
@callback invoke(term(), Protean.State.t(), event()) :: term()
Optional callback for invoked processes specified during machine execution.
Should return a value or child specification for the type of process being invoked.
example
Example
@machine [
# ...
states: [
# ...
awaiting_task: [
invoke: [
task: "my_task",
done: "completed"
]
],
completed: [
# ...
]
]
]
@impl true
def invoke("my_task", _state, event_data) do
{__MODULE__, :run_my_task, [event_data]}
end
Link to this section Functions
@spec call(server(), event(), timeout()) :: {Protean.State.t(), replies :: [term()]}
Makes a synchronous call to the machine, awaiting any transitions that result.
Returns a tuple of {state, replies}
, where state
is the next state of the machine, and
replies
is a (possibly empty) list of replies returned by action callbacks resulting from the
event.
@spec current(server()) :: Protean.State.t()
Synchronously retrieve the current machine state.
TODO: Allow optional timeout as with call/3
.
Helper macro to allow match expressions on events during machine definition.
example
Example
@machine [
# ...
on: [
# Match events that are instances of `MyStruct`
{match(%MyStruct{}), target: "..."},
# Match anything
{match(_), target: "..."}
]
]
@spec matches?(Protean.State.t(), descriptor :: term()) :: boolean()
@spec matches?(server(), descriptor :: term()) :: boolean()
Returns true if the machine is currently in the given state.
Note that calling matches?/2
on a machine process is a synchronous operation that is
equivalent to:
machine |> Protean.current() |> Protean.matches?(descriptor)
Sends an asynchronous event to the machine.
Shares semantics with GenServer.cast/2
.
@spec send_after(server(), event(), non_neg_integer()) :: reference()
Sends an event to the machine after time
in milliseconds has passed.
Returns a timer reference that can be canceled with Process.cancel_timer/1
.
@spec start_link(module(), [start_option()]) :: GenServer.on_start()
Start a Protean machine linked to the current process.
This is often used to start the machine as part of a supervision tree. See
GenServer.start_link/3
for description of return value.
The semantics are similar to GenServer.start_link/3
and accepts the same options, with the
addition of some specific to Protean.
options
Options
:context
- context map that will be merged into the default context defined by the machine.:machine
- defaults tomodule
- module used for machine definition.:module
- defaults tomodule
- callback module used for actions, guards, invoke, etc. See "Callbacks".:parent
- defaults toself()
- process id of the parent that will receive events from the machine if aProtean.Action.send(..., to: :parent)
action is used or when the machine reaches a state with:type
of:final
.:supervisor
- defaults toProtean.Supervisor
- name of the supervisor process that will be used to start processes resulting from running the machine. See "Supervisor".- Any option accepted by
GenServer.start_link/3
.
TODO
@spec subscribe(server(), subscribe_to :: term(), [subscribe_option()]) :: reference()
Subscribes the caller to a running machine, returning a reference.
Subscribers will receive messages whenever the machine transitions, as well as a :DOWN
message when the machine exits. (This can be controlled with the :monitor
option.)
Messages are sent in the shape of:
{:state, ref, {state, replies}}
where:
ref
is a monitor reference returned by the subscription;state
is the machine state resulting from the transition;replies
is a (possibly empty) list of replies resulting from actions on transition.
If the process is already dead when subscribing, a :DOWN
message is delivered immediately.
arguments
Arguments
server
- machine to subscribe the caller to;subscribe_to
- one of:all
(default) or:replies
, in which case messages will only be sent to the caller if thereplies
list is non-empty;options
::monitor
- whether to receive a:DOWN
message on receive exit (defaults totrue
).
Unsubscribes the caller from the machine.