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:

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.

📊 Analogía con sistemas financieros

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:

  1. Inicia procesos hijos
  2. Monitorea su estado
  3. 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
💡 ¿Cuándo usar cada estrategia?
: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 no siempre se llama

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.

Ejercicio 4.1 Supervisor básico Básico

Crea un supervisor que maneje 3 workers de tipo Counter. Cada Counter debe:

  • Mantener un contador interno
  • Responder a :increment y {:get, caller}
  • Fallar si el contador llega a 5

Verifica que cuando un Counter falla, es reiniciado con contador en 0.

Ejercicio 4.2 DynamicSupervisor de subscribers Intermedio

Implementa un SubscriberManager usando DynamicSupervisor que permita:

  • add_subscriber(symbol) - añadir subscriber para un símbolo
  • remove_subscriber(pid) - eliminar subscriber
  • list_subscribers() - listar todos los subscribers activos
  • broadcast(mensaje) - enviar mensaje a todos
Ejercicio 4.3 Estrategias de supervisión Avanzado

Diseña un árbol de supervisión para este escenario:

  • Un FeedManager que conecta a múltiples feeds
  • Cada feed tiene un Parser y un Validator
  • Si el Parser falla, el Validator también debe reiniciarse
  • Si un feed falla, no debe afectar a otros feeds
  • Hay un Aggregator global 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: