Supervisores en OTP: cómo Elixir recupera errores de forma automática

La reputación de Erlang y Elixir de producir sistemas que "nunca se caen" no viene de que el código sea perfecto. Viene de que los errores se asumen como algo inevitable y el sistema está diseñado para recuperarse solo. Los supervisores son el mecanismo que hace posible eso.

La filosofía: deja fallar

En la mayoría de lenguajes, el instinto es rodear el código de try-catch para manejar cada error posible. En OTP, la filosofía es diferente: no intentes manejar errores que no sabes cómo tratar. Deja que el proceso falle, y que un supervisor lo limpie y lo reinicie desde un estado conocido.

Esto funciona porque en Elixir los procesos son baratos, tienen estado aislado y no comparten memoria. El fallo de uno no corrompe el estado de los demás. El supervisor simplemente arranca uno nuevo.

Supervisor básico

defmodule MiApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MiApp.Repo,
      MiAppWeb.Endpoint,
      {MiApp.Cache, ttl: 3600},
      MiApp.Worker
    ]

    opts = [strategy: :one_for_one, name: MiApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

El módulo Application es el punto de entrada de una aplicación OTP. Define qué procesos arrancar y con qué supervisión. Los hijos se especifican como una lista: pueden ser módulos que implementen el behaviour GenServer, tuplas {modulo, args}, o mapas con la especificación completa del proceso.

Estrategias de reinicio

La estrategia controla qué pasa cuando un proceso hijo falla:

  • :one_for_one: solo reinicia el proceso que falló. Los demás siguen como están. Úsalo cuando los procesos son independientes entre sí.
  • :one_for_all: reinicia todos los hijos si uno falla. Úsalo cuando los procesos dependen unos de otros y tiene más sentido reiniciar el grupo completo.
  • :rest_for_one: reinicia el que falló y todos los que se iniciaron después de él. Útil cuando hay una cadena de dependencias: el proceso B depende de A, C depende de B, etc.
# one_for_all: si el worker de base de datos falla, reinicia todo
Supervisor.start_link(children, strategy: :one_for_all)

# rest_for_one: si falla el proceso 2, reinicia procesos 2 y 3 pero no el 1
Supervisor.start_link(children, strategy: :rest_for_one)

Umbrales de reinicio

Si un proceso falla repetidamente en un periodo corto, puede indicar un problema que el reinicio no va a resolver. El supervisor tiene dos opciones de configuración para esto:

opts = [
  strategy: :one_for_one,
  max_restarts: 3,    # máximo 3 reinicios...
  max_seconds: 5,     # ...en los últimos 5 segundos
  name: MiApp.Supervisor
]

Si se supera ese umbral, el supervisor propio falla, lo que escala el error al supervisor padre. Esto permite que los errores graves lleguen a ser visibles sin que el sistema entre en un bucle infinito de reinicios.

Especificación de hijos y tipos de reinicio

Cada hijo tiene una especificación que controla cómo se reinicia:

children = [
  # Worker normal: se reinicia siempre que falla
  %{
    id: MiWorker,
    start: {MiWorker, :start_link, [[]]},
    restart: :permanent,  # siempre reiniciar (por defecto)
    type: :worker
  },
  # Tarea temporal: no se reinicia si termina normalmente
  %{
    id: MiTarea,
    start: {MiTarea, :start_link, [[]]},
    restart: :temporary,  # nunca reiniciar
    type: :worker
  },
  # Proceso transitorio: se reinicia solo si falla con error
  %{
    id: MiTransitorio,
    start: {MiTransitorio, :start_link, [[]]},
    restart: :transient,  # solo reiniciar si falla (no si termina normal)
    type: :worker
  }
]

DynamicSupervisor: procesos que no se conocen de antemano

El Supervisor estático conoce sus hijos en tiempo de arranque. Para procesos que se crean en respuesta a eventos en tiempo de ejecución (una conexión por usuario, un job por petición), existe DynamicSupervisor:

defmodule MiApp.ConnectionSupervisor do
  use DynamicSupervisor

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

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

  def iniciar_conexion(usuario_id) do
    spec = {MiApp.ConexionWorker, usuario_id}
    DynamicSupervisor.start_child(__MODULE__, spec)
  end

  def terminar_conexion(pid) do
    DynamicSupervisor.terminate_child(__MODULE__, pid)
  end
end

Con esto, cada usuario que se conecta obtiene su propio proceso supervisor, sin necesidad de saber de antemano cuántos habrá.

Task.Supervisor: tareas puntuales con supervisión

Task.Supervisor es específico para tareas de vida corta que no tienen estado persistente:

# Arrancar el supervisor de tareas (en el árbol de supervisión)
children = [
  {Task.Supervisor, name: MiApp.TaskSupervisor}
]

# Lanzar una tarea supervisada
Task.Supervisor.start_child(MiApp.TaskSupervisor, fn ->
  procesar_email_async(email)
end)

# Tarea con respuesta (async/await supervisado)
tarea = Task.Supervisor.async(MiApp.TaskSupervisor, fn ->
  calcular_estadisticas()
end)

resultado = Task.await(tarea, 5_000)  # timeout de 5 segundos

El árbol completo en un proyecto Phoenix

En un proyecto Phoenix típico, el árbol de supervisión tiene varios niveles:

# Nivel raíz (Application)
MiApp.Supervisor
  ??? MiApp.Repo              # pool de conexiones a PostgreSQL
  ??? MiApp.TaskSupervisor    # supervisor de tareas
  ??? MiApp.PubSub           # pub/sub para LiveView
  ??? MiApp.ConnectionSupervisor  # conexiones dinámicas
  ??? MiAppWeb.Endpoint      # servidor HTTP y WebSockets
        ??? Phoenix.LiveView.Socket  # un proceso por cliente LiveView

Este árbol hace que los fallos se contengan. Un error en una conexión LiveView no afecta al Repo ni al servidor HTTP. Un fallo del Repo no tumbará el proceso de pub/sub. Cada rama falla y se reinicia de forma independiente.

Para entender los procesos individuales que supervisa este árbol, el artículo sobre GenServer de esta serie es el complemento directo. Y para ver cómo se compara con el modelo de recuperación de errores de otros sistemas robustos, el artículo sobre Go 1.26 muestra un enfoque diferente donde la recuperación de errores es más manual.

Imagen: Pexels / Vladimir Srajber

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP