Elixir OTP - Basics with project example

OTP

OTP (Open Telecom Platform) can be defined with three components: is based on Erlang, has a set of libraries from BEAM (Erlang VM) and follow a system design principles.

If you want to know more about the design principles, take a look at the book Designing for Scalability with Erlang/OTP from Francesco Cesarini and Steve Vinoski.

Processes

In the core of the OTP we have the processes, not those of the operating system, they are created directly in BEAM, lighter, totally isolated and communicate via messages asynchronously.

In addition to all these advantages, Elixir also provides some abstractions for developers to work faster and more productively with them, through tools that allow the creation of new processes, communication and finalization, besides, thanks to immutability we don't need to worry with saving state, then problems with race condition can be avoided more easily.

An example of process creation:

iex(1)> process = spawn(fn -> IO.puts("hey there!") end)
Hey there!
#PID<0.108.0>

Supervisor

We can create several processes but so far we have no control over them, what happens if one dies? If we need to group by context? How to organize them? For this, the OTP provides the Supervisor, with him we can start and end a list of pre-defined processes, define a behavior so that when a process dies, it is executed, for example, restarted, in addition to leaving the structure according to the context through the Supervision Tree .

Creating a simple demo project

After understanding the basics, we will create a small project to demonstrate the ease of creating and killing processes, in addition to the communication between them, in this project we will manage the number of store employees.

Creating the project:

mix new otp_test --sup

cd otp_test

We will create the store struct:

otp_test/lib/otp_test/store/struct.ex

defmodule Store.Struct do
  @enforce_keys [:name, :employees]
  defstruct [:name, :employees]
end

Let's implement the GenServer behavior, with our customization:

otp_test/lib/otp_test/core/store.ex

defmodule Core.Store do
  use GenServer

  alias Store.Struct, as: Store

  def start_link(%Store{} = store) do
    GenServer.start_link(__MODULE__, store, name: String.to_atom(store.name))
  end

  @impl true
  def init(%Store{} = store) do
    {:ok, store}
  end

  @impl true
  def handle_call(:store, _from, %Store{} = store) do
    {:reply, store, store}
  end

  @impl true
  def handle_cast({:add_employees, amount}, %Store{} = store) do
    store =
      store
      |> Map.put(:employees, store.employees + amount)

    {:noreply, store}
  end
end

Here we implement the GenServer behavior, let's analyze the functions/callbacks:

  • start_link: Function to start the process, we define the name as an atom of the store name.
  • init: Function executed as soon as the process is started.
  • handle_call: Callback for synchronous executions, in this case we just want to return the store.
  • handle_cast: Callback for asynchronous executions, here we add more employees according to the sent parameter.

We can now test the implementation, checking if the init function is executed when generating the process:

iex -S mix

iex(1)> store = %Store.Struct{name: "test1", employees: 2}
%Store.Struct{employees: 2, name: "test1"}
iex(2)> Core.Store.start_link store
{:ok, #PID<0.164.0>}

Let's add some employees:

iex(3)> GenServer.cast String.to_atom(store.name), {:add_employees, 4}     
:ok     
iex(4)> GenServer.call String.to_atom(store.name), :store             
%Store.Struct{employees: 6, name: "test1"}

Now this store has 6 employees, we were able to change the value as we configured! 🎉

A simpler way to deal with GenServer is separating its implementation to a public api, let's do that:

otp_test/lib/otp_test/store/management.ex

defmodule Store.Management do
  alias Store.Struct, as: Store

  def open(%Store{} = store) do
    store
    |> Core.Store.start_link()
  end

  def get_store(%Store{} = store) do
    GenServer.call(String.to_atom(store.name), :store)
  end

  def add_employees(%Store{} = store, amount) do
    GenServer.cast(String.to_atom(store.name), {:add_employees, amount})
  end
end

Here we abstract the implementation for use in a public api that calls GenServer but doesn't care about implementing its callbacks.

Lets test:

iex(7)> store = %Store.Struct{name: "test2", employees: 3}
%Store.Struct{employees: 3, name: "test2"}
iex(8)> Store.Management.open store                       
{:ok, #PID<0.179.0>}
iex(9)> Store.Management.add_employees store, 4 
:ok     
iex(10)> Store.Management.get_store store       
%Store.Struct{employees: 7, name: "test2"}

So far we've created processes, but we don't use Supervisors, let's put it into practice:

otp_test/lib/otp_test/store/supervisor.ex

defmodule Store.Supervisor do
  use Supervisor

  alias Store.Struct, as: Store

  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg,  name: __MODULE__)
  end

  def init(_init_arg) do
    children = [
      create_store(%Store{name: "Test1", employees: 2}),
      create_store(%Store{name: "Test2", employees: 2})
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end

  defp create_store(%Store{} = store) do
    %{
      id: String.to_atom(store.name),
      start: {Core.Store, :start_link, [store]}
    }
  end
end

This way we create a unique supervisor for the store context, when we start, we create two children processes.

The strategy we define for this supervisor's processes is :one_for_one , which restarts each time one of them dies.

To test that our project will start with this supervisor, it is necessary to include it in the Supervisors list, in otp_test/lib/otp_test/application.ex

defmodule OtpTest.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Starts a worker by calling: OtpTest.Worker.start_link(arg)
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: OtpTest.Supervisor]
    Supervisor.start_link(children, opts)
    Store.Supervisor.start_link([])
  end
end

To test our Supervisor started, let's restart the application and check how many processes are in it:

iex(1)> Supervisor.which_children Store.Supervisor
[
  {:Test2, #PID<0.143.0>, :worker, [Core.Store]},
  {:Test1, #PID<0.142.0>, :worker, [Core.Store]}
]
iex(2)> Supervisor.which_children OtpTest.Supervisor
[]

We were able to confirm that when starting the application, Store.Supervisor created the two initial processes, let's test killing one to see if it will restart:

iex(3)> :sys.terminate :Test1, :kill
:ok
iex(4)> 
18:56:44.901 [error] GenServer :Test1 terminating
** (stop) :kill
Last message: []
State: %Store.Struct{employees: 2, name: "Test1"}

nil
iex(5)> Process.whereis :Test1
#PID<0.149.0>
iex(6)>

It worked! 🎉

We killed the :Test1 process, which had the id 142, a new one was automatically generated, this time with the id 149.

With this example I hope that the importance and ease of using OTP in elixir applications is clear, its ease and extensibility allows for great productivity in projects.

I appreciate everyone who has read through here, if you guys have anything to add, please leave a comment.

48