View Source Protean.Action behaviour (Protean v0.1.0)

Protean manages state, processes, and side-effects through actions, data structures describing things that should occur as a result of a transition.

introduction

Introduction

When a Protean machine transitions from one state to the next (or even self-transitions back to the same state), the interpreter collects any actions that should be performed as a result of that transition. Actions are then executed in a specific order:

  • Exit actions - Any :exit actions specified on states that are exiting as a result of the transition.
  • Transition actions - Any :actions specified on the transition that responded to the event.
  • Entry actions - Any :entry actions specified on the states being entered.

Actions are a unifying abstraction that serve many purposes:

  • assign to and update a machine's context;
  • produce additional actions to be run immediately after;
  • run arbitrary side-effects, like broadcasting a PubSub message;
  • reply to the sender of the event with a value;
  • spawn additional processes whose lifecycle can be tied to machine execution.

I recommend reading the sections below to gain a better understanding of how and where actions can be applied, then review the "Callback Actions" and "Inline Actions" provided by this module.

handling-an-action

Handling an action

The most common way to use actions is through the Protean.handle_action/3 callback. This callback is run when an action is specified like this:

[
  # ...
  entry: [:entering_state]
  on: [
    match(%MyEvent{}, actions: [:first_action, :second_action])
  ],
  exit: [:exiting_state]
]

These are then handled in callbacks:

@impl true
def handle_action(:first_action, context, %MyEvent{data: data}) do
  # ...
  {:reply, reply, context}
end

See Protean.handle_action/3 for possible return values and their effect.

Action callbacks must always return the machine context, but they can attach actions to that context that will be immediately executed by the interpreter. For instance:

def handle_action(:update_data, context, {:data_updated, changes}) do
  context
  |> Action.update_in([:data], &Map.merge(&1, changes))
end

In this case, update_in/3 is being used to update some data in the machine :assigns. But, we could perform additional actions if we wish, such as:

def handle_action(:update_data, context, {:data_updated, changes}) do
  %{topic: topic, other_process: pid, data: data} = context.assigns
  new_data = Map.merge(data, changes)

  PubSub.broadcast!(@pubsub, topic, {:data_updated, changes})

  context
  |> Action.send({:data_commited, new_data}, to: pid)
  |> Action.assign(:data, new_data)
end

Note about return values

These last two examples have returned the context directly, which is equivalent to returning {:noreply, context}. You can also emit replies that will be available to the sender of the event using {:reply, reply, context}. This can be very useful for creating client APIs around your machines, much like you would do with GenServer.

The best practice is to let the interpreter execute as many actions as possible, as opposed to performing explicit side-effects in the callback. In many cases, such as spawning additional processes, this allows Protean to supervise and tie process lifecycle to the machine's lifecycle (or changes in state).

low-level-api-the-action-behaviour

Low-level API: the Action behaviour

Ultimately, all actions resolve to Protean.Action.t/0, a simple struct containing a module and an argument. The module is expected to implement the Protean.Action behaviour.

Action execution, then, follows a simple logic: if the action being executed is a Protean.Action struct, call the exec_action/2 callback on the module, passing it the associated argument and the interpreter in its current state. Otherwise, wrap it in a struct that delegates to the callback module associated with the machine. This is how Protean.handle_action/3 is called.

Many of features features boil down to syntax sugar over actions, including :spawn and delayed transitions using :after.

Link to this section Summary

Types

Allowed return value from exec_action/2.

t()

Data structure representing an executable action.

Callback Actions

Attach an action that merges the given assigns into a machine's context.

Attach an action that assigns a value to a key in a machine's context.

Attach an action that assigns into a machine's context.

Attach an action that executes the first of a list of actions whose guard is truthy.

Attach a custom action that implements the Protean.Action behaviour.

Attach an action that will send a message to a process.

Attach an action that calls a function with a machine's context and merges the result into assigns.

Attach an action that applies an update function into a machine's context.

Inline Actions

Same as assign/2 but used inline in machine configuration.

Same as assign_in/3 but used inline in machine configuration.

Same as choose/2 but used inline in machine configuration.

Same as send/3 but used inline in machine configuration.

Same as update/2, but inserted inline in machine config.

Same as update_in/3 but used inline in machine configuration.

Callbacks

Accepts the action_arg passed with the action as well as the Protean interpreter. Returns one of three values

Link to this section Types

@type exec_action_return() ::
  {:cont, Protean.Interpreter.t()}
  | {:cont, Protean.Interpreter.t(), [t()]}
  | {:halt, Protean.Interpreter.t()}

Allowed return value from exec_action/2.

@type t() :: %Protean.Action{arg: term(), module: module()}

Data structure representing an executable action.

Link to this section Callback Actions

Link to this function

assign(context, assigns)

View Source
@spec assign(Protean.Context.t(), assigns :: Enumerable.t()) :: Protean.Context.t()

Attach an action that merges the given assigns into a machine's context.

Link to this function

assign(context, key, value)

View Source
@spec assign(Protean.Context.t(), key :: term(), value :: term()) ::
  Protean.Context.t()

Attach an action that assigns a value to a key in a machine's context.

Link to this function

assign_in(context, path, value)

View Source
@spec assign_in(Protean.Context.t(), [term(), ...], term()) :: Protean.Context.t()

Attach an action that assigns into a machine's context.

Similar to Kernel.put_in/3.

Link to this function

choose(context, actions)

View Source

Attach an action that executes the first of a list of actions whose guard is truthy.

@spec put(Protean.Context.t(), t()) :: Protean.Context.t()

Attach a custom action that implements the Protean.Action behaviour.

Link to this function

send(context, event, opts)

View Source
@spec send(Protean.Context.t(), event :: term(), [term()]) :: Protean.Context.t()

Attach an action that will send a message to a process.

Attach an action that calls a function with a machine's context and merges the result into assigns.

Link to this function

update_in(context, path, fun)

View Source
@spec update_in(Protean.Context.t(), [term(), ...], function()) :: Protean.Context.t()

Attach an action that applies an update function into a machine's context.

Similar to Kernel.update_in/3.

Link to this section Inline Actions

@spec assign(Enumerable.t()) :: t()

Same as assign/2 but used inline in machine configuration.

@spec assign_in([term(), ...], term()) :: t()

Same as assign_in/3 but used inline in machine configuration.

Same as choose/2 but used inline in machine configuration.

@spec send(event :: term(), [term()]) :: t()

Same as send/3 but used inline in machine configuration.

Same as update/2, but inserted inline in machine config.

@spec update_in([term(), ...], function()) :: t()

Same as update_in/3 but used inline in machine configuration.

Link to this section Callbacks

Link to this callback

exec_action(action_arg, t)

View Source
@callback exec_action(action_arg :: term(), Protean.Interpreter.t()) ::
  exec_action_return()

Accepts the action_arg passed with the action as well as the Protean interpreter. Returns one of three values:

  • {:cont, interpreter} to continue running the action pipeline
  • {:cont, interpreter, [action]} to inject actions that will be run immediately before running the rest of the pipeline's actions
  • {:halt, interpreter} to halt the action pipeline, canceling any further actions