recipe v0.4.4 Recipe behaviour View Source

Intro

The Recipe module allows implementing multi-step, reversible workflows.

For example, you may wanna parse some incoming data, write to two different data stores and then push some notifications. If anything fails, you wanna rollback specific changes in different data stores. Recipe allows you to do that.

In addition, a recipe doesn’t enforce any constraint around which processes execute which step. You can assume that unless you explicitly involve other processes, all code that builds a recipe is executed by default by the calling process.

Ideal use cases are:

  • multi-step operations where you need basic transactional properties, e.g. saving data to Postgresql and Redis, rolling back the change in Postgresql if the Redis write fails
  • interaction with services that simply don’t support transactions
  • composing multiple workflows that can share steps (with the help of Kernel.defdelegate/2)
  • trace workflows execution via a correlation id

You can avoid using this library if:

  • A simple with macro will do
  • You don’t care about failure semantics and just want your operation to crash the calling process
  • Using Ecto, you can express your workflow with Ecto.Multi

Heavily inspired by the ktn_recipe module included in inaka/erlang-katana.

Core ideas

  • A workflow as a set of discreet steps
  • Each step can have a specific error handling scenario
  • Each step is a separate function that receives a state with the result of all previous steps
  • Each step should be easily testable in isolation
  • Each workflow run is identified by a correlation id
  • Each workflow needs to be easily audited via logs or an event store

Example

The example below outlines a possible workflow where a user creates a new conversation, passing an initial message.

Each step is named in steps/0. Each step definition uses data added to the workflow state and performs a specific task.

Any error shortcuts the workflow to handle_error/3, where a specialized clause for :create_initial_message deletes the conversation if the system failes to create the initial message (therefore simulating a transaction).

defmodule StartNewConversation do
  use Recipe

  ### Public API

  def run(user_id, initial_message_text) do
    state = Recipe.initial_state
            |> Recipe.assign(:user_id, user_id)
            |> Recipe.assign(:initial_message_text, initial_message_text)

    Recipe.run(__MODULE__, state)
  end

  ### Callbacks

  def steps, do: [:validate,
                  :create_conversation,
                  :create_initial_message,
                  :broadcast_new_conversation,
                  :broadcast_new_message]

  def handle_result(state) do
    state.assigns.conversation
  end

  def handle_error(:create_initial_message, _error, state) do
    Service.Conversation.delete(state.conversation.id)
  end
  def handle_error(_step, error, _state), do: error

  ### Steps

  def validate(state) do
    text = state.assigns.initial_message_text
    if MessageValidator.valid_text?(text) do
      {:ok, state}
    else
      {:error, :empty_message_text}
    end
  end

  def create_conversation(state) do
    case Service.Conversation.create(state.assigns.user_id) do
      {:ok, conversation} ->
        {:ok, Recipe.assign(state, :conversation, conversation)}
      error ->
        error
    end
  end

  def create_initial_message(state) do
    %{user_id: user_id,
      conversation: conversation,
      initial_message_text: text} = state.assigns
    case Service.Message.create(user_id, conversation.id, text) do
      {:ok, message} ->
        {:ok, Recipe.assign(state, :initial_message, message)}
      error ->
        error
    end
  end

  def broadcast_new_conversation(state) do
    Dispatcher.broadcast("conversation-created", state.assigns.conversation)
    {:ok, state}
  end

  def broadcast_new_message(state) do
    Dispatcher.broadcast("message-created", state.assigns.initial_message)
    {:ok, state}
  end
end

Telemetry

A recipe run can be instrumented with callbacks for start, end and each step execution.

To instrument a recipe run, it’s sufficient to call:

Recipe.run(module, initial_state, enable_telemetry: true)

The default setting for telemetry is to use the Recipe.Debug module, but you can implement your own by using the Recipe.Telemetry behaviour, definining the needed callbacks and run the recipe as follows:

Recipe.run(module, initial_state, enable_telemetry: true, telemetry_module: MyModule)

An example of a compliant module can be:

defmodule Recipe.Debug do
  use Recipe.Telemetry

  def on_start(state) do
    IO.inspect(state)
  end

  def on_finish(state) do
    IO.inspect(state)
  end

  def on_success(step, state, elapsed_microseconds) do
    IO.inspect([step, state, elapsed_microseconds])
  end

  def on_error(step, error, state, elapsed_microseconds) do
    IO.inspect([step, error, state, elapsed_microseconds])
  end
end

Application-wide telemetry configuration

If you wish to control telemetry application-wide, you can do that by creating an application-specific wrapper for Recipe as follows:

defmodule MyApp.Recipe do
  def run(recipe_module, initial_state, run_opts \ []) do
    final_run_opts = Keyword.put_new(run_opts,
                                     :enable_telemetry,
                                     telemetry_enabled?())

    Recipe.run(recipe_module, initial_state, final_run_opts)
  end

  def telemetry_on! do
    Application.put_env(:recipe, :enable_telemetry, true)
  end

  def telemetry_off! do
    Application.put_env(:recipe, :enable_telemetry, false)
  end

  defp telemetry_enabled? do
    Application.get_env(:recipe, :enable_telemetry, false)
  end
end

This module supports using a default setting which can be toggled at runtime with telemetry_on!/0 and telemetry_off!/0, overridable on a per-run basis by passing enable_telemetry: false as a third argument to MyApp.Recipe.run/3.

You can also add static configuration to config/config.exs:

config :recipe,
  enable_telemetry: true

Link to this section Summary

Functions

Assigns a new value in the recipe state under the specified key

Returns an empty recipe state. Useful in conjunction with Recipe.run/2

Runs a recipe, identified by a module which implements the Recipe behaviour, allowing to specify the initial state

Unassigns (a.k.a. deletes) a specific key in the state assigns

Callbacks

Invoked any time a step fails. Receives the name of the failed step, the error and the state

Invoked at the end of the recipe, it receives the state obtained at the last step

Lists all steps included in the recipe, e.g. [:square, :double]

Link to this section Types

Link to this type error() View Source
error() :: term()
Link to this type function_name() View Source
function_name() :: atom()
Link to this type recipe_module() View Source
recipe_module() :: atom()
Link to this type run_opts() View Source
run_opts() :: [enable_telemetry: boolean(), correlation_id: Recipe.UUID.t()]
Link to this type step() View Source
step() :: atom()
Link to this type t() View Source
t() :: %Recipe{assigns: %{optional(atom()) => term()}, correlation_id: nil | Recipe.UUID.t(), recipe_module: module(), run_opts: Recipe.run_opts(), telemetry_module: telemetry_module()}
Link to this type telemetry_module() View Source
telemetry_module() :: module()

Link to this section Functions

Link to this function assign(state, key, value) View Source
assign(t(), atom(), term()) :: t()

Assigns a new value in the recipe state under the specified key.

Keys are available for reading under the assigns key.

iex> state = Recipe.initial_state |> Recipe.assign(:user_id, 1)
iex> state.assigns.user_id
1
Link to this function initial_state() View Source
initial_state() :: t()

Returns an empty recipe state. Useful in conjunction with Recipe.run/2.

Link to this function run(recipe_module, initial_state, run_opts \\ []) View Source
run(recipe_module(), t(), run_opts()) ::
  {:ok, Recipe.UUID.t(), term()} |
  {:error, term()}

Runs a recipe, identified by a module which implements the Recipe behaviour, allowing to specify the initial state.

In case of a successful run, it will return a 3-element tuple {:ok, correlation_id, result}, where correlation_id is a uuid that can be used to connect this workflow with another one and result is the return value of the handle_result/1 callback.

Supports an optional third argument (a keyword list) for extra options:

  • :enable_telemetry: when true, uses the configured telemetry module to log and collect metrics around the recipe execution
  • :telemetry_module: the telemetry module to use when logging events and metrics. The module needs to implement the Recipe.Telemetry behaviour (see related docs), it’s set by default to Recipe.Debug and it’s only used when :enable_telemetry is set to true
  • :correlation_id: you can override the automatically generated correlation id by passing it as an option. A uuid can be generated with Recipe.UUID.generate/0

Example

Recipe.run(Workflow, Recipe.initial_state(), enable_telemetry: true)
Link to this function unassign(state, key) View Source
unassign(t(), atom()) :: t()

Unassigns (a.k.a. deletes) a specific key in the state assigns.

iex> state = Recipe.initial_state |> Recipe.assign(:user_id, 1)
iex> state.assigns.user_id
1
iex> new_state = Recipe.unassign(state, :user_id)
iex> new_state.assigns
%{}

Link to this section Callbacks

Link to this callback handle_error(step, error, t) View Source
handle_error(step(), error(), t()) :: term()

Invoked any time a step fails. Receives the name of the failed step, the error and the state.

Link to this callback handle_result(t) View Source
handle_result(t()) :: term()

Invoked at the end of the recipe, it receives the state obtained at the last step.

Lists all steps included in the recipe, e.g. [:square, :double]