Supervisión y tolerancia a fallos
Aprende la filosofía "let it crash" y cómo construir sistemas que se recuperan automáticamente de fallos. ⏱️ 2.5 horas
La filosofía "Let it crash"
En lenguajes como Rust o C++, la práctica común es el defensive programming: anticipar todo lo que puede fallar y manejarlo explícitamente. En Elixir, adoptamos un enfoque diferente:
- Escribe código para el caso feliz (happy path)
- Deja que los procesos fallen cuando algo inesperado ocurre
- Usa supervisores para reiniciar procesos fallidos
- El sistema se auto-recupera
Esto funciona porque los procesos BEAM son aislados: cuando uno falla, no corrompe el estado de otros. Y crear procesos es tan barato que reiniciar es una estrategia viable.
Imagina un subscriber que procesa ticks de precio. Si recibe un tick malformado y falla, es mejor que muera y sea reiniciado limpio a que continúe en un estado corrupto procesando datos incorrectos.
Supervisores básicos
Un supervisor es un proceso especializado que:
- Inicia procesos hijos
- Monitorea su estado
- Los reinicia cuando fallan según una estrategia
defmodule PriceWorker do
use GenServer
def start_link(symbol) do
GenServer.start_link(__MODULE__, symbol, name: via_tuple(symbol))
end
defp via_tuple(symbol), do: {:via, Registry, {PriceRegistry, symbol}}
def init(symbol) do
IO.puts("[#{symbol}] Worker iniciado")
{:ok, %{symbol: symbol, ultimo_precio: nil}}
end
def handle_cast({:precio, precio}, state) do
IO.puts("[#{state.symbol}] Precio: $#{precio}")
{:noreply, %{state | ultimo_precio: precio}}
end
# Simular un crash
def handle_cast(:crash, _state) do
raise "¡Crash simulado!"
end
end
defmodule PriceSupervisor do
use Supervisor
def start_link(symbols) do
Supervisor.start_link(__MODULE__, symbols, name: __MODULE__)
end
def init(symbols) do
children = Enum.map(symbols, fn symbol ->
%{
id: symbol,
start: {PriceWorker, :start_link, [symbol]}
}
end)
Supervisor.init(children, strategy: :one_for_one)
end
end
Estrategias de supervisión
El supervisor tiene cuatro estrategias principales para manejar fallos de hijos:
:one_for_one
Si un hijo falla, solo ese hijo se reinicia. Los demás no se afectan.
# Ideal para workers independientes
# Ej: cada subscriber de precio es independiente
[Worker A] [Worker B] [Worker C]
↓
[Worker A] muere
↓
[Worker A*] [Worker B] [Worker C] # Solo A reiniciado
:one_for_all
Si un hijo falla, todos los hijos se terminan y reinician.
# Para procesos que dependen entre sí
# Ej: parser + validador + enriquecedor que comparten estado
[Worker A] [Worker B] [Worker C]
↓
[Worker B] muere
↓
# Todos terminados y reiniciados
[Worker A*] [Worker B*] [Worker C*]
:rest_for_one
Si un hijo falla, ese hijo y todos los que fueron iniciados después se reinician.
# Para dependencias secuenciales
# Ej: conexión → autenticador → handler
[Worker A] [Worker B] [Worker C]
↓
[Worker B] muere
↓
[Worker A] [Worker B*] [Worker C*] # B y C reiniciados
:simple_one_for_one (ahora DynamicSupervisor)
Para supervisar un número dinámico de workers del mismo tipo.
defmodule SubscriberSupervisor do
use DynamicSupervisor
def start_link(_) do
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
# Añadir subscriber dinámicamente
def add_subscriber(config) do
spec = {Subscriber, config}
DynamicSupervisor.start_child(__MODULE__, spec)
end
# Eliminar subscriber
def remove_subscriber(pid) do
DynamicSupervisor.terminate_child(__MODULE__, pid)
end
end
| :one_for_one | Workers independientes (lo más común) |
| :one_for_all | Procesos que comparten estado crítico |
| :rest_for_one | Cadena de dependencias (A→B→C) |
| DynamicSupervisor | Número variable de workers del mismo tipo |
Límites de reinicio
Los supervisores tienen límites para evitar crash loops: si un proceso falla demasiado rápido, el supervisor también falla (y su supervisor lo reinicia, o el sistema se detiene).
Supervisor.init(children,
strategy: :one_for_one,
max_restarts: 3, # Máximo 3 reinicios...
max_seconds: 5 # ...en 5 segundos
)
Por defecto son 3 reinicios en 5 segundos. Si se excede, el supervisor
falla con {:shutdown, :reached_max_restart_intensity}.
Child specs
Cada hijo se define con un child spec que indica cómo iniciarlo y reiniciarlo:
%{
id: MiWorker, # Identificador único
start: {MiWorker, :start_link, [arg1, arg2]}, # MFA
restart: :permanent, # Cuándo reiniciar
shutdown: 5000, # Tiempo para shutdown graceful
type: :worker # :worker o :supervisor
}
Opciones de restart
| Valor | Comportamiento |
|---|---|
:permanent |
Siempre reiniciar (default) |
:temporary |
Nunca reiniciar |
:transient |
Reiniciar solo si termina anormalmente |
defmodule OneTimeTask do
use GenServer, restart: :transient # En el use
# O definir child_spec explícitamente
def child_spec(arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [arg]},
restart: :transient
}
end
end
Ejemplo: Sistema de feeds con supervisión
Veamos un ejemplo más completo que modela un sistema de feeds financieros:
defmodule FeedConnection do
use GenServer
require Logger
def start_link(feed_url) do
GenServer.start_link(__MODULE__, feed_url)
end
def init(feed_url) do
Logger.info("Conectando a #{feed_url}")
# Simular conexión
Process.send_after(self(), :check_connection, 5000)
{:ok, %{url: feed_url, connected: true, ticks_received: 0}}
end
def handle_info(:check_connection, state) do
# Simular fallo aleatorio de conexión (10% chance)
if :rand.uniform(10) == 1 do
Logger.error("Conexión perdida con #{state.url}")
{:stop, :connection_lost, state}
else
Process.send_after(self(), :check_connection, 5000)
{:noreply, state}
end
end
def handle_cast({:tick, data}, state) do
{:noreply, %{state | ticks_received: state.ticks_received + 1}}
end
end
defmodule FeedSupervisor do
use Supervisor
def start_link(feeds) do
Supervisor.start_link(__MODULE__, feeds, name: __MODULE__)
end
def init(feeds) do
children = Enum.map(feeds, fn {name, url} ->
%{
id: name,
start: {FeedConnection, :start_link, [url]},
restart: :permanent
}
end)
Supervisor.init(children,
strategy: :one_for_one,
max_restarts: 10,
max_seconds: 60
)
end
end
# Iniciar el sistema
feeds = [
{:nasdaq, "wss://feed.nasdaq.com"},
{:nyse, "wss://feed.nyse.com"},
{:crypto, "wss://feed.binance.com"}
]
{:ok, sup} = FeedSupervisor.start_link(feeds)
Inspeccionando supervisores
# Ver hijos de un supervisor
Supervisor.which_children(FeedSupervisor)
# [
# {:crypto, #PID<0.234.0>, :worker, [FeedConnection]},
# {:nyse, #PID<0.233.0>, :worker, [FeedConnection]},
# {:nasdaq, #PID<0.232.0>, :worker, [FeedConnection]}
# ]
# Contar hijos
Supervisor.count_children(FeedSupervisor)
# %{active: 3, specs: 3, supervisors: 0, workers: 3}
# Terminar un hijo específico
Supervisor.terminate_child(FeedSupervisor, :nasdaq)
# Reiniciar un hijo
Supervisor.restart_child(FeedSupervisor, :nasdaq)
Terminate y cleanup
Cuando un supervisor termina un hijo (shutdown normal o restart), el hijo recibe la oportunidad de hacer cleanup:
defmodule FeedConnection do
use GenServer
# ... init y handles ...
def terminate(reason, state) do
Logger.info("Terminando conexión a #{state.url}. Razón: #{inspect(reason)}")
# Cleanup: cerrar conexiones, flush buffers, etc.
:ok
end
end
terminate/2 solo se llama si el proceso está trapeando
exits (Process.flag(:trap_exit, true)) o si el
shutdown es graceful. En un crash brutal, puede que no se ejecute.
Crea un supervisor que maneje 3 workers de tipo Counter.
Cada Counter debe:
- Mantener un contador interno
- Responder a
:incrementy{:get, caller} - Fallar si el contador llega a 5
Verifica que cuando un Counter falla, es reiniciado con contador en 0.
Implementa un SubscriberManager usando DynamicSupervisor
que permita:
add_subscriber(symbol)- añadir subscriber para un símboloremove_subscriber(pid)- eliminar subscriberlist_subscribers()- listar todos los subscribers activosbroadcast(mensaje)- enviar mensaje a todos
Diseña un árbol de supervisión para este escenario:
- Un
FeedManagerque conecta a múltiples feeds - Cada feed tiene un
Parsery unValidator - Si el Parser falla, el Validator también debe reiniciarse
- Si un feed falla, no debe afectar a otros feeds
- Hay un
Aggregatorglobal que recibe de todos los feeds
Dibuja el árbol y especifica qué estrategia usar en cada nivel.
Conexión con el proyecto final
El sistema de distribución de datos financieros usará supervisión extensivamente:
- DynamicSupervisor para subscribers: nuevos clientes se conectan y desconectan dinámicamente.
- :one_for_one para feeds: si un feed falla, los demás continúan.
- :rest_for_one para pipeline: parser → validator → broadcaster deben reiniciarse juntos si falla el parser.
- max_restarts ajustados: un feed que falla constantemente indica un problema de red, no de código.