OTP Básico

GenServer y Supervisor: los pilares para construir aplicaciones robustas.

¿Qué es OTP?

OTP (Open Telecom Platform) es un conjunto de bibliotecas y patrones probados durante décadas en sistemas de alta disponibilidad. Los conceptos clave son:

¿Por qué OTP?

OTP encapsula las mejores prácticas de 30+ años de desarrollo en Erlang. Sistemas como WhatsApp usan OTP para manejar millones de conexiones con altísima disponibilidad.

GenServer: Servidores genéricos

GenServer es la abstracción más importante de OTP. Maneja el loop de recepción de mensajes, el estado, y proporciona una interfaz estándar:

defmodule Contador do
  use GenServer

  # --- API del cliente (funciones públicas) ---

  def start_link(valor_inicial \\ 0) do
    GenServer.start_link(__MODULE__, valor_inicial, name: __MODULE__)
  end

  def valor do
    GenServer.call(__MODULE__, :valor)
  end

  def incrementar do
    GenServer.cast(__MODULE__, :incrementar)
  end

  def decrementar do
    GenServer.cast(__MODULE__, :decrementar)
  end

  # --- Callbacks del servidor ---

  @impl true
  def init(valor_inicial) do
    {:ok, valor_inicial}
  end

  @impl true
  def handle_call(:valor, _from, estado) do
    {:reply, estado, estado}
  end

  @impl true
  def handle_cast(:incrementar, estado) do
    {:noreply, estado + 1}
  end

  @impl true
  def handle_cast(:decrementar, estado) do
    {:noreply, estado - 1}
  end
end

Uso del contador

iex> Contador.start_link(10)
{:ok, #PID<0.123.0>}

iex> Contador.valor()
10

iex> Contador.incrementar()
:ok

iex> Contador.incrementar()
:ok

iex> Contador.valor()
12

call vs cast

Aspectocallcast
SincroníaSíncrono (bloquea hasta respuesta)Asíncrono (retorna inmediato)
RespuestaSí, el servidor envía respuestaNo, solo :ok
Callbackhandle_call/3handle_cast/2
Retorno{:reply, respuesta, estado}{:noreply, estado}
Uso típicoConsultar estado, operaciones que necesitan confirmaciónNotificaciones, operaciones "fire and forget"

handle_info: Mensajes no estructurados

Para manejar mensajes que no vienen de call o cast:

@impl true
def handle_info(:tick, estado) do
  IO.puts("Tick! Estado: #{estado}")
  Process.send_after(self(), :tick, 1000)  # Próximo tick en 1s
  {:noreply, estado + 1}
end

# Iniciar el timer en init
@impl true
def init(estado) do
  Process.send_after(self(), :tick, 1000)
  {:ok, estado}
end

Supervisor: Tolerancia a fallos

Los supervisores vigilan procesos hijos y los reinician cuando fallan:

defmodule MiApp.Supervisor do
  use Supervisor

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, :ok, opts)
  end

  @impl true
  def init(:ok) do
    hijos = [
      {Contador, 0},
      # Más procesos hijos...
    ]

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

Estrategias de reinicio

EstrategiaComportamiento
:one_for_oneSolo reinicia el proceso que falló
:one_for_allSi uno falla, reinicia todos los hijos
:rest_for_oneReinicia el que falló y los iniciados después de él
"Let it crash"

La filosofía de OTP es dejar que los procesos fallen y que los supervisores los reinicien. Es más simple y robusto que intentar manejar todos los errores posibles.

Ejemplo completo: Carrito de compras

defmodule Carrito do
  use GenServer

  # --- API del cliente ---

  def start_link(usuario_id) do
    GenServer.start_link(__MODULE__, usuario_id, name: via(usuario_id))
  end

  def agregar(usuario_id, producto, cantidad \\ 1) do
    GenServer.call(via(usuario_id), {:agregar, producto, cantidad})
  end

  def quitar(usuario_id, producto) do
    GenServer.call(via(usuario_id), {:quitar, producto})
  end

  def ver(usuario_id) do
    GenServer.call(via(usuario_id), :ver)
  end

  def vaciar(usuario_id) do
    GenServer.cast(via(usuario_id), :vaciar)
  end

  defp via(usuario_id), do: {:via, Registry, {Carrito.Registry, usuario_id}}

  # --- Callbacks del servidor ---

  @impl true
  def init(usuario_id) do
    estado = %{
      usuario_id: usuario_id,
      items: %{},
      creado: DateTime.utc_now()
    }
    {:ok, estado}
  end

  @impl true
  def handle_call({:agregar, producto, cantidad}, _from, estado) do
    items = Map.update(estado.items, producto, cantidad, &(&1 + cantidad))
    nuevo_estado = %{estado | items: items}
    {:reply, {:ok, items}, nuevo_estado}
  end

  @impl true
  def handle_call({:quitar, producto}, _from, estado) do
    items = Map.delete(estado.items, producto)
    nuevo_estado = %{estado | items: items}
    {:reply, {:ok, items}, nuevo_estado}
  end

  @impl true
  def handle_call(:ver, _from, estado) do
    {:reply, estado.items, estado}
  end

  @impl true
  def handle_cast(:vaciar, estado) do
    {:noreply, %{estado | items: %{}}}
  end
end

Supervisor dinámico para carritos

defmodule Carrito.Supervisor do
  use DynamicSupervisor

  def start_link(opts) do
    DynamicSupervisor.start_link(__MODULE__, :ok, opts)
  end

  def crear_carrito(usuario_id) do
    DynamicSupervisor.start_child(__MODULE__, {Carrito, usuario_id})
  end

  @impl true
  def init(:ok) do
    DynamicSupervisor.init(strategy: :one_for_one)
  end
end

child_spec

Cada módulo que puede ser supervisado define cómo iniciarse:

defmodule MiServidor do
  use GenServer

  # Personalizar child_spec
  def child_spec(arg) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [arg]},
      restart: :permanent,  # :permanent | :temporary | :transient
      type: :worker
    }
  end

  # ...
end
Opciones de restart

:permanent - Siempre reiniciar
:temporary - Nunca reiniciar
:transient - Reiniciar solo si termina anormalmente

Ejercicio 8.1 Stack GenServer Intermedio

Implementa una pila (stack) como GenServer:

  • push(valor) - Agrega al tope
  • pop() - Quita y retorna el tope
  • peek() - Ver el tope sin quitar
  • size() - Cantidad de elementos
Ejercicio 8.2 Cache con expiración Avanzado

Crea un GenServer que funcione como cache:

  • put(clave, valor, ttl_segundos) - Guardar con tiempo de vida
  • get(clave) - Obtener (nil si expiró)
  • Usa handle_info para limpiar entradas expiradas periódicamente