├── .formatter.exs ├── .gitignore ├── README.md ├── lib ├── pubraft.ex └── pubraft │ └── peer.ex ├── mix.exs ├── mix.lock └── test ├── pubraft_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 | pubraft-*.tar 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pubraft 2 | 3 | An implementation of Raft Leadership Election using Phoenix PubSub. 4 | 5 | ## Installation 6 | 7 | Be sure you have Redis installed (or swap out the Phoenix.PubSub adapter). After 8 | cloning the repo install the dependencies: 9 | 10 | ```elixir 11 | mix deps.get 12 | ``` 13 | 14 | ## Usage 15 | 16 | Start an iex session with a name or short name, which is necessary for node 17 | identification: 18 | 19 | ```bash 20 | iex --sname alpha -S mix 21 | ``` 22 | 23 | Once `iex` has started you can start the `PubRaft` supervision tree: 24 | 25 | ```elixir 26 | {:ok, sup} = PubRaft.start_link(psub: :my_pubsub, size: 3) 27 | ``` 28 | 29 | Without any other nodes to talk to the node will churn along logging out messages like this: 30 | 31 | ``` 32 | 12:50:07.113 node=delta@SorenBook mode=follower Becoming a candidate in term 1 33 | 12:50:07.119 node=delta@SorenBook mode=candidate Already voted in term 1 34 | 12:50:08.320 node=delta@SorenBook mode=candidate Becoming a candidate in term 2 35 | 12:50:08.320 node=delta@SorenBook mode=candidate Already voted in term 2 36 | 12:50:09.522 node=delta@SorenBook mode=candidate Becoming a candidate in term 3 37 | 12:50:09.523 node=delta@SorenBook mode=candidate Already voted in term 3 38 | ``` 39 | 40 | To elect a leader you'll need at least one more node, or preferrably two. Start 41 | another shell and bring up another uniquely named node (beta, gamma, etc), then 42 | start `PubRaft` again. 43 | 44 | Now you should see a leader announced: 45 | 46 | ``` 47 | 22:49:18.335 node=delta@SorenBook mode=follower Voting for gamma@SorenBook in term 2 48 | 22:49:18.340 node=delta@SorenBook mode=follower Newer term (2) discovered, falling back to follower 49 | 22:49:18.341 node=delta@SorenBook mode=follower Leader announced as gamma@SorenBook for term 2, becoming follower 50 | ``` 51 | 52 | And you can verify that from any console by asking who the leader is: 53 | 54 | ```elixir 55 | PubRaft.leader(sup) 56 | # => gamma@local 57 | ``` 58 | 59 | Terminate an iex session, bring it back up, and have fun playing with leadership 60 | election. 61 | -------------------------------------------------------------------------------- /lib/pubraft.ex: -------------------------------------------------------------------------------- 1 | defmodule PubRaft do 2 | use Supervisor 3 | 4 | def start_link(opts) do 5 | Supervisor.start_link(__MODULE__, opts) 6 | end 7 | 8 | @impl Supervisor 9 | def init(opts) do 10 | Logger.configure(level: :debug) 11 | 12 | Logger.configure_backend(:console, 13 | format: "\n$time $metadata$message", 14 | metadata: [:node, :mode] 15 | ) 16 | 17 | psub = Keyword.fetch!(opts, :psub) 18 | size = Keyword.fetch!(opts, :size) 19 | 20 | children = [ 21 | {Phoenix.PubSub, 22 | adapter: Phoenix.PubSub.Redis, 23 | name: psub, 24 | node_name: Node.self(), 25 | url: "redis://localhost:6379/10"}, 26 | {PubRaft.Peer, node: Node.self(), psub: psub, size: size} 27 | ] 28 | 29 | Supervisor.init(children, strategy: :one_for_one) 30 | end 31 | 32 | def leader(sup) do 33 | {PubRaft.Peer, pid, :worker, _} = 34 | sup 35 | |> Supervisor.which_children() 36 | |> List.first() 37 | 38 | GenServer.call(pid, :leader) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/pubraft/peer.ex: -------------------------------------------------------------------------------- 1 | defmodule PubRaft.Peer do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | alias Phoenix.PubSub 7 | 8 | @heartbeat_timeout 75 9 | @election_timeout_range 150..1200 10 | 11 | defmodule State do 12 | defstruct [ 13 | :node, 14 | :psub, 15 | :size, 16 | leader: :none, 17 | mode: :follower, 18 | term: 0, 19 | timer: nil, 20 | vote_for: nil, 21 | votes: [] 22 | ] 23 | end 24 | 25 | def start_link(opts) do 26 | GenServer.start_link(__MODULE__, opts) 27 | end 28 | 29 | @impl GenServer 30 | def init(opts) do 31 | state = struct!(State, opts) 32 | 33 | PubSub.subscribe(state.psub, "gossip") 34 | 35 | {:ok, schedule_timeout(state)} 36 | end 37 | 38 | @impl GenServer 39 | def terminate(_reason, %State{} = state) do 40 | PubSub.broadcast(state.psub, "gossip", {:peer_exit, state.node}) 41 | 42 | :ok 43 | end 44 | 45 | @impl GenServer 46 | def handle_call(:leader, _from, %State{} = state) do 47 | {:reply, state.leader, state} 48 | end 49 | 50 | @impl GenServer 51 | def handle_info(:timeout, %State{} = state) do 52 | case state.mode do 53 | :follower -> become_candidate(state) 54 | :candidate -> check_candidacy(state) 55 | :leader -> send_heartbeat(state) 56 | end 57 | end 58 | 59 | def handle_info({:heartbeat, from, term}, %State{} = state) do 60 | case {state.mode, state.node, check_term(term, state)} do 61 | {:leader, ^from, :same} -> 62 | {:noreply, schedule_timeout(state)} 63 | 64 | {:leader, _from, :older} -> 65 | {:noreply, schedule_timeout(state)} 66 | 67 | {_mode, _from, _term} -> 68 | state = %{state | leader: from, mode: :follower} 69 | 70 | {:noreply, schedule_timeout(state)} 71 | end 72 | end 73 | 74 | def handle_info({:request_vote, from, term}, %State{} = state) do 75 | case {check_term(term, state), check_vote(state)} do 76 | {:newer, :no_vote} -> 77 | log("Voting for #{from} in term #{term}", state) 78 | 79 | PubSub.broadcast(state.psub, "gossip", {:respond_vote, state.node, term, from}) 80 | 81 | {:noreply, schedule_timeout(%{state | vote_for: from})} 82 | 83 | {:newer, _vote} -> 84 | log("Newer term detected, #{term}, falling back", state) 85 | 86 | {:noreply, schedule_timeout(%{state | mode: :follower, vote_for: nil})} 87 | 88 | _ -> 89 | log("Already voted in term #{term}", state) 90 | 91 | {:noreply, schedule_timeout(state)} 92 | end 93 | end 94 | 95 | def handle_info({:respond_vote, from, term, vote}, %State{} = state) do 96 | case {state.node, check_term(term, state), check_quorum(from, state)} do 97 | {^vote, :same, :quorum} -> 98 | log("Voted the leader for #{term}", state) 99 | 100 | state = 101 | state 102 | |> Map.put(:leader, vote) 103 | |> Map.put(:mode, :leader) 104 | |> Map.put(:term, term) 105 | |> Map.put(:votes, []) 106 | |> Map.put(:voted_for, nil) 107 | 108 | PubSub.broadcast(state.psub, "gossip", {:announce_leader, state.node, term}) 109 | 110 | {:noreply, schedule_timeout(state)} 111 | 112 | {^vote, :same, :no_quorum} -> 113 | log("Received vote from #{from} on #{term}, no quorum yet", state) 114 | 115 | {:noreply, schedule_timeout(%{state | votes: [from | state.votes]})} 116 | 117 | {_vote, :newer, _quorum} -> 118 | log("Newer term (#{term}) discovered, falling back to follower", state) 119 | 120 | {:noreply, schedule_timeout(%{state | mode: :follower, term: term})} 121 | 122 | _ -> 123 | log("Received vote on term #{term} for another node", state) 124 | 125 | {:noreply, schedule_timeout(state)} 126 | end 127 | end 128 | 129 | def handle_info({:announce_leader, from, term}, %State{} = state) do 130 | case {state.node, term} do 131 | {^from, _term} -> 132 | {:noreply, schedule_timeout(state)} 133 | 134 | {_from, _term} -> 135 | log("Leader announced as #{from} for term #{term}, becoming follower", state) 136 | 137 | {:noreply, schedule_timeout(%{state | leader: from, term: term, mode: :follower})} 138 | end 139 | end 140 | 141 | def handle_info({:peer_exit, from}, %State{} = state) do 142 | log("Peer #{from} exited and the node is down, starting election", state) 143 | 144 | {:noreply, schedule_timeout(state)} 145 | end 146 | 147 | # Logging Helpers 148 | 149 | defp log(message, state) do 150 | Logger.debug(message, node: state.node, mode: state.mode) 151 | end 152 | 153 | # Timeout Helpers 154 | 155 | defp schedule_timeout(%State{} = state) do 156 | if is_reference(state.timer), do: Process.cancel_timer(state.timer) 157 | 158 | timeout = 159 | case state.mode do 160 | :follower -> Enum.random(@election_timeout_range) 161 | :candidate -> Enum.max(@election_timeout_range) 162 | :leader -> @heartbeat_timeout 163 | end 164 | 165 | timer = Process.send_after(self(), :timeout, timeout) 166 | 167 | %{state | timer: timer} 168 | end 169 | 170 | # Candidate Helpers 171 | 172 | defp become_candidate(%State{} = state) do 173 | log("Becoming a candidate in term #{state.term + 1}", state) 174 | 175 | state = %{state | leader: :none, mode: :candidate, term: state.term + 1} 176 | 177 | PubSub.broadcast(state.psub, "gossip", {:request_vote, state.node, state.term}) 178 | 179 | {:noreply, schedule_timeout(state)} 180 | end 181 | 182 | defp check_candidacy(%State{} = state) do 183 | if state.mode == :candidate do 184 | become_candidate(state) 185 | else 186 | {:noreply, schedule_timeout(state)} 187 | end 188 | end 189 | 190 | defp send_heartbeat(%State{} = state) do 191 | PubSub.broadcast(state.psub, "gossip", {:heartbeat, state.node, state.term}) 192 | 193 | {:noreply, schedule_timeout(state)} 194 | end 195 | 196 | # Voting Helpers 197 | 198 | defp check_term(term, %State{} = state) do 199 | cond do 200 | term > state.term -> :newer 201 | term == state.term -> :same 202 | term < state.term -> :older 203 | end 204 | end 205 | 206 | defp check_vote(%State{vote_for: nil}), do: :no_vote 207 | defp check_vote(_state), do: :voted 208 | 209 | defp check_quorum(from, state) do 210 | if length([from | state.votes]) >= state.size / 2 do 211 | :quorum 212 | else 213 | :no_quorum 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Pubraft.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :pubraft, 7 | version: "0.1.0", 8 | elixir: "~> 1.10", 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 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | {:phoenix_pubsub, "~> 2.0"}, 25 | {:phoenix_pubsub_redis, "~> 3.0"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 3 | "phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.0", "75a3f3908210196c499a961ab7cf7202e68947ba33994f9219dc153367ac8662", [:mix], [{:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 0.10.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "57b4ac98239600340ae485976ae5400f39f48ce8741105e1709f4e16d0d60e92"}, 4 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 5 | "redix": {:hex, :redix, "0.10.7", "758916c71fc09e223e18d6715344581d7768c430983dabf77e792ba2087729e6", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73fdf73c0472278dc040dcd1a5da91d4febe218201ae8ac0454b37e136886c34"}, 6 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 7 | } 8 | -------------------------------------------------------------------------------- /test/pubraft_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PubraftTest do 2 | use ExUnit.Case 3 | doctest Pubraft 4 | 5 | test "greets the world" do 6 | assert Pubraft.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------