Previously, we beefed up our Elixir-based Bitcoin-node-in-progress to use the Connection behavior to better manage our connection to our peer node. Now that we can robustly connect to a single peer node, let’s broaden our horizons and connect to multiple peers!
Let’s refactor our node to use a dynamic supervisor to manage our collection of connections, and start recursively connecting to nodes in the Bitcoin peer-to-peer network!
Going Dynamic
Each of our connections to a Bitcoin peer node is currently managed through a BitcoinNetwork.Node
process. We’ll manage this collection of processes with a new dynamic supervisor called Bitcoin.Node.Supervisor
.
Let’s create that new supervisor now:
defmodule BitcoinNetwork.Node.Supervisor do
use DynamicSupervisor
def start_link([]) do
DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__)
end
def init([]) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end
The code here is largely boilerplate. Our Node.Supervisor
initiates itself with a :one_for_one
strategy (the only supervision strategy currently available to a dynamic supervisor). It’s also important to note that like all dynamic supervisors, our Node.Supervisor
starts without children.
Back to Where we Started
Next, we’ll go into our BitcoinNetwork.Application
supervisor and replace our BitcoinNetwork.Node
child specification with a specification for our new dynamic supervisor:
Supervisor.start_link(
[
{DynamicSupervisor, strategy: :one_for_one, name: BitcoinNetwork.Node.Supervisor}
],
strategy: :one_for_one
)
After our Application
has successfully started its Node.Supervisor
child, we’ll go ahead and add our Node
process as a child of our new dynamic supervisor:
DynamicSupervisor.start_child(BitcoinNetwork.Node.Supervisor, %{
id: BitcoinNetwork.Node,
start:
{BitcoinNetwork.Node, :start_link,
[
{
Application.get_env(:bitcoin_network, :ip),
Application.get_env(:bitcoin_network, :port)
}
]},
restart: :transient
})
We simply moved our BitcoinNetwork.Node
child specification out of our old supervisor’s child list, and dropped it into our call to DynamicSupervisor.start_child/2
.
What we’re really trying to do here is “connect to a node”, but all of this boilerplate is confusing our intentions. Let’s create a new function in our BitcoinNetwork
module called connect_to_node/2
that takes a node’s IP address and a port, and adds a child to our Node.Supervisor
that manages the connection to that node:
def connect_to_node(ip, port) do
DynamicSupervisor.start_child(BitcoinNetwork.Node.Supervisor, %{
id: BitcoinNetwork.Node,
start: {BitcoinNetwork.Node, :start_link, [{ip, port}]},
restart: :transient
})
end
Now we can replace the start_child/2
mess in the start/2
callback of our Application
module with a call to our new connect_to_node/2
function:
BitcoinNetwork.connect_to_node(
Application.get_env(:bitcoin_network, :ip),
Application.get_env(:bitcoin_network, :port)
)
That’s much nicer.
Now it’s clear that when our application starts up, it creates a new dynamic supervisor, Node.Supervisor
, and then connects to the Bitcoin node specified in our application’s configuration.
At this point, we’re back up to feature parity with our original one-node solution. All we’ve really managed to do it add a supervisor layer into our supervision tree.
Our new supervision tree.
Adding Nodes
Now that we’re equipped with our connect_to_node/2
function and our new dynamic node supervisor, we’re ready rapidly expand our network of known Bitcoin nodes.
Our Node
process is currently listening for incoming node addresses in one of our handle_payload/2
functions:
defp handle_payload(%Addr{addr_list: addr_list}, state) do
log([:bright, "Received ", :green, "#{length(addr_list)}", :reset, :bright, " peers."])
{:ok, state}
end
We can connect to each of these additional peer nodes by mapping each node address in addr_list
over our new connect_to_node/2
function:
Enum.map(addr_list, &BitcoinNetwork.connect_to_node(&1.ip, &1.port))
Let’s clean this up a bit by adding another function head to our connect_to_node/2
function that accepts a single NetAddr
struct as a parameter:
def connect_to_node(%NetAddr{ip: ip, port: port}), do: connect_to_node(ip, port)
Now we can simply our map over the list of NetAddr
structures we receive in our addr_list
variable:
Enum.map(addr_list, &BitcoinNetwork.connect_to_node/1)
Beautiful.
Now our application fires up, connects to our initial Bitcoin peer node, receives that node’s list of peers, and spawns a dynamically supervised process that attempts to connect to each of those peers. If any of those peers successfully connect and return their list of peers, we’ll repeat the process.
So many peers!
Uncontrolled Growth
At this point, our Bitcoin node will happily spreading itself through the Bitcoin peer-to-peer network, introducing itself as a peer to tens thousands of nodes. However, this level of connectivity might be overkill for our node.
We need some way of limiting the number of active peer connections to some configurable value.
We’ll start implementing this limit by adding a max_peers
configuration value to our config.exs
:
config :bitcoin_network, max_peers: 125
Let’s start with a limit of one hundred twenty five connections, just like the default limit in the Bitcoin core client.
Next, we’ll make a new function in our BitcoinNetwork
module to count the number of active peer connections. This is fairly straight forward thanks to the count_children/1
function on the DynamicSupervisor
module:
def count_peers() do
BitcoinNetwork.Node.Supervisor
|> DynamicSupervisor.count_children()
|> Map.get(:active)
end
Next, in our connect_to_node/2
function, we’ll wrap our call to DynamicSupervisor.start_child/2
with a check that we haven’t reached our max_peers
limit:
if count_peers() < Application.get_env(:bitcoin_network, :max_peers) do
DynamicSupervisor.start_child(BitcoinNetwork.Node.Supervisor, %{
...
})
else
{:error, :max_peers}
end
And that’s all there is to it! Now, every time we receive a peer and try to connect to it, our connect_to_node/2
function will first check that we haven’t exceeded the max_peers
limit defined in our application’s configuration.
Our Bitcoin node will now limit its pool of peers to a maximum of one hundred twenty five connections.
Final Thoughts
Elixir’s dynamic supervisor is a breeze to work with and made it possible to easily and quickly scale up our pool of peers from one to tens of thousands of connections in the blink of an eye.
While our Bitcoin node is working its way through the Bitcoin peer-to-peer network, it doesn’t actually do anything. We’ll need to spend some time in the future figuring out how to process incoming blocks and transactions. Maybe at some point we’ll even be able to send our own transactions and mine for new blocks!
It sounds like we’ll have to dust off Mastering Bitcoin and finish off the few remaining chapters.