PlugStatem behaviour (plug_statem v0.2.0)

Copy Markdown View Source

A gen_statem-style event loop that fits the Plug contract.

Elixir HTTP servers using Plug require every write to a Plug.Conn to come from the process that received the request. PlugStatem.enter_loop/6 takes over that process during the execution of a long-lived response, such as when serving SSE.

Over the lifetime of the response, your callback module will be executed just like a standard gen_statem.

On :stop, PlugStatem.enter_loop/5 returns a result instead of exiting the process, so that the rest of the Plug call/2 contract can be fulfilled.

There are a handful of message shapes that are reserved by Bandit. Messages that match these patterns will not be delivered to your module as an :info event.

  • {:bandit, _}
  • {:plug_conn, :sent}

Example

A Plug's call/2 enters the loop and returns the conn when finished:

def call(%Plug.Conn{path_info: ["events"]} = conn, _opts) do
  conn = Plug.Conn.put_resp_content_type(conn, "text/event-stream")

  case PlugStatem.enter_loop(MyStream, [], conn, :starting, %{}) do
    {_reason, _state, conn, _data} -> conn
  end
end

Your module streams chunks to the conn:

defmodule MyStream do
  @behaviour PlugStatem

  @keep_alive {{:timeout, :keep_alive}, 15_000, nil}

  @impl true
  def callback_mode, do: [:handle_event_function, :state_enter]

  @impl true
  def handle_event(:enter, _old, :streaming, conn, data) do
    {:keep_state, Plug.Conn.send_chunked(conn, 200), data, @keep_alive}
  end

  def handle_event(:info, {:event, iodata}, :streaming, conn, data) do
    write(conn, iodata, data)
  end

  def handle_event({:timeout, :keep_alive}, nil, :streaming, conn, data) do
    write(conn, ": keep-alive\n\n", data)
  end

  def handle_event(:info, :stop, :streaming, conn, data) do
    {:stop, :normal, conn, data}
  end

  defp write(conn, iodata, data) do
    case Plug.Conn.chunk(conn, iodata) do
      {:ok, conn} -> {:keep_state, conn, data, @keep_alive}
      {:error, reason} -> {:stop, {:shutdown, reason}, conn, data}
    end
  end
end

Other processes can drive the stream by sending it messages, for example send(pid, {:event, "data: hello\n\n"}). With the :name option they can find it through a Registry instead of holding the pid.

Callbacks

The callback module mirrors gen_statem, with the Plug.Conn threaded through every callback alongside state and data.

callback_mode() :: mode | [mode | :state_enter]
  where mode :: :handle_event_function | :state_functions

# callback_mode :handle_event_function
handle_event(event_type, event_content, state, conn, data) :: result

# callback_mode :state_functions, one function per state atom
state_name(event_type, event_content, conn, data) :: result

# optional; see enter_loop/4
init(init_arg, conn) :: {:ok, state, conn, data}
                      | {:ok, state, conn, data, actions}
                      | {:stop, reason, conn}

# optional
terminate(reason, state, conn, data) :: term

Events

:info                message that is not one of the below
{:call, from}        from `:gen_statem.call/2,3`; reply with a `:reply` action
:cast                from `:gen_statem.cast/2`
:state_timeout       content of a `:state_timeout` action
:timeout             content of a `:timeout` action
{:timeout, name}     content of a `{:timeout, name}` action
:enter               state enter call; content is the previous state
any other type       inserted by a `:next_event` action

Results

{:next_state, state, conn, data}
{:next_state, state, conn, data, actions}
{:keep_state, conn, data}
{:keep_state, conn, data, actions}
:keep_state_and_data
{:keep_state_and_data, actions}
{:repeat_state, conn, data}
{:repeat_state, conn, data, actions}
:repeat_state_and_data
{:repeat_state_and_data, actions}
{:stop, reason}
{:stop, reason, conn, data}
{:stop_and_reply, reason, replies}
{:stop_and_reply, reason, replies, conn, data}

A state enter call may not change the state and may not use :postpone or :next_event.

Actions

{:reply, from, reply}
:postpone | {:postpone, boolean}
{:next_event, event_type, event_content}
{:state_timeout, ms | :infinity, content}
{:state_timeout, :cancel}
{:timeout, ms | :infinity, content}
{:timeout, :cancel}
{{:timeout, name}, ms | :infinity, content}
{{:timeout, name}, :cancel}

Not yet implemented, and rejected with an ArgumentError naming the action:

:hibernate
{timeout_type, :update, content}
{timeout_type, ms, content, opts}
{:change_callback_module, module}
{:push_callback_module, module}
:pop_callback_module

format_status/1 and code_change/4 are never called.

After a state change, events are handled in this order: events inserted by :next_event, then postponed events in the order they were postponed, then anything already queued, then the mailbox.

Options

enter_loop/6 takes a keyword list of options as its second argument, in the position GenServer.start_link/3 uses:

  • :name - registers the current process for the lifetime of the loop under a name, in any of the forms GenServer accepts: an atom, a {:global, term}, or a {:via, module, term} such as {:via, Registry, {MyRegistry, key}}. The name is unregistered when the loop returns, including when a callback raises. If the name is already taken, enter_loop/6 returns {:error, {:already_started, pid}} without running any callback.

Summary

Functions

Registers the name, calls init/2, and runs the loop until a callback stops it.

Runs the loop in the current process until a callback stops it, starting from the given state and data. init/2 is not called.

Types

action()

@type action() ::
  reply_action()
  | :postpone
  | {:postpone, boolean()}
  | {:next_event, event_type(), term()}
  | {:state_timeout, timeout(), term()}
  | {:state_timeout, :cancel}
  | {:timeout, timeout(), term()}
  | {:timeout, :cancel}
  | {{:timeout, term()}, timeout(), term()}
  | {{:timeout, term()}, :cancel}

actions()

@type actions() :: [action()] | action()

conn()

@type conn() :: term()

data()

@type data() :: term()

event_type()

@type event_type() ::
  :info
  | {:call, from()}
  | :cast
  | :state_timeout
  | :timeout
  | {:timeout, term()}
  | :enter
  | term()

from()

@type from() :: {pid(), term()}

option()

@type option() :: {:name, GenServer.name()}

reply_action()

@type reply_action() :: {:reply, from(), term()}

result()

@type result() ::
  {:next_state, state(), conn(), data()}
  | {:next_state, state(), conn(), data(), actions()}
  | {:keep_state, conn(), data()}
  | {:keep_state, conn(), data(), actions()}
  | :keep_state_and_data
  | {:keep_state_and_data, actions()}
  | {:repeat_state, conn(), data()}
  | {:repeat_state, conn(), data(), actions()}
  | :repeat_state_and_data
  | {:repeat_state_and_data, actions()}
  | {:stop, term()}
  | {:stop, term(), conn(), data()}
  | {:stop_and_reply, term(), [reply_action()] | reply_action()}
  | {:stop_and_reply, term(), [reply_action()] | reply_action(), conn(), data()}

state()

@type state() :: term()

Callbacks

callback_mode()

@callback callback_mode() ::
  :handle_event_function
  | :state_functions
  | [:handle_event_function | :state_functions | :state_enter]

handle_event(event_type, term, state, conn, data)

(optional)
@callback handle_event(event_type(), term(), state(), conn(), data()) :: result()

init(init_arg, conn)

(optional)
@callback init(init_arg :: term(), conn()) ::
  {:ok, state(), conn(), data()}
  | {:ok, state(), conn(), data(), actions()}
  | {:stop, reason :: term(), conn()}

terminate(reason, state, conn, data)

(optional)
@callback terminate(reason :: term(), state(), conn(), data()) :: term()

Functions

enter_loop(module, opts, conn, init_arg)

@spec enter_loop(module(), [option()], conn(), init_arg :: term()) ::
  {reason :: term(), state(), conn(), data()}
  | {:error, {:init, reason :: term()}, conn()}
  | {:error, {:already_started, pid()}}

Registers the name, calls init/2, and runs the loop until a callback stops it.

This is the ordering gen_statem's start_link gives init/1: the name is registered first, so anything init/2 subscribes to can already find this process, and anything it reads comes after that subscription. The callback receives init_arg and the conn, and returns the initial state, conn, and data, optionally with actions, or {:stop, reason, conn}.

Returns {reason, state, conn, data} as they were when the loop stopped; {:error, {:init, reason}, conn} when init/2 stopped, with the name already unregistered and no callback run; or {:error, {:already_started, pid}} if the :name option names a registered process. Exceptions propagate to the caller.

enter_loop(module, opts, conn, state, data, actions \\ [])

@spec enter_loop(module(), [option()], conn(), state(), data(), actions()) ::
  {reason :: term(), state(), conn(), data()}
  | {:error, {:already_started, pid()}}

Runs the loop in the current process until a callback stops it, starting from the given state and data. init/2 is not called.

Returns {reason, state, conn, data} as they were when the loop stopped, or {:error, {:already_started, pid}} if the :name option names a registered process. Exceptions raised by callbacks propagate to the caller.