View Source Protean behaviour (Protean v0.1.0)

Caveat emptor: Protean is likely to undergo significant changes and should not be considered stable.

A library for managing state and side-effects with event-driven statecharts.

Protean was initially inspired by XState, a robust JavaScript/TypeScript statechart implementation, but strays to adhere to Elixir and OTP conventions. We also follow much of the SCXML W3C Standard's recommendations, but compatibility is not a goal.

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 spawn), 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} = 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.proc/2 et al.

Machines can be started under this supervisor using Protean.start_machine/2.

{:ok, machine} = 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. Like Process.send_after/4, returns a timer reference so that the send can be canceled with Process.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 (and unsubscribe/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

If you are already starting a Phoenix.PubSub in your application (e.g. a Phoenix application), you need to configure the :protean application to use your process instead of starting its own. This can be done by adding the following to your config.exs:

config :protean, :pubsub,
  name: MyApp.PubSub,
  start: false

For subscription 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 spawned processes linked to a certain machine state.

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 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()} | {:error, {:already_started, machine()} | term()}

Return values of start_machine/2

@type spawn_type() :: :proc | :task | :stream
@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

Link to this callback

delay(term, t, event)

View Source (optional)
@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
Link to this callback

guard(term, t, event)

View Source (optional)
@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
Link to this callback

handle_action(term, t, event)

View Source (optional)
@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
Link to this callback

spawn(spawn_type, term, t, event)

View Source (optional)
@callback spawn(spawn_type(), term(), Protean.Context.t(), event()) :: term()

Optional callback for spawned processes linked to a certain machine state.

Should return a child_spec appropriate for the type of process being spawned:

example

Example

@machine [
  states: [
    atomic(:awaiting_task,
      spawn: [
        task(:my_task, done: :completed)
      ]
    ),
    atomic(:completed)
  ]
]

@impl true
def spawn(:task, :my_task, context, _event) do
  {__MODULE__, :run_task, [context.assigns.task_data]}
end

Link to this section Functions

Link to this function

call(machine, event, timeout \\ 5000)

View Source
@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.

Link to this function

matches?(item, descriptor)

View Source
@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)
@spec send(machine(), event()) :: :ok

Sends an asynchronous event to the machine.

Shares semantics with GenServer.cast/2.

Link to this function

send_after(machine, event, time)

View Source
@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.

Link to this function

start_machine(module, opts \\ [])

View Source
@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 in module - machine configuration.
  • :module - defaults to module - callback module used for actions, guards, spawns, etc. See "Callbacks".
  • :parent - defaults to self() - process id of the parent that will receive events from the machine if a Protean.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.
Link to this function

stop(machine, reason \\ :default, timeout \\ :infinity)

View Source
@spec stop(machine(), reason :: term(), timeout()) :: :ok

TODO

Link to this function

subscribe(machine, opts \\ [])

View Source
@spec subscribe(machine(), [{:filter, :replies}]) :: {:ok, id()} | {:error, term()}

Subscribes the caller to receive messages when a machine transitions.

Returns {:ok, id} where id is a unique identifier for the machine process, or {:error, error} if subscription is unsuccessful.

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

{:ok, id} = Protean.subscribe(machine)
Protean.send(machine, :some_event)
# receive: {^id, context, []}

You can also subscribe to only receive messages if replies are non-empty:

{:ok, id} = Protean.subscribe(machine, filter: :replies)
Protean.send(machine, :reply_triggering_event)
# receive: {^id, context, [reply, ...]}
@spec unsubscribe(machine()) :: :ok

Unsubscribes the caller from machine transition messages.