View Source Protean behaviour (Protean v0.1.0-alpha.1)

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

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",
    assigns: [
      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, 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.

{:ok, pid} = Protean.start_link(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.

the-machine-attribute

The @machine attribute

By default, Protean assumes that your machine is defined on the @machine attribute of the module that called use Protean.

defmodule MyMachine do
  use Protean

  @machine [
    initial: "my_initial_state",
    states: [
      my_initial_state: [
        # ...
      ],
      # other states
    ]
  ]
end

states

States

One of the extensions that statecharts make to typical finite-state machines is a notion of hierarchy. States can contain child states, and the state type determines how those child states are entered or exited.

Protean currently supports four types of states: :compound, :parallel, :atomic, and :final.

Note about state names

In order to take advantage of Elixir's keyword list syntax, state names are usually defined as keywords, but they are converted internally to strings. Notice above, for instance, that we use initial: "my_initial_state" and then my_initial_state: [ shortly thereafter.

the-compound-state

The :compound state

Compound states have children, of which only one can be active at a given time. They additional define an :initial attribute specifying which child should become active if we transition directly to the compound state.

@machine points to the root, which itself is almost always a compound state:

@machine [
  type: :compound,
  initial: "state_a",
  states: [
    state_a: []
  ]
]

Because :compound is the only state that has an :initial, we do not need to explicitly specify the :type. These two examples are equivalent:

[
  parent_state: [
    type: :compound,
    initial: "child_a",
    states: [
      child_a: [],
      child_b: []
    ]
  ]
]
# equivalent to
[
  parent_state: [
    initial: "child_a",
    states: [
      child_a: [],
      child_b: []
    ]
  ]
]

the-parallel-state

The :parallel state

Parallel states also have child states, but when a parallel state is entered, all of its children become active concurrently.

Parallel states must be specified using type: :parallel.

[
  parent_state: [
    type: :parallel,
    states: [
      child_a: [],
      child_b: []
    ]
  ]
]

the-atomic-state

The :atomic state

Atomic states are simple states that cannot define children, but represent some intermediary state of the machine.

Atomic states can be specified with type: :atomic, but they are usually inferred.

[
  atomic_state: [
    type: :atomic
  ]
]
# equivalent to
[
  atomic_state: []
]

the-final-state

The :final state

Final states are a variation of atomic states that represent some form of completion. These states are most useful in triggering :done transitions. Note that final states cannot define transitions of their own using :on.

Final states must be specified with type: :final.

[
  final_state: [
    type: :final
  ]
]

event-transitions

Event transitions

The most common way to transition from one state to another is in response to an event sent to the machine. This is done using the :on attribute of a state. This should point to a list of two-element tuples, where the first element matches the event, and the second is a keyword list specifying the transition.

[
  state_a: [
    on: [
      {:foo_event, target: "state_b"},
      {:bar_event, target: "state_c"}
    ]
  ]
]

pattern-matching

Pattern matching

You can pattern match on events using the automatically-imported Protean.match/1 macro.

[
  state_a: [
    on: [
      {match({:event_with_payload, _payload}), target: "state_b"},
      {match(%Events.OtherEvent{}), target: "state_c"}
    ]
  ]
]

This allows Protean machines to match on arbitrary events regardless of how they are sent to the machine. You could define a catch-all transition, for instance.

[
  state_a: [
    {match({:specific_event, _payload}), target: "state_b"},
    {match(_), target: "unknown_event_received"}
  ]
]

guards

Guards

Guards add run-time checks to transitions and are specified using :guard.

[
  state_a: [
    {:event, target: "state_b", guard: :custom_condition_met?}
  ]
]

See Protean.Guard and guard/3 for additional details.

actions

Actions

Actions -- side effects to be performed when a transition occurs -- can be specified using :actions inside a transition.

[
  state_a: [
    {match(_), actions: [:log_unexpected_event]}
  ]
]

See Protean.Action and handle_action/3 for additional details.

entry-and-exit-actions

Entry and exit actions

In addition to :actions specified on a transition, states themselves can specify actions that should be run when that state is entered and exited.

[
  state_a: [
    entry: [:my_entry_action],
    exit: [:my_exit_action]
  ]
]

See Protean.Action and handle_action/3 for additional details.

invoked-processes

Invoked processes

Invoked processes are subprocesses supervised by Protean that are started and terminated when the machine enters/exits the state that defines them.

[
  runner_state: [
    invoke: [
      task: :some_long_running_task,
      done: [target: "completed", actions: [:save_result]],
      error: [target: "failed", actions: [:log_error]]
    ]
  ]
]

Because the :invoke above specified a :task, Protean will await the return value of the task and then trigger the :done transition associated with the invoke. If the task crashes, the :error transition is taken.

In addition to tasks, Protean can invoke :stream, which sents messages from the stream to the machine as events, and :proc, arbitrary processes (including other machines) that define a supervisor child spec.

If the machine exits the state before an invoked process ends, the process will be exited with a reason of :normal.

See invoke/3 for additional details.

automatic-transitions

Automatic transitions

TODO: :always

See integration tests for examples for now.

delayed-transitions

Delayed transitions

TODO: :after

See integration tests for examples for now.

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.Context{
#   assigns: %{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 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.

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 context.

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() ::
  {:assigns, Protean.Context.assigns()}
  | {: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

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

invoke(term, t, event)

View Source (optional)
@callback invoke(term(), Protean.Context.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", _context, event_data) do
  {__MODULE__, :run_my_task, [event_data]}
end

Link to this section Functions

Link to this function

call(protean, event, timeout \\ 5000)

View Source
@spec call(server(), 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(server()) :: Protean.Context.t()

Synchronously retrieve the current machine context.

TODO: Allow optional timeout as with call/3.

Link to this macro

match(pattern)

View Source (macro)

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: "..."}
  ]
]
Link to this function

matches?(item, descriptor)

View Source
@spec matches?(Protean.Context.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)
@spec send(server(), event()) :: :ok

Sends an asynchronous event to the machine.

Shares semantics with GenServer.cast/2.

Link to this function

send_after(protean, event, time)

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

Link to this function

start_link(module, opts \\ [])

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

  • :assigns - assigns map that will be merged into the default machine context.
  • :machine - defaults to module - module used for machine definition.
  • :module - defaults to module - callback module used for actions, guards, invoke, 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.
  • :supervisor - defaults to Protean.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.
Link to this function

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

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

TODO

Link to this function

subscribe(protean, subscribe_to \\ :all, opts \\ [])

View Source
@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, {context, replies}}

where:

  • ref is a monitor reference returned by the subscription;
  • context is the machine context 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 the replies list is non-empty;
  • options:
    • :monitor - whether to receive a :DOWN message on receive exit (defaults to true).
Link to this function

unsubscribe(protean, ref)

View Source
@spec unsubscribe(server(), reference()) :: :ok

Unsubscribes the caller from the machine.