├── test ├── test_helper.exs └── phoenix_pubsub_postgres_test.exs ├── .gitignore ├── config └── config.exs ├── mix.exs ├── lib ├── phoenix_pubsub_postgres │ ├── connection.ex │ └── server.ex └── phoenix_pubsub_postgres.ex └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /test/phoenix_pubsub_postgres_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixPubSubPostgresTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixPubSubPostgres.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :phoenix_pubsub_postgres, 6 | version: "0.0.2", 7 | description: "Postgresql PubSub adapter for Phoenix apps", 8 | package: package, 9 | elixir: "~> 1.0", 10 | deps: deps] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type `mix help compile.app` for more information 16 | def application do 17 | [applications: [:logger, :postgrex, :poolboy]] 18 | end 19 | 20 | 21 | defp package do 22 | [contributors: ["Shankar Dhanasekaran - (shankardevy)"], 23 | licenses: ["MIT"], 24 | links: %{demo: "http://pgchat.opendrops.com", 25 | github: "https://github.com/opendrops/phoenix-pubsub-postgres"}] 26 | end 27 | 28 | # Dependencies can be Hex packages: 29 | # 30 | # {:mydep, "~> 0.3.0"} 31 | # 32 | # Or git/path repositories: 33 | # 34 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 35 | # 36 | # Type `mix help deps` for more examples and options 37 | defp deps do 38 | [{:phoenix, github: "phoenixframework/phoenix", override: true}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:poolboy, "~> 1.4.2"}] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/phoenix_pubsub_postgres/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixPubSubPostgres.Connection do 2 | use GenServer 3 | 4 | @moduledoc """ 5 | The connection pool for the `PhoenixPubSubPostgres` adapter 6 | See `PhoenixPubSubPostgres` for configuration details. 7 | """ 8 | 9 | def start_link(opts) do 10 | GenServer.start_link(__MODULE__, opts) 11 | end 12 | 13 | def init([opts]) do 14 | Process.flag(:trap_exit, true) 15 | {:ok, {:disconnected, opts}} 16 | end 17 | 18 | def handle_call(:conn, _, {:disconnected, opts}) do 19 | case Postgrex.Connection.start_link(opts) do 20 | {:ok, pid} -> {:reply, {:ok, pid}, {pid, opts}} 21 | {:error, err} -> {:reply, {:error, err}, {:disconnected, opts}} 22 | end 23 | end 24 | 25 | def handle_call(:conn, _, {pid, opts}) do 26 | {:reply, {:ok, pid}, {pid, opts}} 27 | end 28 | 29 | def handle_info({:EXIT, pid, _}, {pid, opts}) do 30 | {:noreply, {:disconnected, opts}} 31 | end 32 | 33 | def handle_info(_, state) do 34 | {:noreply, state} 35 | end 36 | 37 | def terminate(_reason, {:disconnected, _}), do: :ok 38 | def terminate(_reason, {pid, _}) do 39 | try do 40 | Postgrex.Connection.stop(conn) 41 | catch 42 | :exit, {:noproc, _} -> :ok 43 | end 44 | :ok 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PhoenixPubSubPostgres 2 | ===================== 3 | 4 | This package provides postgres adapater for [Phoenix](http://github.com/phoenixframework/phoenix)'s Pub/Sub channels. 5 | 6 | 7 | Demo 8 | --- 9 | Open pgchat.opendrops.com in two different browsers windows and start sending some messages. The message passing is handled by postgres's built-in [pubsub support] (http://www.postgresql.org/docs/9.1/static/sql-notify.html) 10 | 11 | Demo app source 12 | -------------- 13 | Source code of the demo app is available at http://github.com/opendrops/pgchat-demo-app 14 | 15 | - 16 | How to use 17 | --------- 18 | 19 | Add phoenix_pubsub_postgres to your mix deps 20 | 21 | defp deps do 22 | [{:phoenix, github: "phoenixframework/phoenix", override: true}, 23 | {:phoenix_pubsub_postgres, "~> 0.0.2"}, 24 | {:postgrex, ">= 0.0.0"}, 25 | {:cowboy, "~> 1.0"}] 26 | end 27 | 28 | To use Postgres as your PubSub adapter, simply add it to your Endpoint's config and modify it as needed. 29 | 30 | config :my_app, MyApp.Endpiont, 31 | ... 32 | pubsub: [name: MyApp.PubSub, 33 | adapter: PhoenixPubSubPostgres, 34 | hostname: "localhost", 35 | database: "myapp_db_env", 36 | username: "postgres", 37 | password: "postgres"] 38 | -------------------------------------------------------------------------------- /lib/phoenix_pubsub_postgres.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixPubSubPostgres do 2 | use Supervisor 3 | 4 | @moduledoc """ 5 | The Supervisor for the Postgres `Phoenix.PubSub` adapter 6 | 7 | To use Postgres as your PubSub adapter, simply add it to your Endpoint's config: 8 | 9 | config :my_app, MyApp.Endpiont, 10 | ... 11 | pubsub: [name: MyApp.PubSub, 12 | adapter: PhoenixPubSubPostgres, 13 | hostname: "localhost", 14 | database: "myapp_db_env", 15 | username: "postgres", 16 | password: "postgres"] 17 | 18 | ## Options 19 | 20 | * `name` - The required name to register the PubSub processes, ie: `MyApp.PubSub` 21 | * `host` - The Postgres-server host IP, defaults `"127.0.0.1"` 22 | * `port` - The Postgres-server port, defaults `6379` 23 | * `password` - The Postgres-server password, defaults `""` 24 | 25 | """ 26 | 27 | @pool_size 5 28 | @defaults [host: "127.0.0.1", port: 5432] 29 | 30 | 31 | def start_link(name, opts) do 32 | supervisor_name = Module.concat(name, Supervisor) 33 | Supervisor.start_link(__MODULE__, [name, opts], name: supervisor_name) 34 | end 35 | 36 | @doc false 37 | def init([server_name, opts]) do 38 | opts = Keyword.merge(@defaults, opts) 39 | opts = Keyword.merge(opts, host: String.to_char_list(opts[:host])) 40 | if pass = opts[:password] do 41 | opts = Keyword.put(opts, :pass, String.to_char_list(pass)) 42 | end 43 | 44 | pool_name = Module.concat(server_name, Pool) 45 | local_name = Module.concat(server_name, Local) 46 | server_opts = Keyword.merge(opts, name: server_name, 47 | local_name: local_name, 48 | pool_name: pool_name) 49 | pool_opts = [ 50 | name: {:local, pool_name}, 51 | worker_module: PhoenixPubSubPostgres.Connection, 52 | size: opts[:pool_size] || @pool_size, 53 | max_overflow: 0 54 | ] 55 | 56 | children = [ 57 | worker(Phoenix.PubSub.Local, [local_name]), 58 | :poolboy.child_spec(pool_name, pool_opts, [opts]), 59 | worker(PhoenixPubSubPostgres.Server, [server_opts]), 60 | ] 61 | supervise children, strategy: :one_for_all 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/phoenix_pubsub_postgres/server.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixPubSubPostgres.Server do 2 | use GenServer 3 | alias Phoenix.PubSub.Local 4 | 5 | @moduledoc """ 6 | `Phoenix.PubSub` adapter for Postgres 7 | 8 | See `Phoenix.PubSub.Postgres` for details and configuration options. 9 | """ 10 | 11 | @reconnect_after_ms 5_000 12 | @postgres_msg_vsn 1 13 | 14 | @doc """ 15 | Starts the server 16 | """ 17 | def start_link(opts) do 18 | GenServer.start_link(__MODULE__, opts, name: Dict.fetch!(opts, :name)) 19 | end 20 | 21 | @doc """ 22 | Broadcasts message to Postgres. To be only called from {:perform, {m, f, a}} 23 | response to clients 24 | """ 25 | def broadcast(namespace, pool_name, postgres_msg) do 26 | :poolboy.transaction pool_name, fn worker_pid -> 27 | bin_msg = :erlang.term_to_binary(postgres_msg) |> :base64.encode 28 | case GenServer.call(worker_pid, :conn) do 29 | {:ok, conn_pid} -> 30 | case Postgrex.Connection.query(conn_pid, "NOTIFY $1, $2", [namespace, bin_msg]) do 31 | {:error, reason} -> {:error, reason} 32 | {:error, kind, reason, stack} -> 33 | :erlang.raise(kind, reason, stack) 34 | _ -> :ok 35 | end 36 | 37 | {:error, reason} -> {:error, reason} 38 | end 39 | end 40 | end 41 | 42 | def init(opts) do 43 | Process.flag(:trap_exit, true) 44 | send(self, :establish_conn) 45 | {:ok, %{local_name: Keyword.fetch!(opts, :local_name), 46 | pool_name: Keyword.fetch!(opts, :pool_name), 47 | namespace: postgres_namespace, 48 | postgrex_pid: nil, 49 | postgrex_ref: nil, 50 | status: :disconnected, 51 | opts: opts}} 52 | end 53 | 54 | def handle_call({:broadcast, from_pid, topic, msg}, _from, state) do 55 | postgres_msg = {@postgres_msg_vsn, from_pid, topic, msg} 56 | resp = {:perform, {__MODULE__, :broadcast, [state.namespace, state.pool_name, postgres_msg]}} 57 | {:reply, resp, state} 58 | end 59 | 60 | def handle_call({:subscribe, pid, topic, opts}, _from, state) do 61 | response = {:perform, {Local, :subscribe, [state.local_name, pid, topic, opts]}} 62 | {:reply, response, state} 63 | end 64 | 65 | def handle_call({:unsubscribe, pid, topic}, _from, state) do 66 | response = {:perform, {Local, :unsubscribe, [state.local_name, pid, topic]}} 67 | {:reply, response, state} 68 | end 69 | 70 | def handle_info(:establish_conn, state) do 71 | case Postgrex.Connection.start_link(state.opts) do 72 | {:ok, postgrex_pid} -> establish_success(postgrex_pid, state) 73 | _error -> establish_failed(state) 74 | end 75 | end 76 | 77 | def handle_info({:notification, pid, ref, namespace, encoded_message} = message, state) do 78 | {_vsn, from_pid, topic, msg} = :base64.decode(encoded_message) |> :erlang.binary_to_term 79 | Local.broadcast(state.local_name, from_pid, topic, msg) 80 | {:noreply, state} 81 | end 82 | 83 | defp postgres_namespace(), do: "phoenix_pubsub_postgres" 84 | 85 | defp establish_failed(state) do 86 | :timer.send_after(@reconnect_after_ms, :establish_conn) 87 | {:noreply, %{state | status: :disconnected}} 88 | end 89 | defp establish_success(postgrex_pid, state) do 90 | {:ok, ref} = Postgrex.Connection.listen(postgrex_pid, state.namespace) 91 | {:noreply, %{state | postgrex_pid: postgrex_pid, 92 | postgrex_ref: ref, 93 | status: :connected}} 94 | end 95 | end 96 | --------------------------------------------------------------------------------