Do you want to pick up from where you left of?
Take me there

Distribución en OTP

Introducción a la distribución

Podemos ejecutar aplicaciones Elixir en un conjunto de nodos distribuidos diferentes ya sea en un único host o en múltiples hosts Elixir nos permite comunicarnos a través de estos nodos usando algunos mecanismos los cuales están fuera del objetivo de esta lección.

Comunicación entre nodos

Elixir corre sobre la máquina virtual de Erlang, esto significa que puede acceder a la poderosa funcionalidad de distribución de Erlang.

Un sistema distribuido en Erlang consiste de un número de sistemas Erlang comunicándose entre sí. Cada sistema es llamado un nodo.

Un nodo es cualquier sistema de Erlang al que se le ha dado un nombre. Podemos iniciar un nodo abriendo una sesión de iex y dándole un nombre:

iex --sname alex@localhost
iex(alex@localhost)>

Vamos a abrir otro nodo en otra terminal:

iex --sname kate@localhost
iex(kate@localhost)>

Estos dos nodos pueden enviarse mensajes entre sí usando Node.spawn_link/2.

Comunicación con Node.spawn_link/2

Esta función toma dos argumentos:

Esto establece una conexión al nodo remoto y ejecuta la función dada en ese nodo, retornando el PID del proceso enlazado.

Vamos a definir un módulo Kate, en el nodo kate que sabe como presentar a Kate, la persona:

iex(kate@localhost)> defmodule Kate do
...(kate@localhost)>   def say_name do
...(kate@localhost)>     IO.puts "Hi, my name is Kate"
...(kate@localhost)>   end
...(kate@localhost)> end

Enviando mensajes

Ahora podemos usar Node.spawn_link/2 para hacer que el nodo alex le diga el nodo kate que ejecute la función say_name/0:

iex(alex@localhost)> Node.spawn_link(:kate@localhost, fn -> Kate.say_name end)
Hi, my name is Kate
#PID<10507.132.0>

Una nota sobre I/O y nodos

Hay que darse cuenta que aunque Kate.say_name/0 está siendo ejecutado en el nodo remoto, es el nodo local el que recibe la salida de IO.puts. Eso es porque el nodo local es el grupo líder. La máquina virtual de Erlang maneja I/O mediante procesos. Esto nos permite ejecutar tareas de I/O como IO.puts a través de nodos distribuidos. Estos procesos distribuidos son administrados por el líder de grupo de los procesos de I/O. El grupo líder es siempre el nodo que genera el proceso. Entonces dado que nuestro nodo alex es desde el que se llamó a spawn_link/2, ese nodo es el líder de grupo y la salida de IO.puts será dirigida a la salida estándar de ese nodo.

Respondiendo a mensajes

¿Qué tal si queremos que el nodo que reciba el mensaje envía una respuesta al que hizo el envío? Podemos usar las funciones receive/1 y send/3 para lograr esto.

Vamos a tener a nuestro nodo alex que genera un enlace con el nodo kate y le da una función anónima para ser ejecutada. Esa función anónima va a esperar recibir una tupla específica la cual contiene un mensaje y el PID del nodo alex. Responderá a ese mensaje enviándole otro mensaje al PID del nodo alex:

iex(alex@localhost)> pid = Node.spawn_link :kate@localhost, fn ->
...(alex@localhost)>   receive do
...(alex@localhost)>     {:hi, alex_node_pid} -> send alex_node_pid, :sup?
...(alex@localhost)>   end
...(alex@localhost)> end
#PID<10467.112.0>
iex(alex@localhost)> pid
#PID<10467.112.0>
iex(alex@localhost)> send(pid, {:hi, self()})
{:hi, #PID<0.106.0>}
iex(alex@localhost)> flush()
:sup?
:ok

Una nota sobre la comunicación entre nodos de diferentes redes

Si quieres enviar mensajes entre nodos de diferentes redes necesitas empezar los nodos con una cookie compartida:

iex --sname alex@localhost --cookie secret_token
iex --sname kate@localhost --cookie secret_token

Solo los nodos que hayan sido iniciados con la misma cookie serán capaces de conectarse satisfactoriamente entre sí.

Limitaciones de Node.spawn_link/2

Mientras que Node.spawn_link/2 ilustra la relación entre nodos y la manera como podemos enviar mensajes entre ellos, esta no es realmente la decisión correcta para una aplicación que correrá entre nodos distribuidos. Node.spawn_link/2 genera procesos aislados, es decir procesos que no están supervisados. Si solo hubiera una forma de generar procesos supervisados, asíncronos a través de los nodos

Tareas distribuidas

Las tareas distribuidas nos permite generar tareas supervisadas a través de nodos. Construiremos una aplicación simple con un supervisor que aprovecha las tareas distribuidas para permitir a los usuarios chatear entre sí usando una sesión iex a través de nodos distribuidos.

Definiendo una aplicación supervisada

Genera tu aplicación:

mix new chat --sup

Agregando un supervisor de tareas al árbol de supervisión

Un supervisor de tareas supervisa dinámicamente tareas. Este empieza sin hijos, frecuentemente bajo otro supervisor, y puede ser usado luego para supervisar cualquier número de tareas.

Vamos a agregar un supervisor de tareas al árbol de supervisión de nuestra aplicación y llamarlo Chat.TaskSupervisor

# lib/chat/application.ex
defmodule Chat.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      {Task.Supervisor, name: Chat.TaskSupervisor}
    ]

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

Ahora que sabemos que donde sea que nuestra aplicación sea iniciada en un nodo, Chat.Supervisor estará corriendo y listo para supervisar tareas.

Enviando mensajes con tareas supervisadas

Empezaremos tareas supervisadas con la función Task.Supervisor.async/5.

Esta función debe tomar cuatro parámetros:

Puedes pasar un quinto elemento, un argumento opcional que describe las opciones de apagado. No nos preocuparemos acerca de eso aquí.

Nuestra aplicación de chat es bastante simple. Envía mensajes a nodos remotos y los nodos remotos responder a estos mensajes usando IO.puts para enviar la respuesta a STDOUT del nodo remoto.

Primero vamos a definir una función Chat.receive_message/1 la cual queremos que nuestra tarea ejecute en un nodo remoto.

# lib/chat.ex
defmodule Chat do
  def receive_message(message) do
    IO.puts message
  end
end

Ahora vamos a enseñarle al módulo Chat como enviar el mensaje a un nodo remoto usando una tarea supervisada. Definiremos una función Chat.send_message/2 que hará este proceso:

# lib/chat.ex
defmodule Chat do
  ...

  def send_message(recipient, message) do
    spawn_task(__MODULE__, :receive_message, recipient, [message])
  end

  def spawn_task(module, fun, recipient, args) do
    recipient
    |> remote_supervisor()
    |> Task.Supervisor.async(module, fun, args)
    |> Task.await()
  end

  defp remote_supervisor(recipient) do
    {Chat.TaskSupervisor, recipient}
  end
end

Vamos a verlo en acción.

En una terminal inicia nuestra aplicación de chat en una sesión iex con nombre.

iex --sname alex@localhost -S mix

Abre otra terminal y empieza la aplicación con un nombre de nodo diferente:

iex --sname kate@localhost -S mix

Ahora desde el nodo alex podemos enviar un mensaje al nodo kate:

iex(alex@localhost)> Chat.send_message(:kate@localhost, "hi")
:ok

Cambia a la ventana del nodo kate y deberías ver el mensaje:

iex(kate@localhost)> hi

El nodo kate puede responder al nodo alex:

iex(kate@localhost)> hi
Chat.send_message(:alex@localhost, "how are you?")
:ok
iex(kate@localhost)>

Y esto se mostrará en la sesión iex del nodo alex:

iex(alex@localhost)> how are you?

Vamos a volver a visitar nuestro código ver que es lo que está pasando aquí.

Tenemos una función Chat.send_message/2 que toma el nombre del nodo remoto en el cual queremos ejecutar nuestra tarea supervisada y el mensaje que queremos enviar a ese nodo.

Esa función llama a nuestra función spawn_task/4 la cual empieza una tarea asíncrona en el nodo remoto con el nombre dado, supervisada por Chat.TaskSupervisor en ese nodo remoto. Sabemos que el supervisor de tareas con el nombre Chat.TaskSupervisor está corriendo en ese nodo porque ese nodo también está corriendo una instancia de nuestra aplicación de chat y Chat.TaskSupervisor ha iniciado como parte del árbol de supervisión de nuestra aplicación.

Le estamos diciendo a Chat.TaskSupervisor que supervise una tarea que ejecuta la función Chat.receive_message con un argumento de cualquier mensaje que se haya pasado a spawn_task/4 desde send_message/2.

Por lo que Chat.receive_message("hi") es llamado en el nodo remoto kate causando que el mensaje "hi" sea pasado al flujo STDOUT de ese nodo.

En este caso dado que la tarea esta siendo supervisada en el nodo remoto, ese nodo es el líder de grupo para este proceso de I/O.

Respondiendo a los mensajes desde los nodos remotos

Vamos a hacer que nuestra aplicación de chat sea un poco mas inteligente. Hasta aquí cualquier número de usuarios puede ejecutar la aplicación en una sesión iex y comenzar a chatear. Pero vamos a decir que hay un perro blanco de tamaño medio llamado Moebi quien no quiere quedarse fuera. Moebi quiere ser incluido en la aplicación de chat pero tristemente el no sabe como escribir porque es un perro. Por lo que vamos a enseñarle a nuestro módulo Chat a responder a cualquier mensaje enviado al nodo llamado moebi@localhost en lugar de Moebi. No importa que le digas a Moebi, el siempre responderá con "chicken?" porque su único deseo real es comer pollo.

Vamos a definir otra versión de nuestra función send_message/2 que haga pattern matching con el argumento recipient. Si el destinatario es :moebi@localhost vamos a:

# lib/chat.ex
...
def send_message(:moebi@localhost, message) do
  spawn_task(__MODULE__, :receive_message_for_moebi, :moebi@localhost, [message, Node.self()])
end

Ahora definiremos una función receive_message_for_moebi/2 que imprima, con IO.puts, el mensaje en el flujo STDOUT del nodo de moebi y envíe un mensaje de regreso al emisor.

# lib/chat.ex
...
def receive_message_for_moebi(message, from) do
  IO.puts message
  send_message(from, "chicken?")
end

Llamando a send_message/2 con el nombre del nodo que envió el mensaje original (el nodo emisor) le estamos diciendo al nodo remoto que genere una tarea supervisada en el nodo emisor.

Vamos a verlo en acción. En tres diferentes terminales, abre tres diferentes nodos:

iex --sname alex@localhost -S mix
iex --sname kate@localhost -S mix
iex --sname moebi@localhost -S mix

Vamos a hacer que alex envié un mensaje a moebi:

iex(alex@localhost)> Chat.send_message(:moebi@localhost, "hi")
chicken?
:ok

Podemos ver que el nodo alex recibe el mensaje "chicken?". Si abrimos el nodo kate veremos que no se ha recibido ningún mensaje dado que ni alex o moebi le enviaron uno (disculpa kate). Si abrimos la terminal del nodo moebi veremos el mensaje que el nodo alex envió.

iex(moebi@localhost)> hi

Probando código distribuido

Vamos a empezar a escribir una prueba simple para nuestra función send_message.

# test/chat_test.exs
defmodule ChatTest do
  use ExUnit.Case, async: true
  doctest Chat

  test "send_message" do
    assert Chat.send_message(:moebi@localhost, "hi") == :ok
  end
end

Si ejecutamos nuestras pruebas usando mix test veremos que fallan con el siguiente error:

** (exit) exited in: GenServer.call({Chat.TaskSupervisor, :moebi@localhost}, {:start_task, [#PID<0.158.0>, :monitor, {:sophie@localhost, #PID<0.158.0>}, {Chat, :receive_message_for_moebi, ["hi", :sophie@localhost]}], :temporary, nil}, :infinity)
         ** (EXIT) no connection to moebi@localhost

Este error tiene mucho sentido, no podemos conectarnos a un nodo llamado moebi@localhost porque no existe tal nodo corriendo.

Podemos hacer que esta prueba pase ejecutando algunos pasos:

Esto es demasiado trabajo y definitivamente nos sería considerado un proceso de pruebas automatizado.

Hay dos enfoques diferentes que podríamos usar aquí:

1.

Condicionalmente excluir las pruebas que necesitan nodos distribuidos si el nodo necesario no está corriendo.

2.

Configurar nuestra aplicación para evitar generar tareas en nodos remotos en el entorno de pruebas.

Vamos a revisar el primer enfoque.

Excluir pruebas condicionalmente usando etiquetas

Agregaremos una etiqueta ExUnit a esta prueba:

# test/chat_test.exs
defmodule ChatTest do
  use ExUnit.Case, async: true
  doctest Chat

  @tag :distributed
  test "send_message" do
    assert Chat.send_message(:moebi@localhost, "hi") == :ok
  end
end

Y agregaremos lógica condicional a nuestro helper de pruebas para excluir pruebas con tales etiquetas si las pruebas no están corriendo en un nodo con nombre.

# test/test_helper.exs
exclude =
  if Node.alive?, do: [], else: [distributed: true]

ExUnit.start(exclude: exclude)

Vamos a revisar si el nodo está vivo, es decir si el nodo es parte de un sistema distribuido con Node.alive?. Si no, podemos llamar a ExUnit para omitir cualquier prueba con la etiqueta distributed: true. De otra manera le diremos que no excluya las pruebas.

Ahora si ejecutamos mix test veremos:

mix test
Excluding tags: [distributed: true]

Finished in 0.02 seconds
1 test, 0 failures, 1 excluded

Si queremos ejecutar pruebas distribuidas, simplemente necesitamos hacer los pasos relatados en la sección previa: ejecutar el nodo moebi@localhost y ejecutar las pruebas en un nodo nombrado usando iex.

Vamos a revisar el otro enfoque configurando la aplicación para comportarse diferente en entornos diferentes.

Configuración específica por entorno

La parte de nuestro código que dice Task.Supervisor para empezar una tarea supervisada en un nodo remoto está aquí:

# app/chat.ex
def spawn_task(module, fun, recipient, args) do
  recipient
  |> remote_supervisor()
  |> Task.Supervisor.async(module, fun, args)
  |> Task.await()
end

defp remote_supervisor(recipient) do
  {Chat.TaskSupervisor, recipient}
end

Task.Supervisor.async/5 toma en un primer argumento el supervisor que queremos usar. Si pasamos una tupla de {SupervisorName, location}, empezará el supervisor especificado en el nodo especificado. Sin embargo si pasamos Task.Supervisor como primer argumento de nuestro nombre de supervisor iniciará ese supervisor para supervisar tareas localmente.

Vamos a hacer la función remote_supervisor/1 configurable basada en el entorno. En el entorno de desarrollo retornará {Chat.TaskSupervisor, recipient} y en el entorno de pruebas retornará Chat.TaskSupervisor.

Haremos esto mediante variables de aplicación.

Crea un archivo config/dev.exs y agrega:

# config/dev.exs
use Mix.Config
config :chat, remote_supervisor: fn(recipient) -> {Chat.TaskSupervisor, recipient} end

Crea un archivo config/test.exs y agrega:

# config/test.exs
use Mix.Config
config :chat, remote_supervisor: fn(_recipient) -> Chat.TaskSupervisor end

Recuerda descomentar esta linea en config/config.exs:

import_config "#{Mix.env()}.exs"

Por último actualizaremos nuestra función Chat.remote_supervisor/1 para usar la función guardada en nuestra nueva variable de aplicación:

# lib/chat.ex
defp remote_supervisor(recipient) do
  Application.get_env(:chat, :remote_supervisor).(recipient)
end

Conclusión

Las capacidades distribuidas de Elixir, las cuales son gracias al poder de la máquina virtual de Erlang, es una de las características que lo hacen una herramienta poderosa. Podemos imaginar aprovechar la habilidad de Elixir para manejar cómputo distribuido para correr trabajos en segundo plano concurrentes, para soportar aplicaciones de gran rendimiento, para ejecutar operaciones costosas, o lo que se te pueda ocurrir.

Esta lección nos da una introducción básica del concepto de distribución en Elixir y te da las herramientas que necesitas para empezar a construir aplicaciones distribuidas. Usando tareas supervisadas puedes enviar mensajes a través de varios nodos de una aplicación distribuida.

¿Encontraste un error o quieres contribuir a la lección? ¡Edita esta lección en GitHub!