├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── pulse.ex └── pulse │ ├── directory.ex │ ├── discover.ex │ └── register.ex ├── mix.exs └── mix.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/erlang,elixir,windows,osx,linux 3 | 4 | ### Erlang ### 5 | .eunit 6 | deps 7 | *.o 8 | *.beam 9 | *.plt 10 | erl_crash.dump 11 | ebin 12 | rel/example_project 13 | .concrete/DEV_MODE 14 | .rebar 15 | 16 | 17 | ### Elixir ### 18 | /_build 19 | /cover 20 | /deps 21 | /doc 22 | erl_crash.dump 23 | *.ez 24 | 25 | 26 | ### Windows ### 27 | # Windows image file caches 28 | Thumbs.db 29 | ehthumbs.db 30 | 31 | # Folder config file 32 | Desktop.ini 33 | 34 | # Recycle Bin used on file shares 35 | $RECYCLE.BIN/ 36 | 37 | # Windows Installer files 38 | *.cab 39 | *.msi 40 | *.msm 41 | *.msp 42 | 43 | # Windows shortcuts 44 | *.lnk 45 | 46 | 47 | ### OSX ### 48 | .DS_Store 49 | .AppleDouble 50 | .LSOverride 51 | 52 | # Icon must end with two \r 53 | Icon 54 | 55 | 56 | # Thumbnails 57 | ._* 58 | 59 | # Files that might appear in the root of a volume 60 | .DocumentRevisions-V100 61 | .fseventsd 62 | .Spotlight-V100 63 | .TemporaryItems 64 | .Trashes 65 | .VolumeIcon.icns 66 | 67 | # Directories potentially created on remote AFP share 68 | .AppleDB 69 | .AppleDesktop 70 | Network Trash Folder 71 | Temporary Items 72 | .apdisk 73 | 74 | 75 | ### Linux ### 76 | *~ 77 | 78 | # temporary files which can be created if a process still has a handle open of a deleted file 79 | .fuse_hidden* 80 | 81 | # KDE directory preferences 82 | .directory 83 | 84 | # Linux trash folder which might appear on any partition or disk 85 | .Trash-* 86 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ### Unreleased changes 5 | 6 | None. 7 | 8 | --- 9 | 10 | ### v0.1.0, 18 Mar 2016, Elixir `~> 1.2` 11 | 12 | * Initial release. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pulse 2 | ===== 3 | 4 | [![hex.pm version](https://img.shields.io/hexpm/v/pulse.svg?style=flat)](https://hex.pm/packages/pulse) 5 | 6 | Service registration and discovery library for Elixir. Relies on [etcd](https://coreos.com/etcd/) as an external service registry. 7 | 8 | Works best with FQDN node names, such as `my_node@ip-123-234-124-235.local`. 9 | 10 | ### Installation 11 | 12 | The latest version is `0.1.0` and requires Elixir `~> 1.2`. New releases may change this minimum compatible version depending on breaking language changes. The [changelog](https://github.com/heroiclabs/pulse/blob/master/CHANGELOG.md) lists every available release and its corresponding language version requirement. 13 | 14 | Releases are published through [hex.pm](https://hex.pm/packages/pulse). Add as a dependency in your `mix.exs` file: 15 | ```elixir 16 | defp deps do 17 | [ { :pulse, "~> 0.1" } ] 18 | end 19 | ``` 20 | 21 | Also ensure it's listed in the `mix.exs` list of applications to start: 22 | ```elixir 23 | def application do 24 | [ 25 | applications: [:pulse] 26 | ] 27 | end 28 | ``` 29 | 30 | ### Configuration 31 | 32 | Below is the complete default configuration. All parameters can be changed. 33 | 34 | ```elixir 35 | config :pulse, 36 | directory: "pulse" 37 | ``` 38 | 39 | For `etcd` connection configuration, see [Sonic](https://github.com/heroiclabs/sonic). 40 | 41 | ### Usage 42 | 43 | There are three discrete functions handled by Pulse: 44 | 45 | * Service Registration - Publishing service status so it is discoverable by other nodes. 46 | * Service Discovery - Retrieving a list of available nodes registered to provide a service. 47 | * Service Directory - Maintaining the internal service registry and handling connections to discovered nodes. 48 | 49 | For the examples below, we'll assume a typical `Application` module with a start function: 50 | 51 | ```elixir 52 | def start(_type, _args) do 53 | import Supervisor.Spec, warn: false 54 | 55 | children = [ 56 | worker(MyApp.SomeWorker, []) 57 | ] 58 | 59 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 60 | Supervisor.start_link(children, opts) 61 | end 62 | ``` 63 | 64 | #### Service Registration 65 | 66 | `Pulse.Register` is responsible for service registration. To use it, declare it as a worker in your application's supervision tree. 67 | 68 | The module takes 4 parameters in a keyword list, for legibility: 69 | 70 | * `service` - A binary indicating the service name to register. 71 | * `ttl` - An integer indicating the number of seconds the registration should live unless refreshed. 72 | * `heartbeat` - An integer indicating the number of seconds to wait between registration refreshes. 73 | * `delay` - An integer indicating the number of seconds to wait before first registration. 74 | 75 | ```elixir 76 | children = [ 77 | worker(Pulse.Register, [[service: "my_service", ttl: 15, heartbeat: 5, delay: 5]]) 78 | ] 79 | ``` 80 | 81 | Each `Pulse.Register` worker is only responsible for one service, but you can register the application as providing multiple services by declaring more than one worker. 82 | 83 | ```elixir 84 | children = [ 85 | worker(Pulse.Register, [[service: "my_service", ttl: 15, heartbeat: 5, delay: 5]], id: MyApp.MyServiceRegister), 86 | worker(Pulse.Register, [[service: "my_other_service", ttl: 15, heartbeat: 5, delay: 5]], id: MyApp.MyOthereSrviceRegister) 87 | # ... 88 | ] 89 | ``` 90 | 91 | The configuration options should be tuned for your application, however the values shown above are a good baseline. 92 | 93 | #### Service Discovery 94 | 95 | Start one or more `Pulse.Discover` workers to look up registered nodes for required services. 96 | 97 | The module taks 3 parameters in a keyword list, for legibility: 98 | 99 | * `service` - A binary indicating the service name to discover. 100 | * `poll` - An integer indicating the number of seconds to wait between discovery refreshes. 101 | * `delay` - An integer indicating the number of seconds to wait before first discovery. 102 | 103 | ```elixir 104 | children = [ 105 | worker(Pulse.Discover, [[service: "my_service", poll: 5, delay: 1]]) 106 | ] 107 | ``` 108 | 109 | Each `Pulse.Discover` worker is only responsible for one service, but you can discover multiple services by declaring more than one worker. 110 | 111 | ```elixir 112 | children = [ 113 | worker(Pulse.Discover, [[service: "my_service", poll: 5, delay: 1]], id: MyApp.MyServiceDiscover), 114 | worker(Pulse.Discover, [[service: "my_other_service", poll: 5, delay: 1]], id: MyApp.MyOtherServiceDiscover) 115 | # ... 116 | ] 117 | ``` 118 | 119 | The configuration options should be tuned for your application, however the values shown above are a good baseline. 120 | 121 | #### Service Directory 122 | 123 | The `Pulse.Directory` module maintains connections and tracks available nodes registered for each discovered service. This module is monitored by Pulse internally, you do not need to start a worker to use it. 124 | 125 | `Pulse.Directory.get/1` is the primary way to retrieve available nodes for any given discovered service. 126 | 127 | ```elixir 128 | iex> Pulse.Directory.get("my_service") 129 | [:"my_machine1@ip-123-234-124-235.local", :"my_machine2@ip-123-234-124-235.local"] 130 | ``` 131 | 132 | These nodes will already be connected by the `Pulse.Directory` process and are valid RPC targets if necessary. The directory also monitors connections and will unregister nodes from service lists if they disconnect. 133 | 134 | ### License 135 | 136 | ```none 137 | Copyright 2016 Heroic Labs 138 | 139 | Licensed under the Apache License, Version 2.0 (the "License"); 140 | you may not use this file except in compliance with the License. 141 | You may obtain a copy of the License at 142 | 143 | http://www.apache.org/licenses/LICENSE-2.0 144 | 145 | Unless required by applicable law or agreed to in writing, software 146 | distributed under the License is distributed on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 148 | See the License for the specific language governing permissions and 149 | limitations under the License. 150 | ``` 151 | -------------------------------------------------------------------------------- /lib/pulse.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulse do 2 | use Application 3 | 4 | @moduledoc """ 5 | Service registration and discovery library for Elixir. Relies on etcd as an external service registry. 6 | """ 7 | 8 | def start(_type, _args) do 9 | import Supervisor.Spec, warn: false 10 | 11 | children = [ 12 | worker(Pulse.Directory, []) 13 | ] 14 | 15 | # Set options and start supervisor. 16 | opts = [strategy: :one_for_one, name: Pulse.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/pulse/directory.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulse.Directory do 2 | use GenServer 3 | require Logger 4 | 5 | @moduledoc """ 6 | The `Pulse.Directory` module maintains connections and tracks available nodes registered for each discovered service. This module is monitored by Pulse internally, you do not need to start a worker to use it. 7 | 8 | `Pulse.Directory.get/1` is the primary way to retrieve available nodes for any given discovered service. 9 | 10 | ```elixir 11 | > Pulse.Directory.get("my_service") 12 | [:"my_machine1@ip-123-234-124-235.local", :"my_machine2@ip-123-234-124-235.local"] 13 | ``` 14 | 15 | These nodes will already be connected by the `Pulse.Directory` process and are valid RPC targets if necessary. The directory also monitors connections and will unregister nodes from service lists if they disconnect. 16 | """ 17 | 18 | @doc false 19 | def update(service, nodes_snapshot) do 20 | GenServer.call(__MODULE__, {:update, service, nodes_snapshot}) 21 | end 22 | 23 | @doc """ 24 | Retrieve a list of connected nodes that are registered to provide the given service. 25 | 26 | ```elixir 27 | > Pulse.Directory.get("my_service") 28 | [:"my_machine1@ip-123-234-124-235.local", :"my_machine2@ip-123-234-124-235.local"] 29 | ``` 30 | """ 31 | def get(service) do 32 | GenServer.call(__MODULE__, {:get, service}) 33 | end 34 | 35 | # 36 | # Internal. 37 | # 38 | 39 | @doc false 40 | def start_link() do 41 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 42 | end 43 | 44 | def init([]) do 45 | {:ok, %{"services" => %{}, "nodes" => %{}}} 46 | end 47 | 48 | def handle_info({:nodedown, node}, %{"services" => services, "nodes" => nodes}) do 49 | Logger.warn("Pulse.Directory unexpected disconnect from node #{node}") 50 | 51 | # Update the services-to-nodes and nodes-to-services mappings. 52 | services = Enum.into(services, %{}, fn({service, service_nodes}) -> 53 | {service, List.delete(service_nodes, node)} 54 | end) 55 | nodes = Map.delete(nodes, node) 56 | 57 | {:noreply, %{"services" => services, "nodes" => nodes}} 58 | end 59 | 60 | def handle_call({:get, service}, _from, %{"services" => services} = state) do 61 | {:reply, services[service] || [], state} 62 | end 63 | def handle_call({:update, service, nodes_snapshot}, _from, %{"services" => services, "nodes" => nodes}) do 64 | service_nodes = services[service] || [] 65 | 66 | # Connect new nodes as needed and update the state. 67 | new_nodes = complement(nodes_snapshot, service_nodes) 68 | {service_nodes, nodes} = Enum.reduce(new_nodes, {service_nodes, nodes}, fn(new_node, {service_nodes, nodes}) -> 69 | node_services = nodes[new_node] 70 | case node_services do 71 | # There are known services for this node, so assume it's a new node. 72 | nil -> 73 | case Node.connect(new_node) do 74 | true -> 75 | case Node.monitor(new_node, true) do 76 | true -> 77 | Logger.info("Pulse.Directory connected to node #{new_node}") 78 | Logger.info("Pulse.Directory node #{new_node} registered for service #{service}") 79 | {[new_node | service_nodes], Map.put(nodes, new_node, [service])} 80 | _ -> 81 | # Don't retry connection errors, the next directory update will reconnect. 82 | Logger.warn("Pulse.Directory failed to monitor node #{new_node}") 83 | {service_nodes, nodes} 84 | end 85 | _ -> 86 | # Don't retry connection errors, the next directory update will reconnect. 87 | Logger.warn("Pulse.Directory failed to connect to node #{new_node}") 88 | {service_nodes, nodes} 89 | end 90 | # This node is already connected, possibly by another service. 91 | _ -> 92 | Logger.info("Pulse.Directory node #{new_node} registered for service #{service}") 93 | {[new_node | service_nodes], Map.put(nodes, new_node, [service | node_services])} 94 | end 95 | end) 96 | 97 | # Disconnect missing nodes as needed. 98 | missing_nodes = complement(service_nodes, nodes_snapshot) 99 | {service_nodes, nodes} = Enum.reduce(missing_nodes, {service_nodes, nodes}, fn(missing_node, {service_nodes, nodes}) -> 100 | node_services = nodes[missing_node] 101 | case node_services do 102 | # Theis node is currently only serving this one service, disconnect. 103 | [^service] -> 104 | Node.monitor(missing_node, false) 105 | Node.disconnect(missing_node) 106 | Logger.info("Pulse.Directory node #{missing_node} unregistered for service #{service}") 107 | Logger.info("Pulse.Directory disconnected node #{missing_node}") 108 | {List.delete(service_nodes, missing_node), Map.delete(nodes, missing_node)} 109 | # There are other services for this node, do not disconnect. 110 | _ -> 111 | Logger.info("Pulse.Directory node #{missing_node} unregistered for service #{service}") 112 | {List.delete(service_nodes, missing_node), Map.put(nodes, missing_node, List.delete(node_services, service))} 113 | end 114 | end) 115 | 116 | # Update the services-to-nodes mapping. 117 | # The nodes-to-services mapping was updated through the new_nodes and missing_nodes processing above. 118 | services = case service_nodes do 119 | [] -> 120 | Map.delete(services, service) 121 | _ -> 122 | Map.put(services, service, service_nodes) 123 | end 124 | 125 | {:reply, :ok, %{"services" => services, "nodes" => nodes}} 126 | end 127 | 128 | # 129 | # Utilities. 130 | # 131 | 132 | defp complement(list1, list2) do 133 | List.foldl(list2, list1, fn(item, acc) -> 134 | List.delete(acc, item) 135 | end) 136 | end 137 | 138 | end 139 | -------------------------------------------------------------------------------- /lib/pulse/discover.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulse.Discover do 2 | use GenServer 3 | require Logger 4 | 5 | @moduledoc """ 6 | Start one or more `Pulse.Discover` workers to look up registered nodes for required services. 7 | 8 | The module taks 3 parameters in a keyword list, for legibility: 9 | 10 | * `service` - A binary indicating the service name to discover. 11 | * `poll` - An integer indicating the number of seconds to wait between discovery refreshes. 12 | * `delay` - An integer indicating the number of seconds to wait before first discovery. 13 | 14 | ```elixir 15 | children = [ 16 | worker(Pulse.Discover, [[service: "my_service", poll: 5, delay: 1]]) 17 | ] 18 | ``` 19 | 20 | Each `Pulse.Discover` worker is only responsible for one service, but you can discover multiple services by declaring more than one worker. 21 | 22 | ```elixir 23 | children = [ 24 | worker(Pulse.Discover, [[service: "my_service", poll: 5, delay: 1]], id: MyApp.MyServiceDiscover), 25 | worker(Pulse.Discover, [[service: "my_other_service", poll: 5, delay: 1]], id: MyApp.MyOtherServiceDiscover) 26 | # ... 27 | ] 28 | ``` 29 | 30 | The configuration options should be tuned for your application, however the values shown above are a good baseline. 31 | """ 32 | 33 | @doc false 34 | def start_link(args) do 35 | GenServer.start_link(__MODULE__, args, []) 36 | end 37 | 38 | def init([service: service, poll: poll, delay: delay]) do 39 | pid = self() 40 | 41 | # Schedule the first discover refresh. 42 | Process.send_after(pid, :discover, delay * 1000) 43 | 44 | {:ok, [pid: pid, service: service, poll: poll]} 45 | end 46 | 47 | def handle_info(:discover, [pid: pid, service: service, poll: poll] = state) do 48 | # Determine what the discover path will be. 49 | path = [Application.get_env(:pulse, :directory), service] 50 | 51 | # Execute the discover request and handle the result. 52 | result = Sonic.Client.dir_list(path) 53 | case result do 54 | {:ok, 200, _headers, body} -> 55 | nodes = Enum.map(body["node"]["nodes"] || [], fn(node) -> 56 | node["value"] |> String.to_atom 57 | end) 58 | Pulse.Directory.update(service, nodes) 59 | {:ok, 404, _headers, _body} -> 60 | :ok 61 | _ -> 62 | Logger.error("Pulse.Discover failed for service #{service}: #{inspect result}") 63 | end 64 | 65 | # Schedule the next discover refresh. 66 | Process.send_after(pid, :discover, poll * 1000) 67 | {:noreply, state} 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /lib/pulse/register.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulse.Register do 2 | use GenServer 3 | require Logger 4 | 5 | @moduledoc """ 6 | `Pulse.Register` is responsible for service registration. To use it, declare it as a worker in your application's supervision tree. 7 | 8 | The module takes 4 parameters in a keyword list, for legibility: 9 | 10 | * `service` - A binary indicating the service name to register. 11 | * `ttl` - An integer indicating the number of seconds the registration should live unless refreshed. 12 | * `heartbeat` - An integer indicating the number of seconds to wait between registration refreshes. 13 | * `delay` - An integer indicating the number of seconds to wait before first registration. 14 | 15 | ```elixir 16 | children = [ 17 | worker(Pulse.Register, [[service: "my_service", ttl: 15, heartbeat: 5, delay: 5]]) 18 | ] 19 | ``` 20 | 21 | Each `Pulse.Register` worker is only responsible for one service, but you can register the application as providing multiple services by declaring more than one worker. 22 | 23 | ```elixir 24 | children = [ 25 | worker(Pulse.Register, [[service: "my_service", ttl: 15, heartbeat: 5, delay: 5]], id: MyApp.MyServiceRegister), 26 | worker(Pulse.Register, [[service: "my_other_service", ttl: 15, heartbeat: 5, delay: 5]], id: MyApp.MyOthereSrviceRegister) 27 | # ... 28 | ] 29 | ``` 30 | 31 | The configuration options should be tuned for your application, however the values shown above are a good baseline. 32 | """ 33 | 34 | @doc false 35 | def start_link(args) do 36 | GenServer.start_link(__MODULE__, args, []) 37 | end 38 | 39 | def init([service: service, ttl: ttl, heartbeat: heartbeat, delay: delay]) do 40 | pid = self() 41 | 42 | # Schedule the first registration. 43 | Process.send_after(pid, :register, delay * 1000) 44 | 45 | {:ok, [pid: pid, service: service, ttl: ttl, heartbeat: heartbeat]} 46 | end 47 | 48 | def handle_info(:register, [pid: pid, service: service, ttl: ttl, heartbeat: heartbeat] = state) do 49 | # Determine what the registration path and content will be. 50 | id = Node.self |> to_string 51 | path = [Application.get_env(:pulse, :directory), service, id] 52 | 53 | # Execute the registration and report the result. 54 | result = Sonic.Client.kv_put(path, id, ttl: ttl) 55 | case result do 56 | {:ok, status, _headers, _body} when status == 200 or status == 201 -> 57 | :ok 58 | _ -> 59 | Logger.error("Pulse.Register failed for service #{service}: #{inspect result}") 60 | end 61 | 62 | # Schedule the next registration heartbeat. 63 | Process.send_after(pid, :register, heartbeat * 1000) 64 | {:noreply, state} 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Pulse.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.1.3" 5 | 6 | def project do 7 | [app: :pulse, 8 | version: @version, 9 | elixir: "~> 1.2", 10 | deps: deps, 11 | package: package, 12 | 13 | name: "Pulse", 14 | docs: [extras: ["README.md", "CHANGELOG.md"], 15 | main: "readme", 16 | source_ref: "v#{@version}"], 17 | source_url: "https://github.com/heroiclabs/pulse", 18 | description: description] 19 | end 20 | 21 | # Application configuration. 22 | def application do 23 | [ 24 | mod: {Pulse, []}, 25 | applications: [:logger, :sonic], 26 | env: [ 27 | directory: "pulse" 28 | ] 29 | ] 30 | end 31 | 32 | # List of dependencies. 33 | defp deps do 34 | [{:sonic, "~> 0.1"}, 35 | 36 | # Docs 37 | {:ex_doc, "~> 0.11", only: :dev}, 38 | {:earmark, "~> 0.2", only: :dev}] 39 | end 40 | 41 | # Description. 42 | defp description do 43 | """ 44 | Service registration and discovery library for Elixir. Relies on etcd as an external service registry. 45 | """ 46 | end 47 | 48 | # Package info. 49 | defp package do 50 | [files: ["lib", "mix.exs", "README.md", "LICENSE"], 51 | maintainers: ["Andrei Mihu"], 52 | licenses: ["Apache 2.0"], 53 | links: %{"GitHub" => "https://github.com/heroiclabs/pulse"}] 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"certifi": {:hex, :certifi, "0.4.0"}, 2 | "earmark": {:hex, :earmark, "0.2.1"}, 3 | "ex_doc": {:hex, :ex_doc, "0.11.4"}, 4 | "hackney": {:hex, :hackney, "1.5.7"}, 5 | "idna": {:hex, :idna, "1.2.0"}, 6 | "metrics": {:hex, :metrics, "1.0.1"}, 7 | "mimerl": {:hex, :mimerl, "1.0.2"}, 8 | "poison": {:hex, :poison, "2.1.0"}, 9 | "sonic": {:hex, :sonic, "0.1.3"}, 10 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0"}} 11 | --------------------------------------------------------------------------------