View Source Protean behaviour (Protean v0.0.1)

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

  defmachine(
    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]},
          {{"Set", _}, actions: :set_min_or_max},
          {{"Log", _}, actions: :log}
        ]
      ]
    ]
  )

  @impl Protean
  def action(:increment, state, _event), do: Action.assign_in(state, [:count], & &1 + 1)
  def action(:decrement, state, _event), do: Action.assign_in(state, [:count], & &1 - 1)

  def action(:set_min_or_max, state, {"Set", {key, val}}) do
    state
    |> Action.assign(key, val)
  end

  def action(:log, state, {"Log", attribute}) do
    %{context: context} = state
    IO.puts("#{attribute}: #{context[attribute]}")

    state
  end

  @impl Protean
  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/3. 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 resulting from any transitions in response.
  • 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 state of a running Protean machine.
  • matches?/2 - Query the currently active state(s) of a machine.
  • ask/3 - Like call/3, but potentially returns an "answer" value in addition to the machine state.
  • 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 start* functions.

A running Protean machine process.

Callbacks

Used to execute actions in response to machine transitions.

Used to determine whether a transition should take place.

Used to define invoked services at runtime. Returns a value or child spec usable by the invoke type.

Functions

Makes a synchronous call to the machine and waits for it to execute any transitions that result from the given event, returning an answer and the machine state.

Makes a synchronous call to the machine and waits for it to execute any transitions that result from the given event, returning a possible answer and the new machine state.

See Protean.Interpreter.Server.ask/3.

Makes a synchronous call to the machine and waits for it to execute any transitions that result from the given event, returning the new machine state.

See Protean.Interpreter.Server.call/3.

Synchronously retrieve the current machine state.

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.

See Protean.Interpreter.Server.stop/1.

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 option() ::
  {:machine, Protean.Machine.t()}
  | {:handler, module()}
  | {:parent, server() | pid()}
  | {:supervisor, Supervisor.name()}
  | GenServer.option()

Option values for start* functions.

@type server() :: GenServer.server()

A running Protean machine process.

@type subscribe_option() :: {:monitor, boolean()}

Options for subscribe/2.

Link to this section Callbacks

Link to this callback

action(action, t, event)

View Source
@callback action(action :: term(), Protean.State.t(), event()) :: Protean.State.t()

Used to execute actions in response to machine transitions.

Receives the current machine state and event triggering the action as arguments and must return the machine state. It is possible to attach actions to the machine state to indicate that they should be performed immediately following this action. See Protean.Action.

example

Example

defmachine(
  # ...
  states: [
    # ...
    state: [
      on: [
        {{:data, _any}, target: "data_received", actions: "assign_and_send_data"}
      ]
    ]
  ]
)

@impl Protean
def action("assign_and_send_data", state, {:data, data}) do
  %{service: pid} = state.context

  PubSub.broadcast!(@pubsub, @topic, data)

  state
  |> Protean.Action.send({"data_received", data}, to: pid)
  |> Protean.Action.assign(:last_received_data, data)
end
@callback guard(action :: term(), Protean.State.t(), event()) :: boolean()

Used to determine whether a transition should take place.

example

Example

defmachine(
  # ...
  states: [
    editing_user: [
      on: [
        {
          {:user_commit, _},
          guard: "user_valid?",
          actions: ["broadcast"],
          target: "viewing_user"
        },
        {
          {:user_commit, _},
          guard: {:not, "user_valid?"},
          actions: ["show_invalid_user_error"]
        }
      ]
    ]
  ]
)

@impl Protean
def guard("user_valid?", state, {_, user}) do
  User.changeset(%User{}, user).valid?
end
Link to this callback

invoke(action, t, event)

View Source
@callback invoke(action :: term(), Protean.State.t(), event()) :: term()

Used to define invoked services at runtime. Returns a value or child spec usable by the invoke type.

example

Example

defmachine(
  # ...
  states: [
    # ...
    awaiting_task: [
      invoke: [
        task: "my_task",
        done: "task_complete"
      ]
    ],
    task_complete: [
      # ...
    ]
  ]
)

@impl Protean
def invoke("my_task", _state, {"trigger", data}) do
  {__MODULE__, :run_task, [data]}
end

Link to this section Functions

Makes a synchronous call to the machine and waits for it to execute any transitions that result from the given event, returning an answer and the machine state.

Behaves like ask/3, but raises if an answer is not returned.

Link to this function

ask!(protean, event, timeout)

View Source
@spec ask!(server(), event(), timeout()) :: {term(), Protean.State.t()}

Makes a synchronous call to the machine and waits for it to execute any transitions that result from the given event, returning a possible answer and the new machine state.

Returns one of:

  • {{:ok, answer}, state} - Returned if any actions executed as a result of the event set an answer through the use of Action.answer/2.
  • {nil, state} - Returned if no actions execute or if no executed actions set an answer.

Answers are only returned to the caller if they result from the given event. If an asynchronous call, through send/2 for example, would have resulted in an answer, it will be "lost".

Link to this function

ask(protean, event, timeout)

View Source
@spec ask(server(), event(), timeout()) ::
  {{:ok, term()}, Protean.State.t()} | {nil, Protean.State.t()}

See Protean.Interpreter.Server.ask/3.

Makes a synchronous call to the machine and waits for it to execute any transitions that result from the given event, returning the new machine state.

Shares semantics with GenServer.call/3. See those docs for timeout behavior.

Link to this function

call(protean, event, timeout)

View Source
@spec call(server(), event(), timeout()) :: Protean.State.t()

See Protean.Interpreter.Server.call/3.

@spec current(server()) :: Protean.State.t()

Synchronously retrieve the current machine state.

TODO: Allow optional timeout as with call/3.

Link to this function

matches?(item, descriptor)

View Source
@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)
@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(), [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

  • :machine - defaults to module.machine() - %Protean.Machine{} that will be executed by the Protean interpreter.
  • :handler - 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.

See Protean.Interpreter.Server.stop/1.

TODO

Link to this function

subscribe(protean, opts \\ [monitor: true])

View Source
@spec subscribe(server(), [subscribe_option()]) :: reference()

Subscribes the caller to a running machine, returning a reference.

Processes subscribed to a machine will receive messages whenever the machine transitions. (Note that a machine can transition to the same state it was in previously.) By default, subscribed processes also monitor the machine (see Process.monitor/1). This behavior can be changed by passing monitor: false.

Messages on transition will be delivered in the shape of:

{:state, state, ref}

where:

  • state is the Protean.State resulting from the transition;
  • ref is a monitor reference.

As with monitor, if the process is already dead when calling Protean.subscribe/2, a :DOWN message is delivered immediately.

Link to this function

unsubscribe(protean, ref)

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

Unsubscribes the caller from the machine.