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
