├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── dispatch.ex └── dispatch │ ├── hash_ring_server.ex │ ├── registry.ex │ ├── service.ex │ └── supervisor.ex ├── mix.exs ├── mix.lock └── test ├── dispatch ├── registry_test.exs └── service_test.exs ├── dispatch_test.exs ├── support └── helper.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | doc 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.6 4 | otp_release: 5 | - 19.0 6 | sudo: false 7 | before_script: 8 | - mix deps.get --only test 9 | script: 10 | - mix test 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, VoiceLayer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dispatch 2 | 3 | [![Build Status](https://travis-ci.org/VoiceLayer/dispatch.svg?branch=master)](https://travis-ci.org/voicelayer/dispatch) 4 | 5 | A distributed service registry built on top of [phoenix_pubsub](https://github.com/phoenixframework/phoenix_pubsub). 6 | 7 | Requests are dispatched to one or more services based on hashed keys. 8 | 9 | ## Installation 10 | 11 | 1. Add dispatch to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:dispatch, "~> 0.2.0"} 17 | ] 18 | end 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Configuration 24 | 25 | Configure the registry: 26 | 27 | ```elixir 28 | config :dispatch, 29 | pubsub: [name: Dispatch.PubSub, 30 | adapter: Phoenix.PubSub.PG2, 31 | opts: [pool_size: 1]], 32 | registry: [log_level: :debug, 33 | broadcast_period: 25, 34 | max_silent_periods: 3] 35 | ``` 36 | 37 | When the application is started, a supervisor with be started supervising 38 | a pubsub adapter with the name and options specified. One can also 39 | customize the tracker options for the registry. 40 | 41 | ### Register a service 42 | 43 | ```elixir 44 | iex> {:ok, service_pid} = Agent.start_link(fn -> 1 end) # Use your real service here 45 | iex> Dispatch.Registry.add_service(:uploader, service_pid) 46 | {:ok, "g20AAAAI9+IQ28ngDfM="} 47 | ``` 48 | 49 | In this example, `:uploader` is the type of the service. 50 | 51 | ### Retrieve all services for a service type 52 | 53 | ```elixir 54 | iex> Dispatch.Registry.get_services(:uploader) 55 | [{#PID<0.166.0>, 56 | %{node: :"slave2@127.0.0.1", phx_ref: "g20AAAAIHAHuxydO084=", 57 | phx_ref_prev: "g20AAAAI4oU3ICYcsoQ=", state: :online}}] 58 | ``` 59 | 60 | This retrieves all of the services info. 61 | 62 | ### Finding a service for a key 63 | 64 | ```elixir 65 | iex> Dispatch.Registry.find_service(:uploader, "file.png") 66 | {:ok, :"slave1@127.0.0.1", #PID<0.153.0>} 67 | ``` 68 | 69 | Using `find_service/2` returns a tuple in the form `{:ok, node, pid}` where 70 | `node` is the node that owns the service `pid`. If no service can be 71 | found then `{:error, reason}` is returned. 72 | 73 | ### Finding a list of `count` service instances for a particular `key` 74 | 75 | ```elixir 76 | iex> Dispatch.Registry.find_multi_service(2, :uploader, "file.png") 77 | [{:ok, :"slave1@127.0.0.1", #PID<0.153.0>}, {:ok, :"slave2@127.0.0.1", #PID<0.145.0>}] 78 | ``` 79 | 80 | ## Convenience API 81 | 82 | The `Service` module can be used to automatically handle registration of a service 83 | based on a `GenServer`. 84 | 85 | Call `Service.init` within your GenServer's `init` function. 86 | 87 | ```elixir 88 | def init(_) do 89 | :ok = Dispatch.Service.init(type: :uploader) 90 | {:ok, %{}} 91 | end 92 | ``` 93 | 94 | This will use the type provided to attach a service to the configured registry 95 | pid. 96 | 97 | Use `Dispatch.Service.cast` and `Dispatch.Service.call` to route the GenServer `cast` or `call` 98 | to the appropriate service based on the `key` provided. 99 | 100 | Use `Dispatch.Service.multi_cast` to send cast messages to several service instances at once. 101 | 102 | `Dispatch.Service.multi_call` calls several service instances and waits 103 | for all the responses before returning. 104 | 105 | ```elixir 106 | 107 | # File is a map with the format %{name: "file.png", contents: "test file"} 108 | def upload(file) 109 | Dispatch.Service.cast(:uploader, file.name, {:upload, file}) 110 | end 111 | 112 | def download(file) 113 | Dispatch.Service.call(:uploader, file.name, {:download, file}) 114 | end 115 | 116 | def handle_cast({:upload, file}, state) do 117 | Logger.info("Uploading #{file.name}") 118 | {:noreply, Map.put(state, file.name, file.contents)} 119 | end 120 | 121 | def handle_call({:download, %{name: name}}, from, state) do 122 | Logger.info("Downloading #{name}") 123 | {:reply, {:ok, Map.get(state, name}}, state} 124 | end 125 | ``` 126 | 127 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | if Mix.env() == :test do 4 | config :dispatch, 5 | pubsub: [name: Phoenix.PubSub.Test.PubSub, adapter: Phoenix.PubSub.PG2, opts: [pool_size: 1]] 6 | end 7 | -------------------------------------------------------------------------------- /lib/dispatch.ex: -------------------------------------------------------------------------------- 1 | defmodule Dispatch do 2 | use Application 3 | require Logger 4 | 5 | def start(_type, _args) do 6 | Dispatch.Supervisor.start_link() 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/dispatch/hash_ring_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.HashRingServer do 2 | use GenServer 3 | @moduledoc false 4 | 5 | def start_link(opts \\ []) do 6 | name = Keyword.fetch!(opts, :name) 7 | opts = [name: Module.concat(name, HashRing)] 8 | GenServer.start_link(__MODULE__, [], opts) 9 | end 10 | 11 | @doc false 12 | def init(_) do 13 | {:ok, %{hash_rings: %{}}} 14 | end 15 | 16 | def handle_call({:get, type}, _reply, state) do 17 | {:reply, Map.get(state.hash_rings, type, {:error, :no_nodes}), state} 18 | end 19 | 20 | def handle_call(:get_all, _reply, state) do 21 | {:reply, state.hash_rings, state} 22 | end 23 | 24 | def handle_call({:put_all, hash_rings}, _reply, state) do 25 | {:reply, :ok, %{state | hash_rings: hash_rings}} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/dispatch/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.Registry do 2 | use Phoenix.Tracker 3 | 4 | @moduledoc """ 5 | Provides a distributes registry for services. 6 | 7 | This module implements the `Phoenix.Tracker` behaviour to provide a distributed 8 | registry of services. Services can be added and removed to and from the 9 | registry. 10 | 11 | Services are identified by their type. The type must be a binary. 12 | 13 | When a node goes down, all associated services will be removed from the 14 | registry when the CRDT syncs. 15 | 16 | A hash ring is used to determine which service to use for a particular 17 | term. The term is arbitrary, however the same node and service pid will 18 | always be returned for a term unless the number of services for the type 19 | changes. 20 | 21 | ## Optional 22 | 23 | * `:test` - If set to true then a registry and hashring will not be 24 | started when the application starts. They should be started manually 25 | with `Dispatch.Registry.start_link/1` and 26 | `Dispatch.Supervisor.start_hash_ring/2`. Defaults to `false` 27 | """ 28 | 29 | @doc """ 30 | Start a new registry. The `pubsub` config value from `Dispatch` will be used. 31 | 32 | ## Examples 33 | 34 | iex> Dispatch.Registry.start_link() 35 | {:ok, #PID<0.168.0>} 36 | """ 37 | def start_link(opts \\ []) do 38 | pubsub_server = Keyword.get(opts, :dispatch_name, Dispatch.PubSub) 39 | 40 | full_opts = 41 | Keyword.merge( 42 | [name: __MODULE__, pubsub_server: pubsub_server], 43 | opts 44 | ) 45 | 46 | Phoenix.Tracker.start_link(__MODULE__, full_opts, full_opts) 47 | end 48 | 49 | @doc """ 50 | Add a service to the registry. The service is set as online. 51 | 52 | * `type` - The type of the service. Must be a binary. 53 | * `pid` - The pid that provides the service 54 | 55 | ## Examples 56 | 57 | iex> Dispatch.Registry.add_service("downloader", self()) 58 | {:ok, "g20AAAAIlB7XfDdRhmk="} 59 | """ 60 | def add_service(type, pid) do 61 | Phoenix.Tracker.track(__MODULE__, pid, type, pid, %{node: node(), state: :online}) 62 | end 63 | 64 | @doc """ 65 | Set a service as online. When a service is online it can be used. 66 | 67 | * `type` - The type of the service. Must be a binary. 68 | * `pid` - The pid that provides the service 69 | 70 | ## Examples 71 | 72 | iex> Dispatch.Registry.enable_service("downloader", self()) 73 | {:ok, "g20AAAAI9+IQ28ngDfM="} 74 | """ 75 | def enable_service(type, pid) do 76 | Phoenix.Tracker.update(__MODULE__, pid, type, pid, %{node: node(), state: :online}) 77 | end 78 | 79 | @doc """ 80 | Set a service as offline. When a service is offline it can't be used. 81 | 82 | * `type` - The type of the service. Must be a binary. 83 | * `pid` - The pid that provides the service 84 | 85 | ## Examples 86 | 87 | iex> Dispatch.Registry.disable_service("downloader", self()) 88 | {:ok, "g20AAAAI4oU3ICYcsoQ="} 89 | """ 90 | def disable_service(type, pid) do 91 | Phoenix.Tracker.update(__MODULE__, pid, type, pid, %{node: node(), state: :offline}) 92 | end 93 | 94 | @doc """ 95 | Remove a service from the registry. 96 | 97 | * `type` - The type of the service. Must be a binary. 98 | * `pid` - The pid that provides the service 99 | 100 | ## Examples 101 | 102 | iex> Dispatch.Registry.remove_service("downloader", self()) 103 | {:ok, "g20AAAAI4oU3ICYcsoQ="} 104 | """ 105 | def remove_service(type, pid) do 106 | Phoenix.Tracker.untrack(__MODULE__, pid, type, pid) 107 | end 108 | 109 | @doc """ 110 | List all of the services for a particular type. 111 | 112 | * `type` - The type of the service. Must be a binary. 113 | 114 | ## Examples 115 | 116 | iex> Dispatch.Registry.get_services("downloader") 117 | [{#PID<0.166.0>, 118 | %{node: :"slave2@127.0.0.1", phx_ref: "g20AAAAIHAHuxydO084=", 119 | phx_ref_prev: "g20AAAAI4oU3ICYcsoQ=", state: :online}}] 120 | """ 121 | def get_services(type) do 122 | Phoenix.Tracker.list(__MODULE__, type) 123 | end 124 | 125 | @doc """ 126 | List all of the services that are online for a particular type. 127 | 128 | * `type` - The type of the service. Must be a binary. 129 | 130 | ## Examples 131 | 132 | iex> Dispatch.Registry.get_online_services("downloader") 133 | [{#PID<0.166.0>, 134 | %{node: :"slave2@127.0.0.1", phx_ref: "g20AAAAIHAHuxydO084=", 135 | phx_ref_prev: "g20AAAAI4oU3ICYcsoQ=", state: :online}}] 136 | """ 137 | def get_online_services(type) do 138 | get_services(type) 139 | |> Enum.filter(&(elem(&1, 1)[:state] == :online)) 140 | end 141 | 142 | @doc """ 143 | Find a service to use for a particular `key` 144 | 145 | * `type` - The type of the service. Must be a binary. 146 | * `key` - The key to lookup the service. Can be any elixir term 147 | 148 | ## Examples 149 | 150 | iex> Dispatch.Registry.find_service(:uploader, "file.png") 151 | {:ok, :"slave1@127.0.0.1", #PID<0.153.0>} 152 | """ 153 | def find_service(type, key) do 154 | with( 155 | %HashRing{} = hash_ring <- GenServer.call(hash_ring_server(), {:get, type}), 156 | {:ok, service_info} <- HashRing.key_to_node(hash_ring, key), 157 | do: service_info 158 | ) 159 | |> case do 160 | {host, pid} when is_pid(pid) -> {:ok, host, pid} 161 | _ -> {:error, :no_service_for_key} 162 | end 163 | end 164 | 165 | @doc """ 166 | Find a list of `count` service instances to use for a particular `key` 167 | 168 | * `count` - The number of service instances to retrieve 169 | * `type` - The type of services to retrieve 170 | * `key` - The key to lookup the service. Can be any elixir term 171 | 172 | ## Examples 173 | 174 | iex> Dispatch.Registry.find_multi_service(2, :uploader, "file.png") 175 | [{:ok, :"slave1@127.0.0.1", #PID<0.153.0>}, {:ok, :"slave2@127.0.0.1", #PID<0.145.0>}] 176 | """ 177 | def find_multi_service(count, type, key) do 178 | with( 179 | %HashRing{} = hash_ring <- GenServer.call(hash_ring_server(), {:get, type}), 180 | {:ok, service_info} <- HashRing.key_to_nodes(hash_ring, key, count), 181 | do: service_info 182 | ) 183 | |> case do 184 | list when is_list(list) -> list 185 | _ -> [] 186 | end 187 | end 188 | 189 | @doc false 190 | def init(opts) do 191 | server = Keyword.fetch!(opts, :pubsub_server) 192 | {:ok, %{pubsub_server: server, hash_rings: %{}}} 193 | end 194 | 195 | @doc false 196 | def handle_diff(diff, state) do 197 | hash_rings = GenServer.call(hash_ring_server(), :get_all) 198 | 199 | hash_rings = 200 | Enum.reduce(diff, hash_rings, fn {type, _} = event, hash_rings -> 201 | hash_ring = 202 | hash_rings 203 | |> Map.get(type, HashRing.new()) 204 | |> remove_leaves(event, state) 205 | |> add_joins(event, state) 206 | 207 | Map.put(hash_rings, type, hash_ring) 208 | end) 209 | 210 | GenServer.call(hash_ring_server(), {:put_all, hash_rings}) 211 | {:ok, state} 212 | end 213 | 214 | defp remove_leaves(hash_ring, {type, {joins, leaves}}, state) do 215 | Enum.reduce(leaves, hash_ring, fn {pid, meta}, acc -> 216 | service_info = {meta.node, pid} 217 | 218 | any_joins = 219 | Enum.any?(joins, fn {jpid, %{state: meta_state}} -> 220 | jpid == pid && meta_state == :online 221 | end) 222 | 223 | Phoenix.PubSub.direct_broadcast(node(), state.pubsub_server, type, {:leave, pid, meta}) 224 | 225 | case any_joins do 226 | true -> acc 227 | _ -> HashRing.remove_node(acc, service_info) 228 | end 229 | end) 230 | end 231 | 232 | defp add_joins(hash_ring, {type, {joins, _leaves}}, state) do 233 | Enum.reduce(joins, hash_ring, fn {pid, meta}, acc -> 234 | service_info = {meta.node, pid} 235 | Phoenix.PubSub.direct_broadcast(node(), state.pubsub_server, type, {:join, pid, meta}) 236 | 237 | case meta.state do 238 | :online -> 239 | HashRing.add_node(acc, service_info) 240 | 241 | _ -> 242 | acc 243 | end 244 | end) 245 | end 246 | 247 | defp hash_ring_server() do 248 | Module.concat(__MODULE__, HashRing) 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /lib/dispatch/service.ex: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.Service do 2 | alias Dispatch.Registry 3 | 4 | def init(opts) do 5 | type = Keyword.fetch!(opts, :type) 6 | 7 | case Registry.add_service(type, self()) do 8 | {:ok, _} -> :ok 9 | other -> other 10 | end 11 | end 12 | 13 | def cast(type, key, params) do 14 | case Registry.find_service(type, key) do 15 | {:ok, _node, pid} -> GenServer.cast(pid, params) 16 | _ -> {:error, :service_unavailable} 17 | end 18 | end 19 | 20 | def call(type, key, params, timeout \\ 5000) do 21 | case Registry.find_service(type, key) do 22 | {:ok, _node, pid} -> GenServer.call(pid, params, timeout) 23 | _ -> {:error, :service_unavailable} 24 | end 25 | end 26 | 27 | def multi_cast(count, type, key, params) do 28 | case Registry.find_multi_service(count, type, key) do 29 | [] -> 30 | {:error, :service_unavailable} 31 | 32 | servers -> 33 | servers 34 | |> Enum.each(fn {_node, pid} -> 35 | GenServer.cast(pid, params) 36 | end) 37 | 38 | {:ok, Enum.count(servers)} 39 | end 40 | end 41 | 42 | def multi_call(count, type, key, params, timeout \\ 5000) do 43 | case Registry.find_multi_service(count, type, key) do 44 | [] -> 45 | {:error, :service_unavailable} 46 | 47 | servers -> 48 | for {_node, pid} <- servers do 49 | Task.Supervisor.async_nolink(Dispatch.TaskSupervisor, fn -> 50 | try do 51 | {:ok, pid, GenServer.call(pid, params, timeout)} 52 | catch 53 | :exit, reason -> {:error, pid, reason} 54 | end 55 | end) 56 | end 57 | |> Enum.map(&Task.await(&1, :infinity)) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/dispatch/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.Supervisor do 2 | use Supervisor 3 | 4 | def start_link(_opts \\ []) do 5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | def init(:ok) do 9 | pubsub = Application.get_env(:dispatch, :pubsub, []) 10 | opts = pubsub[:opts] || [] 11 | opts = Keyword.put_new(opts, :name, Dispatch.PubSub) 12 | 13 | registry = 14 | Application.get_env(:dispatch, :registry, []) 15 | |> Keyword.put_new(:name, Dispatch.Registry) 16 | |> Keyword.put_new(:dispatch_name, Keyword.fetch!(opts, :name)) 17 | 18 | children = [ 19 | {Phoenix.PubSub.Supervisor, opts}, 20 | {Dispatch.Registry, registry}, 21 | {Dispatch.HashRingServer, registry}, 22 | {Task.Supervisor, [name: Dispatch.TaskSupervisor]} 23 | ] 24 | 25 | Supervisor.init(children, strategy: :rest_for_one) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.4.1" 5 | 6 | def project do 7 | [ 8 | app: :dispatch, 9 | version: @version, 10 | elixir: "~> 1.6", 11 | build_embedded: Mix.env() == :prod, 12 | start_permanent: Mix.env() == :prod, 13 | source_url: "https://github.com/VoiceLayer/dispatch", 14 | description: description(), 15 | aliases: aliases(), 16 | package: package(), 17 | deps: deps(), 18 | docs: [extras: ["README.md"]] 19 | ] 20 | end 21 | 22 | # Configuration for the OTP application 23 | # 24 | def application do 25 | [applications: [:logger, :phoenix_pubsub, :libring], mod: {Dispatch, []}] 26 | end 27 | 28 | # Dependencies can be Hex packages: 29 | # 30 | defp deps do 31 | [ 32 | {:libring, "~> 1.3"}, 33 | {:phoenix_pubsub, "~> 2.0"}, 34 | {:ex_doc, "~> 0.21.3", only: :dev} 35 | ] 36 | end 37 | 38 | defp description do 39 | """ 40 | A distributed service registry built on top of phoenix_pubsub. 41 | Requests are dispatched to one or more services based on hashed keys. 42 | """ 43 | end 44 | 45 | defp package do 46 | [ 47 | files: ~w(lib test mix.exs README.md LICENSE.md), 48 | maintainers: ["Gary Rennie", "Gabi Zuniga"], 49 | licenses: ["MIT"], 50 | links: %{ 51 | "GitHub" => "https://github.com/voicelayer/dispatch", 52 | "Docs" => "http://hexdocs.pm/dispatch" 53 | } 54 | ] 55 | end 56 | 57 | defp aliases do 58 | [test: "test --no-start"] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 3 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, 4 | "hash_ring": {:git, "https://github.com/voicelayer/hash-ring.git", "3bc9f2fd14d0e161cc0b5b6e7fcd7d3f3ec5791a", []}, 5 | "libring": {:hex, :libring, "1.5.0", "44313eb6862f5c9168594a061e9d5f556a9819da7c6444706a9e2da533396d70", [:mix], [], "hexpm", "04e843d4fdcff49a62d8e03778d17c6cb2a03fe2d14020d3825a1761b55bd6cc"}, 6 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, 9 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 10 | } 11 | -------------------------------------------------------------------------------- /test/dispatch/registry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.RegistryTest do 2 | use ExUnit.Case, async: false 3 | alias Dispatch.{Registry, Helper} 4 | 5 | setup do 6 | type = "RegistryTest" 7 | 8 | pubsub_server = 9 | Application.get_env(:dispatch, :pubsub) 10 | |> Keyword.get(:name, Dispatch.PubSub) 11 | 12 | Phoenix.PubSub.subscribe(pubsub_server, type) 13 | {:ok, _registry_pid} = Helper.setup_registry() 14 | 15 | on_exit(fn -> 16 | Helper.clear_type(type) 17 | end) 18 | 19 | {:ok, %{service_type: type}} 20 | end 21 | 22 | test "empty registry returns empty service list", %{service_type: type} do 23 | assert [] == Registry.get_services(type) 24 | end 25 | 26 | test "empty registry returns empty service list with a different type", %{service_type: type} do 27 | Registry.add_service("Other", self()) 28 | assert [] == Registry.get_services(type) 29 | end 30 | 31 | test "enable service adds to registry", %{service_type: type} do 32 | Registry.add_service(type, self()) 33 | {this_pid, this_node} = {self(), node()} 34 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}} 35 | 36 | [{pid, %{node: node, state: state}}] = Registry.get_services(type) 37 | assert pid == this_pid 38 | assert node == this_node 39 | assert state == :online 40 | end 41 | 42 | test "enable service allows multiple different types", %{} do 43 | {:ok, service_1} = Agent.start(fn -> 1 end) 44 | {:ok, service_2} = Agent.start(fn -> 2 end) 45 | Registry.add_service("Type1", service_1) 46 | Registry.add_service("Type2", service_2) 47 | this_node = node() 48 | 49 | assert {:ok, ^this_node, ^service_1} = Registry.find_service("Type1", "my_key") 50 | assert {:ok, ^this_node, ^service_2} = Registry.find_service("Type2", "my_key") 51 | end 52 | 53 | test "remove service removes it from registry", %{service_type: type} do 54 | Registry.add_service(type, self()) 55 | {this_pid, this_node} = {self(), node()} 56 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 57 | assert {:ok, this_node, this_pid} == Registry.find_service(type, "key") 58 | 59 | Registry.remove_service(type, self()) 60 | assert_receive {:leave, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 61 | 62 | assert [] == Registry.get_services(type) 63 | assert {:error, :no_service_for_key} == Registry.find_service(type, "key") 64 | end 65 | 66 | test "disable service", %{service_type: type} do 67 | Registry.add_service(type, self()) 68 | {this_pid, this_node} = {self(), node()} 69 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 70 | assert {:ok, this_node, this_pid} == Registry.find_service(type, "key") 71 | 72 | Registry.disable_service(type, self()) 73 | assert_receive {:leave, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 74 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :offline}}, 1_000 75 | 76 | [{pid, %{node: node, state: state}}] = Registry.get_services(type) 77 | assert pid == self() 78 | assert node == node() 79 | assert state == :offline 80 | assert {:error, :no_service_for_key} == Registry.find_service(type, "key") 81 | 82 | Registry.enable_service(type, self()) 83 | assert_receive {:leave, ^this_pid, %{node: ^this_node, state: :offline}}, 1_000 84 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 85 | assert {:ok, this_node, this_pid} == Registry.find_service(type, "key") 86 | end 87 | 88 | test "enable multiple services", %{service_type: type} do 89 | Registry.add_service(type, self()) 90 | other_pid = spawn(fn -> :timer.sleep(30_000) end) 91 | Registry.add_service(type, other_pid) 92 | 93 | {this_pid, this_node} = {self(), node()} 94 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 95 | assert_receive {:join, ^other_pid, %{node: ^this_node, state: :online}}, 1_000 96 | 97 | [{first_pid, %{node: ^this_node}}, {second_pid, %{node: ^this_node}}] = 98 | Registry.get_services(type) 99 | 100 | assert {first_pid, second_pid} == {this_pid, other_pid} or 101 | {second_pid, first_pid} == {this_pid, other_pid} 102 | end 103 | 104 | test "get online services", %{service_type: type} do 105 | Registry.add_service(type, self()) 106 | {this_pid, this_node} = {self(), node()} 107 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 108 | 109 | [{pid, %{node: node, state: state}}] = Registry.get_online_services(type) 110 | assert pid == self() 111 | assert node == node() 112 | assert state == :online 113 | Registry.disable_service(type, self()) 114 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :offline}}, 1_000 115 | assert [] == Registry.get_online_services(type) 116 | end 117 | 118 | test "get error if no services joined", %{service_type: type} do 119 | assert {:error, :no_service_for_key} == Registry.find_service(type, "my_key") 120 | end 121 | 122 | test "get service pid", %{service_type: type} do 123 | Registry.add_service(type, self()) 124 | {this_pid, this_node} = {self(), node()} 125 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 126 | 127 | assert {:ok, this_node, this_pid} == Registry.find_service(type, "my_key") 128 | end 129 | 130 | test "get service pid from term key", %{service_type: type} do 131 | Registry.add_service(type, self()) 132 | {this_pid, this_node} = {self(), node()} 133 | assert_receive {:join, ^this_pid, %{node: ^this_node, state: :online}}, 1_000 134 | 135 | assert {:ok, this_node, this_pid} == Registry.find_service(type, {:abc, 1}) 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/dispatch/service_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.ServiceTest do 2 | use ExUnit.Case, async: false 3 | alias Dispatch.{Service, Helper} 4 | 5 | defmodule FakeService do 6 | def start_link(opts) do 7 | GenServer.start_link(__MODULE__, opts) 8 | end 9 | 10 | def init(opts) do 11 | :ok = Service.init(opts) 12 | {:ok, %{}} 13 | end 14 | 15 | def handle_call({:get, key}, _from, state) do 16 | {:reply, {:ok, key}, state} 17 | end 18 | 19 | def handle_cast({:set, key, from}, state) do 20 | send(from, {:set_request, key}) 21 | {:noreply, state} 22 | end 23 | end 24 | 25 | setup do 26 | type = "ServiceTest" 27 | 28 | pubsub_server = 29 | Application.get_env(:dispatch, :pubsub) 30 | |> Keyword.get(:name, Dispatch.PubSub) 31 | 32 | Phoenix.PubSub.subscribe(pubsub_server, type) 33 | {:ok, _registry_pid} = Helper.setup_registry() 34 | 35 | on_exit(fn -> 36 | Helper.clear_type(type) 37 | end) 38 | 39 | {:ok, %{service_type: type}} 40 | end 41 | 42 | test "invoke service cast", %{service_type: type} do 43 | {:ok, service} = FakeService.start_link(type: type) 44 | this_node = node() 45 | assert_receive({:join, ^service, %{node: ^this_node, state: :online}}) 46 | 47 | Service.cast(type, "my_key", {:set, "key", self()}) 48 | assert_receive({:set_request, "key"}) 49 | end 50 | 51 | test "invoke service call", %{service_type: type} do 52 | this_node = node() 53 | {:ok, service} = FakeService.start_link(type: type) 54 | assert_receive({:join, ^service, %{node: ^this_node, state: :online}}) 55 | 56 | {:ok, result} = Service.call(type, "my_key", {:get, "my_key"}) 57 | assert result == "my_key" 58 | end 59 | 60 | test "invoke service multi cast", %{service_type: type} do 61 | {:ok, service1} = FakeService.start_link(type: type) 62 | {:ok, service2} = FakeService.start_link(type: type) 63 | {:ok, service3} = FakeService.start_link(type: type) 64 | this_node = node() 65 | assert_receive({:join, ^service1, %{node: ^this_node, state: :online}}) 66 | assert_receive({:join, ^service2, %{node: ^this_node, state: :online}}) 67 | assert_receive({:join, ^service3, %{node: ^this_node, state: :online}}) 68 | 69 | res = Service.multi_cast(2, type, "my_key", {:set, "key", self()}) 70 | assert(res != {:error, :service_unavailable}) 71 | assert_receive({:set_request, "key"}) 72 | assert_receive({:set_request, "key"}) 73 | end 74 | 75 | test "invoke service multi call", %{service_type: type} do 76 | {:ok, service1} = FakeService.start_link(type: type) 77 | {:ok, service2} = FakeService.start_link(type: type) 78 | {:ok, service3} = FakeService.start_link(type: type) 79 | this_node = node() 80 | assert_receive({:join, ^service1, %{node: ^this_node, state: :online}}) 81 | assert_receive({:join, ^service2, %{node: ^this_node, state: :online}}) 82 | assert_receive({:join, ^service3, %{node: ^this_node, state: :online}}) 83 | 84 | [{:ok, out_service1, out_res1}, {:ok, out_service2, out_res2}] = 85 | Service.multi_call(2, type, "my_key", {:get, "my_key"}) 86 | 87 | assert(out_res1 == {:ok, "my_key"}) 88 | assert(out_res2 == {:ok, "my_key"}) 89 | assert(Enum.member?([service1, service2, service3], out_service1)) 90 | assert(Enum.member?([service1, service2, service3], out_service2)) 91 | assert(out_service1 != out_service2) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/dispatch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DispatchTest do 2 | use ExUnit.Case 3 | doctest Dispatch 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/helper.exs: -------------------------------------------------------------------------------- 1 | defmodule Dispatch.Helper do 2 | import ExUnit.Assertions 3 | alias Dispatch.Registry 4 | 5 | def setup_pubsub() do 6 | pubsub = Application.get_env(:dispatch, :pubsub, []) 7 | opts = pubsub[:opts] || [] 8 | opts = Keyword.put_new(opts, :name, Phoenix.PubSub.Test.PubSub) 9 | 10 | Phoenix.PubSub.Supervisor.start_link(opts) 11 | end 12 | 13 | def setup_registry() do 14 | pubsub = Application.get_env(:dispatch, :pubsub, []) 15 | opts = pubsub[:opts] || [] 16 | name = Keyword.get(opts, :name, Phoenix.PubSub.Test.PubSub) 17 | 18 | {:ok, registry_pid} = 19 | Registry.start_link( 20 | broadcast_period: 5_000, 21 | max_silent_periods: 20, 22 | name: Registry, 23 | dispatch_name: name 24 | ) 25 | 26 | {:ok, _} = Dispatch.HashRingServer.start_link(name: Registry) 27 | {:ok, registry_pid} 28 | end 29 | 30 | def clear_type(_type) do 31 | if old_pid = Process.whereis(Registry) do 32 | Supervisor.stop(old_pid) 33 | end 34 | catch 35 | :exit, _ -> nil 36 | end 37 | 38 | def wait_dispatch_ready(node \\ nil) do 39 | if node do 40 | assert_receive {:join, _, %{node: ^node, state: :online}}, 5_000 41 | else 42 | assert_receive {:join, _, %{node: _, state: :online}}, 5_000 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("support/helper.exs", __DIR__) 2 | Dispatch.Helper.setup_pubsub() 3 | Task.Supervisor.start_link(name: Dispatch.TaskSupervisor) 4 | 5 | ExUnit.start(capture_log: true) 6 | --------------------------------------------------------------------------------