├── .gitignore ├── .travis.yml ├── README.md ├── lib ├── boltun.ex └── boltun │ ├── callbacks_agent.ex │ ├── connection.ex │ ├── listener.ex │ └── supervisor.ex ├── mix.exs ├── mix.lock └── test ├── boltun └── listener_test.exs ├── boltun_test.exs ├── conn_helper.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.4.4 4 | otp_release: 5 | - 19.3 6 | services: 7 | - postgresql 8 | addons: 9 | postgresql: "9.4" 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boltun 2 | 3 | [![Build Status](https://travis-ci.org/bitgamma/boltun.svg?branch=master)](https://travis-ci.org/bitgamma/boltun) 4 | 5 | Boltun simplifies handling of the LISTEN/NOTIFY mechanism offered by Postgres. Basically you will just need to define which callback(s) should be called on a specific notification and that's it 6 | 7 | ## Usage 8 | 9 | Add Boltun as a dependency in your `mix.exs` file. 10 | 11 | ```elixir 12 | defp deps do 13 | [{:boltun, "~> 1.0.2"}] 14 | end 15 | ``` 16 | 17 | After you are done, run `mix deps.get` in your shell to fetch the dependencies. 18 | 19 | ## Defining a listener 20 | 21 | Defining a listener is trivial. See the example below 22 | 23 | ```elixir 24 | defmodule TestListener do 25 | use Boltun, otp_app: :my_app 26 | 27 | listen do 28 | channel "my_channel", :my_callback 29 | channel "my_channel", :my_other_callback 30 | channel "my_other_channel", :my_other_callback 31 | end 32 | 33 | def my_callback(channel, payload) do 34 | IO.puts channel 35 | IO.puts payload 36 | end 37 | ... 38 | end 39 | ``` 40 | 41 | The channel is a concept defined by Postgres. On the SQL side you will have something like `NOTIFY my_channel, 'my payload'` happening, for example, in a trigger which will cause your callbacks TestListener.my_callback and TestListener.my_other_callback to be called. The callbacks will be invoked synchronously in the order they were declared in the listen block. 42 | 43 | ## Using a listener 44 | 45 | Defining a listener is not enough to use it. It should be started with `TestListener.start_link`. You can do this, for example, in a supervisor. 46 | The listener also needs the connection parameters to establish a connection. You will provide this in your config.exs file in this format 47 | 48 | ```elixir 49 | ... 50 | config :my_app, TestListener, database: "postgres", username: "postgres", password: "postgres", hostname: "localhost" 51 | ... 52 | ``` 53 | 54 | The full list of options can be read in the documentation for Postgrex. 55 | 56 | ## License 57 | Copyright (c) 2014, Bitgamma OÜ 58 | 59 | Permission to use, copy, modify, and/or distribute this software for any 60 | purpose with or without fee is hereby granted, provided that the above 61 | copyright notice and this permission notice appear in all copies. 62 | 63 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 64 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 65 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 66 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 67 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 68 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 69 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 70 | -------------------------------------------------------------------------------- /lib/boltun.ex: -------------------------------------------------------------------------------- 1 | defmodule Boltun do 2 | @moduledoc """ 3 | Provides macros to define a listener and its callbacks. See the example below 4 | 5 | defmodule TestListener do 6 | use Boltun, otp_app: :my_app 7 | 8 | listen do 9 | channel "my_channel", :my_callback 10 | channel "my_channel", :my_other_callback 11 | channel "my_other_channel", :my_other_callback 12 | end 13 | 14 | def my_callback(channel, payload) do 15 | IO.puts channel 16 | IO.puts payload 17 | end 18 | ... 19 | end 20 | """ 21 | 22 | defmacro __using__(opts) do 23 | otp_app = Keyword.fetch!(opts, :otp_app) 24 | 25 | quote do 26 | import Boltun, only: [channel: 2, channel: 3, channel: 4, listen: 1] 27 | Module.register_attribute(__MODULE__, :registered_callbacks, []) 28 | Module.put_attribute(__MODULE__, :registered_callbacks, %{}) 29 | 30 | def config() do 31 | Boltun.get_config(unquote(otp_app), __MODULE__) 32 | end 33 | end 34 | end 35 | 36 | @doc """ 37 | Defines a callback for the given channel, module, function and arguments. 38 | """ 39 | defmacro channel(channel, module, function, args) do 40 | quote do 41 | callback = {unquote(module), unquote(function), unquote(args)} 42 | channel_cbs = Map.get(@registered_callbacks, unquote(channel), []) ++ [callback] 43 | Module.put_attribute(__MODULE__, :registered_callbacks, Map.put(@registered_callbacks, unquote(channel), channel_cbs)) 44 | end 45 | end 46 | 47 | @doc """ 48 | Defines a callback for the given channel, function and optional arguments. 49 | The callback must be defined in the same module using this macro. 50 | """ 51 | defmacro channel(channel, function, args \\ []) do 52 | quote do 53 | channel(unquote(channel), __MODULE__, unquote(function), unquote(args)) 54 | end 55 | end 56 | 57 | @doc """ 58 | Defines the listener and its callbacks. Multiple callbacks per channel are supported and they will be invoked in 59 | in the order in which they appear in this block 60 | """ 61 | defmacro listen(do: source) do 62 | quote do 63 | unquote(source) 64 | 65 | def start_link do 66 | opts = [ 67 | connection: config(), 68 | callbacks: @registered_callbacks, name: __MODULE__ 69 | ] 70 | Boltun.Supervisor.start_link(opts) 71 | end 72 | end 73 | end 74 | 75 | @doc false 76 | def get_config(otp_app, module) do 77 | if config = Application.get_env(otp_app, module) do 78 | config 79 | else 80 | raise ArgumentError, "configuration for #{inspect module} not specified in #{inspect otp_app}" 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/boltun/callbacks_agent.ex: -------------------------------------------------------------------------------- 1 | defmodule Boltun.CallbacksAgent do 2 | @moduledoc """ 3 | Stores the callback to be executed by a listener. The functions defined here should only be 4 | invoked by a listener, since it also takes care of sending the LISTEN/UNLISTEN commands to the connection. 5 | """ 6 | 7 | @doc "Starts the callback agent" 8 | def start_link(initial_callbacks, opts) do 9 | Agent.start_link(fn -> Enum.into(initial_callbacks, Map.new) end, opts) 10 | end 11 | 12 | @doc "Returns the list of channels to monitor" 13 | def channels(agent) do 14 | Agent.get(agent, &Map.keys(&1)) 15 | end 16 | 17 | @doc "Returns all callbacks for the given channel" 18 | def callbacks_for_channel(agent, channel) do 19 | Agent.get(agent, &Map.get(&1, channel)) 20 | end 21 | 22 | @doc "Adds a callback for the given channel" 23 | def add_to_channel(agent, channel, {_module, _function, _args} = value) do 24 | Agent.update(agent, fn callbacks -> 25 | channel_cbs = Map.get(callbacks, channel, []) ++ [value] 26 | Map.put(callbacks, channel, channel_cbs) 27 | end) 28 | end 29 | 30 | @doc "Removes all callbacks for the given channel" 31 | def remove_channel(agent, channel) do 32 | Agent.update(agent, &Map.delete(&1, channel)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/boltun/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Boltun.Connection do 2 | @moduledoc false 3 | 4 | @doc false 5 | def start_link(opts, name) do 6 | res = {:ok, pid} = Postgrex.Notifications.start_link(opts) 7 | Process.register pid, name 8 | res 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/boltun/listener.ex: -------------------------------------------------------------------------------- 1 | defmodule Boltun.Listener do 2 | use GenServer 3 | 4 | @moduledoc """ 5 | Listens to Postgrex notifications and invokes the registed callbacks. 6 | """ 7 | 8 | @doc """ 9 | Starts a listener. Expects a keyword list with the following parameters 10 | * `:connection` => pid or name of the connection to the database 11 | * `:callbacks_agent` => pid or name of the agent keeping track of the callbacks 12 | 13 | and a keyword list with GenServer specific options, like `:name`. 14 | """ 15 | def start_link(opts, server_opts) do 16 | GenServer.start_link(__MODULE__, opts, server_opts) 17 | end 18 | 19 | @doc "Stops this listener and deregisters all channels" 20 | def stop(listener) do 21 | GenServer.call(listener, :stop) 22 | end 23 | 24 | @doc "Adds a callback to the given channel" 25 | def add_callback(listener, channel, {_module, _function, _args} = value) do 26 | GenServer.call(listener, {:add_callback, channel, value}) 27 | end 28 | 29 | @doc "Removes all callbacks for the given channel" 30 | def remove_channel(listener, channel) do 31 | GenServer.call(listener, {:remove_channel, channel}) 32 | end 33 | 34 | def init(opts) do 35 | conn = Keyword.fetch!(opts, :connection) 36 | cba = Keyword.fetch!(opts, :callbacks_agent) 37 | 38 | state = %{connection: conn, callbacks_agent: cba, listeners: %{}} 39 | {:ok, Enum.reduce(Boltun.CallbacksAgent.channels(cba), state, ®ister_channel(&2, &1))} 40 | end 41 | 42 | def handle_call(:stop, _from, %{callbacks_agent: cba} = state) do 43 | state = Enum.reduce(Boltun.CallbacksAgent.channels(cba), state, &deregister_channel(&2, &1)) 44 | {:stop, :normal, :ok, state} 45 | end 46 | def handle_call({:add_callback, channel, value}, _from, state) do 47 | state = register_callback(state, channel, value) 48 | {:reply, :ok, state} 49 | end 50 | def handle_call({:remove_channel, channel}, _from, state) do 51 | state = deregister_callback(state, channel) 52 | {:reply, :ok, state} 53 | end 54 | 55 | def handle_info({:notification, _, _, channel, payload}, %{callbacks_agent: cba} = state) do 56 | execute_callbacks(cba, channel, payload) 57 | {:noreply, state} 58 | end 59 | 60 | defp register_callback(%{callbacks_agent: cba} = state, channel, value) do 61 | Boltun.CallbacksAgent.add_to_channel(cba, channel, value) 62 | register_channel(state, channel) 63 | end 64 | 65 | defp deregister_callback(%{callbacks_agent: cba} = state, channel) do 66 | Boltun.CallbacksAgent.remove_channel(cba, channel) 67 | deregister_channel(state, channel) 68 | end 69 | 70 | defp register_channel(%{connection: conn, listeners: refs} = state, channel) do 71 | if not Map.has_key?(refs, channel) do 72 | {:ok, ref} = Postgrex.Notifications.listen(conn, channel) 73 | refs = Map.put(refs, channel, ref) 74 | %{ state | listeners: refs} 75 | else 76 | state 77 | end 78 | end 79 | 80 | defp deregister_channel(%{connection: conn, listeners: refs} = state, channel) do 81 | {ref, refs} = Map.pop(refs, channel) 82 | Postgrex.Notifications.unlisten(conn, ref) 83 | %{ state | listeners: refs} 84 | end 85 | 86 | defp execute_callbacks(cba, channel, payload) do 87 | for {module, function, args} <- Boltun.CallbacksAgent.callbacks_for_channel(cba, channel) do 88 | apply(module, function, [channel | [payload | args]]) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/boltun/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Boltun.Supervisor do 2 | use Supervisor 3 | 4 | @doc """ 5 | Starts the Boltun supervision tree. The options are: 6 | * `connection`: the connection parameters 7 | * `name`: the base name to register the process. 8 | * `callbacks`: the initial callbacks (optional) 9 | 10 | The supervisor will register each started process by using the provided name and concatenating 11 | a dot and the following suffixes: 12 | * `CallbacksAgent` the agent where the callbacks are stored 13 | * `Connection` the connection to the database 14 | * `Listener` the actual listener. You can use this to manage active callbacks 15 | """ 16 | def start_link(opts) do 17 | Supervisor.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) 18 | end 19 | 20 | @doc false 21 | def init(opts) do 22 | name = Keyword.fetch!(opts, :name) 23 | callbacks_agent = Module.concat(name, "CallbacksAgent") 24 | connection = Module.concat(name, "Connection") 25 | listener = Module.concat(name, "Listener") 26 | 27 | children = [ 28 | worker(Boltun.CallbacksAgent, [Keyword.get(opts, :callbacks, []), [name: callbacks_agent]]), 29 | worker(Boltun.Connection, [Keyword.fetch!(opts, :connection), connection]), 30 | worker(Boltun.Listener, [[connection: connection, callbacks_agent: callbacks_agent], [name: listener]]) 31 | ] 32 | supervise(children, strategy: :rest_for_one) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Boltun.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :boltun, 6 | version: "1.0.2", 7 | elixir: "~> 1.0", 8 | deps: deps(), 9 | package: package(), 10 | description: description()] 11 | end 12 | 13 | def application do 14 | [applications: [:postgrex, :logger]] 15 | end 16 | 17 | defp deps do 18 | [{:postgrex, "~> 0.13"}, 19 | {:earmark, "~> 1.2", only: :dev}, 20 | {:ex_doc, "~> 0.16", only: :dev}] 21 | end 22 | 23 | defp description do 24 | "Transforms notifications from the Postgres LISTEN/NOTIFY mechanism into callback execution" 25 | end 26 | 27 | defp package do 28 | [maintainers: ["Michele Balistreri"], 29 | licenses: ["ISC"], 30 | links: %{"GitHub" => "https://github.com/bitgamma/boltun"}] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 2 | "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, 3 | "decimal": {:hex, :decimal, "1.3.1", "157b3cedb2bfcb5359372a7766dd7a41091ad34578296e951f58a946fcab49c6", [:mix], []}, 4 | "earmark": {:hex, :earmark, "1.2.2", "f718159d6b65068e8daeef709ccddae5f7fdc770707d82e7d126f584cd925b74", [:mix], []}, 5 | "ex_doc": {:hex, :ex_doc, "0.16.1", "b4b8a23602b4ce0e9a5a960a81260d1f7b29635b9652c67e95b0c2f7ccee5e81", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}, 6 | "postgrex": {:hex, :postgrex, "0.13.2", "2b88168fc6a5456a27bfb54ccf0ba4025d274841a7a3af5e5deb1b755d95154e", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}} 7 | -------------------------------------------------------------------------------- /test/boltun/listener_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boltun.ListenerTest do 2 | use ExUnit.Case 3 | import ConnHelper 4 | 5 | test "add callbacks" do 6 | Process.register(self(), BoltunTest) 7 | {:ok, sup} = Boltun.Supervisor.start_link([connection: Application.get_env(:boltun, Boltun.TestListener), name: Boltun.TestListener]) 8 | 9 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "test_channel", {Boltun.TestListener, :test, []}) 10 | notify("test_channel", "test") 11 | 12 | assert_receive({:test_test_channel, "test"}, 1000) 13 | 14 | Process.unregister(BoltunTest) 15 | Process.exit(sup, :normal) 16 | end 17 | 18 | test "add multiple callbacks" do 19 | Process.register(self(), BoltunTest) 20 | {:ok, sup} = Boltun.Supervisor.start_link([connection: Application.get_env(:boltun, Boltun.TestListener), name: Boltun.TestListener]) 21 | 22 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "test_channel", {Boltun.TestListener, :test, []}) 23 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "test_channel", {Boltun.TestListener, :other_test, []}) 24 | 25 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "other_channel", {Boltun.TestListener, :test, []}) 26 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "other_channel", {Boltun.TestListener, :other_test, []}) 27 | 28 | notify("test_channel", "test") 29 | 30 | assert_receive({:test_test_channel, "test"}, 1000) 31 | assert_receive({:other_test_channel, "test"}, 1000) 32 | 33 | notify("other_channel", "test") 34 | 35 | assert_receive({:test_other_channel, "test"}, 1000) 36 | assert_receive({:other_other_channel, "test"}, 1000) 37 | 38 | Process.unregister(BoltunTest) 39 | Process.exit(sup, :normal) 40 | end 41 | 42 | test "remove callbacks" do 43 | Process.register(self(), BoltunTest) 44 | {:ok, sup} = Boltun.Supervisor.start_link([connection: Application.get_env(:boltun, Boltun.TestListener), name: Boltun.TestListener]) 45 | 46 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "test_channel", {Boltun.TestListener, :test, []}) 47 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "other_channel", {Boltun.TestListener, :test, []}) 48 | Boltun.Listener.add_callback(Boltun.TestListener.Listener, "other_channel", {Boltun.TestListener, :other_test, []}) 49 | Boltun.Listener.remove_channel(Boltun.TestListener.Listener, "other_channel") 50 | 51 | notify("other_channel", "test") 52 | refute_receive({:test_other_channel, "test"}, 1000) 53 | 54 | notify("test_channel", "test") 55 | assert_receive({:test_test_channel, "test"}, 1000) 56 | 57 | Process.unregister(BoltunTest) 58 | Process.exit(sup, :normal) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/boltun_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boltun.TestListener do 2 | use Boltun, otp_app: :boltun 3 | 4 | listen do 5 | channel "test_channel", :test 6 | end 7 | 8 | def test("test_channel", id) do 9 | send BoltunTest, {:test_test_channel, id} 10 | end 11 | 12 | def test("other_channel", id) do 13 | send BoltunTest, {:test_other_channel, id} 14 | end 15 | 16 | def other_test("test_channel", id) do 17 | send BoltunTest, {:other_test_channel, id} 18 | end 19 | 20 | def other_test("other_channel", id) do 21 | send BoltunTest, {:other_other_channel, id} 22 | end 23 | end 24 | 25 | defmodule BoltunTest do 26 | use ExUnit.Case 27 | import ConnHelper 28 | 29 | test "listens to notifications" do 30 | Process.register(self(), BoltunTest) 31 | Boltun.TestListener.start_link 32 | 33 | notify("test_channel", "test") 34 | 35 | assert_receive({:test_test_channel, "test"}, 1000) 36 | 37 | Process.unregister(BoltunTest) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/conn_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule ConnHelper do 2 | def start_connection do 3 | elem(Postgrex.start_link(Application.get_env(:boltun, Boltun.TestListener)), 1) 4 | end 5 | 6 | def notify(channel, payload) do 7 | conn = start_connection() 8 | Postgrex.query(conn, "NOTIFY #{channel}, '#{payload}'", []) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("conn_helper.exs", __DIR__) 2 | 3 | Application.put_env(:boltun, Boltun.TestListener, [database: "postgres", username: "postgres", password: "postgres", hostname: "localhost"]) 4 | ExUnit.start() 5 | --------------------------------------------------------------------------------