View Source Protean behaviour (Protean v0.1.0-alpha.3)
Caveat emptor: Protean started as a library for personal learning and exploration. It should not yet be relied upon.
A library for managing state and side-effects with event-driven statecharts.
Protean 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 assigns), 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 assigns 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
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,
assigns: [
count: 0,
min: nil,
max: nil
],
states: [
atomic(:active,
on: [
match("Inc", actions: :increment, guard: [not: :at_max]),
match("Dec", actions: :decrement, guard: [not: :at_min]),
match({"Set", _}, actions: :set_min_or_max),
match({"Log", _}, actions: :log)
]
)
]
]
@impl true
def handle_action(:increment, context, _event) do
context
|> Action.assign_in([:count], & &1 + 1)
end
def handle_action(:decrement, context, _event) do
context
|> Action.assign_in([:count], & &1 - 1)
end
def handle_action(:set_min_or_max, context, {"Set", {key, val}}) do
context
|> Action.assign(key, val)
end
def handle_action(:log, context, {"Log", attribute}) do
%{assigns: assigns} = context
IO.puts("#{attribute}: #{assigns[attribute]}")
context
end
@impl true
def guard(:at_max, %{assigns: %{max: max, count: count}}, _event) do
max && count >= max
end
def guard(:at_min, %{assigns: %{min: min, count: count}}, _event) do
min && count <= min
end
end
It can be started under a supervisor, but we'll start it directly using Protean's built-in DynamicSupervisor
.
{:ok, pid, _id} = Protean.start_machine(Counter)
Protean.current(pid).assigns
# %{count: 0, min: nil, max: nil}
Protean.send(pid, "Inc")
# :ok
Enum.each(1..4, fn _ -> Protean.send(pid, "Inc") end)
Protean.current(pid).assigns
# %{count: 5, min: nil, max: nil}
Protean.call(pid, {"Set", {:max, 10}})
# %Protean.Context{
# assigns: %{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 XContext's introduction to state machines and statecharts for that reason.
Refer to Protean.Builder
for documentation on machine definitions.
When use Protean
is invoked, functions and macros from Protean.Builder
are imported automatically.
the-machine-attribute
The @machine
attribute
By default, Protean assumes that your machine is defined on the @machine
attribute.
defmodule MyMachine do
use Protean
@machine [
initial: :my_initial_state,
states: [
atomic(:my_initial_state,
# ...
),
# ...
]
]
end
starting-supervised-machines
Starting supervised machines
Since state machines typically model structured interactions with a defined beginning and end, they will generally be started under a DynamicSupervisor
.
Protean starts one (as well as a Registry
) by default, in order to manage subprocesses that are started during machine execution through the use of Protean.Builder.invoked/3
.
Machines can be started under this supervisor using Protean.start_machine/2
.
{:ok, machine, id} = Protean.start_machine(MyMachine)
Similar to GenServer
, calling use Protean
will also define a child_spec/1
that allows you to start a machine in a standard supervision tree, if you wish:
children = [
Counter
]
Supervisor.start_link(children, strategy: :one_for_one)
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 context 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 context 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.
subscriptions
Subscriptions
You can subscribe to a Protean machine to receive messages when the machine transitions.
This functionality depends on the optional dependency :phoenix_pubsub
.
To use it, add the following to deps
in your mix.exs
:
defp deps do
[
:phoenix_pubsub
# ...
]
end
For usage, see subscribe/2
.
Link to this section Summary
Types
Any message sent to a Protean machine.
Unique identifier for a Protean machine process.
A running Protean machine process.
Option values for Protean machines.
Return values of start_machine/2
Option values for start*
functions.
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 context.
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 Protean's supervisor.
Subscribes the caller to receive messages when a machine transitions.
Unsubscribes the caller from machine transition messages.
Link to this section Types
@type event() :: term()
Any message sent to a Protean machine.
@type id() :: binary()
Unique identifier for a Protean machine process.
@type invoke_type() :: :delegate | :proc | :task | :stream
@type machine() :: GenServer.server()
A running Protean machine process.
@type machine_option() :: {:assigns, Protean.Context.assigns()} | {:supervisor, Supervisor.name()} | {:machine, Protean.MachineConfig.t()} | {:module, module()} | {:parent, machine() | pid()}
Option values for Protean machines.
@type on_start() :: {:ok, machine(), id()} | :ignore | {:error, {:already_started, machine()} | term()}
Return values of start_machine/2
@type start_option() :: machine_option() | GenServer.option()
Option values for start*
functions.
@type using_option() :: {:callback_module, module()}
Option values for use Protean
.
Link to this section Callbacks
@callback delay(term(), Protean.Context.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", context, _) do
context.assigns[:configured_delay] || 1000
end
@callback guard(term(), Protean.Context.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?, context, {_, user}) do
User.changeset(%User{}, user).valid?
end
@callback handle_action(term(), Protean.Context.t(), event()) :: Protean.Context.t()
Optional callback for actions specified in response to a transition.
Receives the current machine context and event triggering the action as arguments. Returns one of:
context
- same as{:noreply, context}
{:noreply, context}
- the machine context with any new actions{:reply, reply, context}
- a reply and the machine context 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, context, {:data, data}) do
context
|> Protean.Action.assign(:last_received, data)
end
def handle_action(:broadcast_data, context, _) do
%{notify: pid, last_received: data} = context.assigns
PubSub.broadcast!(@pubsub, @topic, data)
context =
context
|> Protean.Action.send({:data, data}, to: pid)
{:reply, data, context}
end
@callback invoke(term(), Protean.Context.t(), event()) :: {invoke_type(), term()}
Optional callback for invoked processes specified during machine execution.
Returns a tuple of {invoke_type, child_spec}
.
example
Example
@machine [
# ...
states: [
# ...
awaiting_task: [
invoke: [
delegate: "my_task",
done: "completed"
]
],
completed: [
# ...
]
]
]
@impl true
def invoke("my_task", _context, event_data) do
{:task, {__MODULE__, :run_my_task, [event_data]}}
end
Link to this section Functions
@spec call(machine(), event(), timeout()) :: {Protean.Context.t(), replies :: [term()]}
Makes a synchronous call to the machine, awaiting any transitions that result.
Returns a tuple of {context, replies}
, where context
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(machine()) :: Protean.Context.t()
Synchronously retrieve the current machine context.
TODO: Allow optional timeout as with call/3
.
@spec matches?(Protean.Context.t(), descriptor :: term()) :: boolean()
@spec matches?(machine(), 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(machine(), 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_machine(module(), [start_option()]) :: on_start()
Start a Protean machine linked to Protean's supervisor.
By default, machines will be registered and named using Protean's process management registry.
options
Options
:assigns
- assigns map that will be merged into the default machine context.:machine
- defaults to the machine defined inmodule
- machine configuration.: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
.- Any option accepted by
GenServer.start_link/3
.
TODO
Subscribes the caller to receive messages when a machine transitions.
id
- id of the machine to subscribe to.
Note: Subscriptions depend on Phoenix.Pubsub
, an optional dependency.
options
Options
:filter
- if set to:replies
, the caller will only be sent messages with replies.
examples
Examples
Protean.subscribe(machine_id)
Protean.send(machine, :some_event)
# receive: {^machine_id, context, []}
You can also subscribe to only receive messages if replies are non-empty:
Protean.subscribe(machine_id, filter: :replies)
Protean.send(machine, :reply_triggering_event)
# receive: {^machine_id, context, [reply, ...]}
@spec unsubscribe(id()) :: :ok
Unsubscribes the caller from machine transition messages.