├── .formatter.exs ├── .gitignore ├── README.md ├── illustration.png ├── lib ├── ping_machine.ex └── ping_machine │ ├── application.ex │ └── subnet_manager.ex ├── mix.exs ├── mix.lock └── test ├── ping_machine_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ping_machine-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The ping machine 2 | 3 | The ping machine demonstrates some core Elixir and OTP programming concepts using 4 | `Supervisors`, `GenServers` and `Tasks` to concurrently ping all hosts in a network subnet range. 5 | 6 | ![Ping Machine](illustration.png) 7 | 8 | **DISCLAIMER: We will not actually send any ping requests. This is trival to implement, 9 | but beside the point of this demo. We simply sleeps for a given amount of time and 10 | randomly choose if the "request" was successful or not.** 11 | 12 | If you would like to read the full tutorial, it's available over at the 13 | [{engineering@intility}](https://engineering.intility.com/article/building-a-concurrent-network-pinger-pt-1) blog! 14 | 15 | ## Running the project 16 | 17 | Just run the project using `iex -S mix` from the project root. 18 | 19 | ```shell 20 | $ mix deps.get 21 | ... 22 | $ iex -S mix 23 | iex(1)> {:ok, pid} = PingMachine.start_ping("192.168.1.0/24") 24 | [info] Started pinging all hosts in range 192.168.1.0/24 25 | {:ok, #PID<0.212.0>} 26 | [info] Successfully pinged host 192.168.1.139 27 | [info] Successfully pinged host 192.168.1.254 28 | [error] Failed to ping host 192.168.1.29 29 | [info] Successfully pinged host 192.168.1.21 30 | [info] Successfully pinged host 192.168.1.108 31 | [error] Failed to ping host 192.168.1.119 32 | [info] Successfully pinged host 192.168.1.16 33 | [error] Failed to ping host 192.168.1.109 34 | 35 | iex(2)> PingMachine.get_successful_hosts(pid) 36 | [ "192.168.1.84", "192.168.1.161", "192.168.1.50", "192.168.1.2", 37 | "192.168.1.226", "192.168.1.97", "192.168.1.186", "192.168.1.233", 38 | "192.168.1.72", "192.168.1.148", "192.168.1.180", "192.168.1.203", 39 | "192.168.1.73", "192.168.1.107", ...] 40 | 41 | iex(3)> PingMachine.get_failed_hosts(pid) 42 | [ "192.168.1.113", "192.168.1.24", "192.168.1.101", "192.168.1.193", 43 | "192.168.1.197", "192.168.1.219", "192.168.1.22", "192.168.1.165", 44 | "192.168.1.128", "192.168.1.155", "192.168.1.76", "192.168.1.183", 45 | "192.168.1.104", "192.168.1.110", "192.168.1.163", "192.168.1.156", ...] 46 | 47 | iex(4)> PingMachine.stop_ping(pid) 48 | :ok 49 | ``` 50 | -------------------------------------------------------------------------------- /illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/blog-ping-machine/0ba915dff4051d925102f5b8916931edc4aac980/illustration.png -------------------------------------------------------------------------------- /lib/ping_machine.ex: -------------------------------------------------------------------------------- 1 | defmodule PingMachine do 2 | @moduledoc false 3 | 4 | require IP.Subnet 5 | require Logger 6 | 7 | def start_ping(subnet) when is_binary(subnet) do 8 | with {:ok, subnet} <- IP.Subnet.from_string(subnet), 9 | {:ok, pid} <- start_worker(subnet) do 10 | Logger.info("Started pinging all hosts in range #{IP.Subnet.to_string(subnet)}") 11 | {:ok, pid} 12 | else 13 | {:error, {:already_started, pid}} -> 14 | Logger.warn("Already running the #{subnet} range") 15 | {:ok, pid} 16 | 17 | {:error, :einval} = reply -> 18 | Logger.error("#{subnet} is not a valid subnet range") 19 | reply 20 | end 21 | end 22 | 23 | def stop_ping(pid) when is_pid(pid) do 24 | DynamicSupervisor.terminate_child(PingMachine.PingSupervisor, pid) 25 | end 26 | 27 | def get_successful_hosts(pid) when is_pid(pid) do 28 | GenServer.call(pid, :successful_hosts) 29 | end 30 | 31 | def get_failed_hosts(pid) when is_pid(pid) do 32 | GenServer.call(pid, :failed_hosts) 33 | end 34 | 35 | defp start_worker(subnet) when IP.Subnet.is_subnet(subnet) do 36 | DynamicSupervisor.start_child( 37 | PingMachine.PingSupervisor, 38 | {PingMachine.SubnetManager, subnet} 39 | ) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ping_machine/application.ex: -------------------------------------------------------------------------------- 1 | defmodule PingMachine.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | {DynamicSupervisor, strategy: :one_for_one, name: PingMachine.PingSupervisor}, 12 | {Registry, keys: :unique, name: PingMachine.Registry}, 13 | {Task.Supervisor, name: PingMachine.TaskSupervisor} 14 | # Starts a worker by calling: PingMachine.Worker.start_link(arg) 15 | # {PingMachine.Worker, arg} 16 | ] 17 | 18 | # See https://hexdocs.pm/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: PingMachine.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/ping_machine/subnet_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule PingMachine.SubnetManager do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | require Logger 7 | require IP.Subnet 8 | 9 | def start_link(subnet) when IP.Subnet.is_subnet(subnet) do 10 | GenServer.start_link(__MODULE__, subnet, name: via_tuple(IP.Subnet.to_string(subnet))) 11 | end 12 | 13 | def init(subnet) do 14 | # Send a message to our self that we should start pinging at once! 15 | Process.send(self(), :start_ping, []) 16 | {:ok, %{subnet: subnet, tasks: MapSet.new()}} 17 | end 18 | 19 | def handle_call(:successful_hosts, _from, state) do 20 | success = 21 | Enum.filter(state.tasks, fn task -> task.status == :success end) 22 | |> Enum.map(fn task -> task.host end) 23 | 24 | {:reply, success, state} 25 | end 26 | 27 | def handle_call(:failed_hosts, _from, state) do 28 | failed = 29 | Enum.filter(state.tasks, fn task -> task.status == :failed end) 30 | |> Enum.map(fn task -> task.host end) 31 | 32 | {:reply, failed, state} 33 | end 34 | 35 | def handle_cast({:task, host}, state) do 36 | task = 37 | Task.Supervisor.async_nolink( 38 | PingMachine.TaskSupervisor, 39 | fn -> 40 | # Pretends to send a ping request by sleeping some time and then 41 | # randomly selecting a return value for the task. Fails approx 1/3 tasks. 42 | 43 | Enum.random(100..1000) |> Process.sleep() 44 | Enum.random([:ok, :ok, :error]) 45 | end 46 | ) 47 | 48 | # Register the task in the GenServer state, so that we can track which 49 | # tasks responded with a successful ping request, and which didn't. 50 | {:noreply, 51 | %{state | tasks: MapSet.put(state.tasks, %{host: host, status: :pending, task: task})}} 52 | end 53 | 54 | def handle_info(:start_ping, state) do 55 | Enum.map(state.subnet, fn host -> 56 | GenServer.cast( 57 | via_tuple(IP.Subnet.to_string(state.subnet)), 58 | {:task, IP.to_string(host)} 59 | ) 60 | end) 61 | 62 | {:noreply, state} 63 | end 64 | 65 | # The ping request succeeded 66 | def handle_info({ref, :ok}, state) do 67 | task = 68 | Enum.find(state.tasks, fn %{host: _host, status: _status, task: %{ref: r}} -> r == ref end) 69 | 70 | Logger.info("Successfully pinged host #{task.host}") 71 | 72 | # We don't care about the :DOWN message from the task anymore, so demonitor 73 | # and flush it. 74 | Process.demonitor(ref, [:flush]) 75 | 76 | updated_tasks = 77 | MapSet.delete(state.tasks, task) 78 | |> MapSet.put(Map.put(task, :status, :success)) 79 | 80 | {:noreply, %{state | tasks: updated_tasks}} 81 | end 82 | 83 | # The ping request failed 84 | def handle_info({ref, :error}, state) do 85 | task = 86 | Enum.find(state.tasks, fn %{host: _host, status: _status, task: %{ref: r}} -> r == ref end) 87 | 88 | Logger.error("Failed to ping host #{task.host}") 89 | 90 | Process.demonitor(ref, [:flush]) 91 | 92 | updated_tasks = 93 | MapSet.delete(state.tasks, task) 94 | |> MapSet.put(Map.put(task, :status, :failed)) 95 | 96 | {:noreply, %{state | tasks: updated_tasks}} 97 | end 98 | 99 | # The task itself failed 100 | def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do 101 | {:noreply, state} 102 | end 103 | 104 | defp via_tuple(name), do: {:via, Registry, {PingMachine.Registry, name}} 105 | end 106 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PingMachine.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ping_machine, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {PingMachine.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:net_address, "~> 0.3.0"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "net_address": {:hex, :net_address, "0.3.0", "0fd8bdccdcb74986b7e808bc1f99a7cf4bbc8232bffd6958e18a963500adb541", [:mix], [], "hexpm", "678886a834e031009eda8a45f3e2cbda94a20a1e5fbc174e88e3f031eeb62c5f"}, 3 | } 4 | -------------------------------------------------------------------------------- /test/ping_machine_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PingMachineTest do 2 | use ExUnit.Case 3 | doctest PingMachine 4 | 5 | test "passing a valid subnet returns an ok tuple" do 6 | assert {:ok, pid} = PingMachine.start_ping("192.168.0.0/24") 7 | assert :ok = PingMachine.stop_ping(pid) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------