├── .formatter.exs ├── .github └── workflows │ ├── elixir.yaml │ └── publish.yaml ├── .gitignore ├── .tool-versions ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── example.png ├── example ├── .formatter.exs ├── .gitignore ├── README.md ├── config │ └── config.exs ├── lib │ ├── example.ex │ └── node_check.ex ├── mix.exs └── mix.lock ├── lib └── strategy.ex ├── mix.exs ├── mix.lock ├── realtime_example.png └── test ├── strategy_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yaml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | env: 9 | MIX_ENV: test 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | services: 18 | db: 19 | image: postgres 20 | ports: 21 | - 5432:5432 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_USER: postgres 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 31 | strategy: 32 | # Specify the OTP and Elixir versions to use when building 33 | # and running the workflow steps. 34 | matrix: 35 | otp: ["25.0.4"] # Define the OTP version [required] 36 | elixir: ["1.14.1"] # Define the elixir version [required] 37 | steps: 38 | - name: Set up Elixir 39 | uses: erlef/setup-beam@v1 40 | with: 41 | otp-version: ${{matrix.otp}} 42 | elixir-version: ${{matrix.elixir}} 43 | - name: Checkout code 44 | uses: actions/checkout@v3 45 | - name: Install dependencies 46 | run: mix deps.get 47 | - name: Compiles without warnings 48 | run: mix compile --warnings-as-errors 49 | - name: Check Formatting 50 | run: mix format --check-formatted 51 | - name: Run tests 52 | run: mix test 53 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Elixir package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: erlef/setup-beam@v1 12 | with: 13 | version-file: .tool-versions 14 | version-type: strict 15 | - name: Restore dependencies cache 16 | uses: actions/cache@v3 17 | with: 18 | path: deps 19 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 20 | restore-keys: ${{ runner.os }}-mix- 21 | - run: mix deps.get 22 | - run: mix hex.publish --yes 23 | if: github.event_name == 'workflow_dispatch' 24 | env: 25 | HEX_API_KEY: ${{ secrets.HEX_AUTH_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | libcluster_postgres-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.14.4-otp-25 2 | erlang 25.3.1 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir as builder 2 | 3 | RUN apt-get update -y \ 4 | && apt-get install -y build-essential git \ 5 | && apt-get clean 6 | 7 | ENV MIX_ENV=prod 8 | 9 | RUN mix local.hex --force && mix local.rebar --force 10 | ADD ../../ /app 11 | 12 | WORKDIR /app 13 | RUN mix compile 14 | 15 | WORKDIR /app/example 16 | RUN mix deps.get && mix compile 17 | CMD [ "elixir", "--sname", "service", "--cookie", "secret", "-S", "mix", "run", "--no-halt" ] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Filipe Cabaço 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Libcluster Postgres Strategy 2 | 3 | [![Hex version badge](https://img.shields.io/hexpm/v/libcluster_postgres.svg)](https://hex.pm/packages/libcluster_postgres) 4 | [![License badge](https://img.shields.io/hexpm/l/libcluster_postgres.svg)](https://github.com/supabase/libcluster_postgres/blob/main/LICENSE) 5 | [![Elixir CI](https://github.com/supabase/libcluster_postgres/actions/workflows/elixir.yaml/badge.svg)](https://github.com/supabase/libcluster_postgres/actions/workflows/elixir.yaml) 6 | [![ElixirForum](https://img.shields.io/badge/Elixir_Forum-grey)](https://elixirforum.com/t/libcluster-postgres-clustering-strategy-for-libcluster/60053) 7 | 8 | Postgres Strategy for [libcluster](https://hexdocs.pm/libcluster/) which is used by Supabase on the [realtime](https://github.com/supabase/realtime), [supavisor](https://github.com/supabase/supavisor) and [logflare](https://github.com/logflare/logflare) projects. 9 | 10 | You can test it out by running `docker compose up` 11 | 12 | ![example.png](https://github.com/supabase/libcluster_postgres/blob/main/example.png?raw=true) 13 | 14 | ## Installation 15 | 16 | The package can be installed 17 | by adding `libcluster_postgres` to your list of dependencies in `mix.exs`: 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:libcluster_postgres, "~> 0.1"} 23 | ] 24 | end 25 | ``` 26 | 27 | ## How to use it 28 | 29 | To use it, set your configuration file with the informations for your database: 30 | 31 | ```elixir 32 | config :libcluster, 33 | topologies: [ 34 | example: [ 35 | strategy: LibclusterPostgres.Strategy, 36 | config: [ 37 | hostname: "localhost", 38 | username: "postgres", 39 | password: "postgres", 40 | database: "postgres", 41 | port: 5432, 42 | # optional, connection parameters. Defaults to [] 43 | parameters: [], 44 | # optional, defaults to false 45 | ssl: true, 46 | # optional, please refer to the Postgrex docs 47 | ssl_opts: nil, 48 | # optional, please refer to the Postgrex docs 49 | socket_options: nil, 50 | # optional, defaults to node cookie 51 | # must be a valid postgres identifier (alphanumeric and underscores only) with valid length 52 | channel_name: "cluster", 53 | # optional, heartbeat interval in ms. defaults to 5s 54 | heartbeat_interval: 10_000 55 | ], 56 | ] 57 | ] 58 | ``` 59 | 60 | Then add it to your supervision tree: 61 | 62 | ```elixir 63 | defmodule MyApp do 64 | use Application 65 | 66 | def start(_type, _args) do 67 | topologies = Application.get_env(:libcluster, :topologies) 68 | children = [ 69 | # ... 70 | {Cluster.Supervisor, [topologies]} 71 | # ... 72 | ] 73 | Supervisor.start_link(children, strategy: :one_for_one) 74 | end 75 | end 76 | ``` 77 | ### Why do we need a distributed Erlang Cluster? 78 | 79 | At Supabase, we use clustering in all of our Elixir projects which include [Logflare](https://github.com/Logflare/logflare), [Supavisor](https://github.com/supabase/supavisor) and [Realtime](https://github.com/supabase/realtime). With multiple servers connected we can load shed, create globally distributed services and provide the best service to our customers so we’re closer to them geographically and to their instances, reducing overall latency. 80 | 81 | ![Example of Realtime architecture where a customer from CA will connect to the server closest to them and their Supabase instance](https://github.com/supabase/libcluster_postgres/blob/main/realtime_example.png?raw=true) 82 | 83 | Example of Realtime architecture where a customer from CA will connect to the server closest to them and their Supabase instance 84 | To achieve a connected cluster, we wanted to be as cloud-agnostic as possible. This makes our self-hosting options more accessible. We don’t want to introduce extra services to solve this single issue - Postgres is the logical way to achieve it. 85 | 86 | The other piece of the puzzle was already built by the Erlang community being the defacto library to facilitate the creation of connected Elixir servers: [libcluster](https://github.com/bitwalker/libcluster). 87 | 88 | ### What is libcluster? 89 | 90 | [libcluster](https://github.com/bitwalker/libcluster) is the go-to package for connecting multiple BEAM instances and setting up healing strategies. libcluster provides out-of-the-box strategies and it allows users to define their own strategies by implementing a simple behavior that defines cluster formation and healing according to the supporting service you want to use. 91 | 92 | ### How did we use Postgres? 93 | 94 | Postgres provides an event system using two commands: [NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) and [LISTEN](https://www.postgresql.org/docs/current/sql-listen.html) so we can use them to propagate events within our Postgres instance. 95 | 96 | To use this features, you can use psql itself or any other Postgres client. Start by listening on a specific channel, and then notify to receive a payload. 97 | 98 | ```markdown 99 | postgres=# LISTEN channel; 100 | LISTEN 101 | postgres=# NOTIFY channel, 'payload'; 102 | NOTIFY 103 | Asynchronous notification "channel" with payload "payload" received from server process with PID 326. 104 | ``` 105 | 106 | Now we can replicate the same behavior in Elixir and [Postgrex](https://hex.pm/packages/postgrex) within IEx (Elixir's interactive shell). 107 | 108 | ```elixir 109 | Mix.install([{:postgrex, "~> 0.17.3"}]) 110 | config = [ 111 | hostname: "localhost", 112 | username: "postgres", 113 | password: "postgres", 114 | database: "postgres", 115 | port: 5432 116 | ] 117 | {:ok, db_notification_pid} = Postgrex.Notifications.start_link(config) 118 | Postgrex.Notifications.listen!(db_notification_pid, "channel") 119 | {:ok, db_conn_pid} = Postgrex.start_link(config) 120 | Postgrex.query!(db_conn_pid, "NOTIFY channel, 'payload'", []) 121 | 122 | receive do msg -> IO.inspect(msg) end 123 | # Mailbox will have a message with the following content: 124 | # {:notification, #PID<0.223.0>, #Reference<0.57446457.3896770561.212335>, "channel", "test"} 125 | ``` 126 | 127 | ### Building the strategy 128 | 129 | Using the libcluster `Strategy` behavior, inspired by [this GitHub repository](https://github.com/kevbuchanan/libcluster_postgres) and knowing how `NOTIFY/LISTEN` works, implementing a strategy becomes straightforward: 130 | 131 | 1. We send a `NOTIFY` to a channel with our `node()` address to a configured channel 132 | 133 | ```elixir 134 | # lib/cluster/strategy/postgres.ex:52 135 | def handle_continue(:connect, state) do 136 | with {:ok, conn} <- Postgrex.start_link(state.meta.opts.()), 137 | {:ok, conn_notif} <- Postgrex.Notifications.start_link(state.meta.opts.()), 138 | {_, _} <- Postgrex.Notifications.listen(conn_notif, state.config[:channel_name]) do 139 | Logger.info(state.topology, "Connected to Postgres database") 140 | 141 | meta = %{ 142 | state.meta 143 | | conn: conn, 144 | conn_notif: conn_notif, 145 | heartbeat_ref: heartbeat(0) 146 | } 147 | 148 | {:noreply, put_in(state.meta, meta)} 149 | else 150 | reason -> 151 | Logger.error(state.topology, "Failed to connect to Postgres: #{inspect(reason)}") 152 | {:noreply, state} 153 | end 154 | end 155 | ``` 156 | 157 | 2. We actively listen for new `{:notification, pid, reference, channel, payload}` messages and connect to the node received in the payload 158 | 159 | ```elixir 160 | # lib/cluster/strategy/postgres.ex:80 161 | def handle_info({:notification, _, _, _, node}, state) do 162 | node = String.to_atom(node) 163 | 164 | if node != node() do 165 | topology = state.topology 166 | Logger.debug(topology, "Trying to connect to node: #{node}") 167 | 168 | case Strategy.connect_nodes(topology, state.connect, state.list_nodes, [node]) do 169 | :ok -> Logger.debug(topology, "Connected to node: #{node}") 170 | {:error, _} -> Logger.error(topology, "Failed to connect to node: #{node}") 171 | end 172 | end 173 | 174 | {:noreply, state} 175 | end 176 | ``` 177 | 178 | 3. Finally, we configure a heartbeat that is similar to the first message sent for cluster formation so libcluster is capable of heal if need be 179 | 180 | ```elixir 181 | # lib/cluster/strategy/postgres.ex:73 182 | def handle_info(:heartbeat, state) do 183 | Process.cancel_timer(state.meta.heartbeat_ref) 184 | Postgrex.query(state.meta.conn, "NOTIFY #{state.config[:channel_name]}, '#{node()}'", []) 185 | ref = heartbeat(state.config[:heartbeat_interval]) 186 | {:noreply, put_in(state.meta.heartbeat_ref, ref)} 187 | end 188 | ``` 189 | 190 | These three simple steps allow us to connect as many nodes as needed, regardless of the cloud provider, by utilising something that most projects already have: a Postgres connection. 191 | 192 | ## Acknowledgements 193 | 194 | A special thank you to [@gotbones](https://twitter.com/gotbones) for creating libcluster and [@kevinbuch\_](https://twitter.com/kevinbuch_) for the original inspiration for this strategy. 195 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: postgres:latest 6 | ports: 7 | - 5432:5432 8 | environment: 9 | POSTGRES_PASSWORD: postgres 10 | POSTGRES_USER: postgres 11 | service1: 12 | depends_on: 13 | - db 14 | build: . 15 | service2: 16 | depends_on: 17 | - db 18 | build: . 19 | service3: 20 | depends_on: 21 | - db 22 | build: . 23 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase/libcluster_postgres/82e36ab33ec0d83c1f7ded57ac977f9ba1079654/example.png -------------------------------------------------------------------------------- /example/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | example-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `example` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:example, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :console, 4 | format: "[$level][node:$node] $message\n", metadata: [:node] 5 | 6 | config :libcluster, topologies: [ 7 | postgres: [ 8 | strategy: LibclusterPostgres.Strategy, 9 | config: [ 10 | hostname: "db", 11 | username: "postgres", 12 | password: "postgres", 13 | database: "postgres", 14 | port: 5432, 15 | parameters: [], 16 | channel_name: "cluster" 17 | ] 18 | ] 19 | ] 20 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | use Application 3 | def start(_type, _args) do 4 | topologies = Application.get_env(:libcluster, :topologies) 5 | children = [{Cluster.Supervisor, [topologies]}, NodeCheck] 6 | Supervisor.start_link(children, strategy: :one_for_one) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example/lib/node_check.ex: -------------------------------------------------------------------------------- 1 | defmodule NodeCheck do 2 | use GenServer 3 | require Logger 4 | def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) 5 | def init(_) do 6 | Process.send_after(self(), :check, 1_000) 7 | {:ok, []} 8 | end 9 | 10 | def handle_info(:check, state) do 11 | Logger.info("Connected nodes: #{inspect(Node.list())}", %{node: node()}) 12 | Process.send_after(self(), :check, 1_000) 13 | 14 | {:noreply, state} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example, 7 | version: "0.1.0", 8 | elixir: "~> 1.15", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {Example, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:libcluster, "~> 3.3"}, 26 | {:libcluster_postgres, "~> 0.1.0"} 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, 3 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 4 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 5 | "libcluster": {:hex, :libcluster, "3.3.3", "a4f17721a19004cfc4467268e17cff8b1f951befe428975dd4f6f7b84d927fe0", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7c0a2275a0bb83c07acd17dab3c3bfb4897b145106750eeccc62d302e3bdfee5"}, 6 | "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, 7 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 8 | } 9 | -------------------------------------------------------------------------------- /lib/strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule LibclusterPostgres.Strategy do 2 | @moduledoc """ 3 | A libcluster strategy that uses Postgres LISTEN/NOTIFY to determine the cluster topology. 4 | 5 | This strategy operates by having all nodes in the cluster listen for and send notifications to a shared Postgres channel. 6 | 7 | When a node comes online, it begins to broadcast its name in a "heartbeat" message to the channel. All other nodes that receive this message attempt to connect to it. 8 | 9 | This strategy does not check connectivity between nodes and does not disconnect them 10 | 11 | ## Options 12 | 13 | * `heartbeat_interval` - The interval at which to send heartbeat messages in milliseconds (optional; default: 5_000) 14 | * `channel_name` - The name of the channel to which nodes will listen and notify (optional; defaults to the result of `Node.get_cookie/0`) 15 | """ 16 | use GenServer 17 | 18 | alias Cluster.Strategy 19 | alias Cluster.Logger 20 | 21 | def start_link(args), do: GenServer.start_link(__MODULE__, args) 22 | 23 | @spec init([%{:config => any(), :meta => any(), optional(any()) => any()}, ...]) :: 24 | {:ok, %{:config => list(), :meta => map(), optional(any()) => any()}, 25 | {:continue, :connect}} 26 | def init([state]) do 27 | channel_name = Keyword.get(state.config, :channel_name, clean_cookie(Node.get_cookie())) 28 | 29 | opts = [ 30 | hostname: Keyword.fetch!(state.config, :hostname), 31 | username: Keyword.fetch!(state.config, :username), 32 | password: Keyword.fetch!(state.config, :password), 33 | database: Keyword.fetch!(state.config, :database), 34 | port: Keyword.fetch!(state.config, :port), 35 | ssl: Keyword.get(state.config, :ssl), 36 | ssl_opts: Keyword.get(state.config, :ssl_opts), 37 | socket_options: Keyword.get(state.config, :socket_options, []), 38 | parameters: Keyword.get(state.config, :parameters, []), 39 | channel_name: channel_name 40 | ] 41 | 42 | config = 43 | state.config 44 | |> Keyword.put_new(:channel_name, channel_name) 45 | |> Keyword.put_new(:heartbeat_interval, 5_000) 46 | |> Keyword.delete(:url) 47 | 48 | meta = %{ 49 | opts: fn -> opts end, 50 | conn: nil, 51 | conn_notif: nil, 52 | heartbeat_ref: make_ref() 53 | } 54 | 55 | {:ok, %{state | config: config, meta: meta}, {:continue, :connect}} 56 | end 57 | 58 | def handle_continue(:connect, state) do 59 | with {:ok, conn} <- Postgrex.start_link(state.meta.opts.()), 60 | {:ok, conn_notif} <- Postgrex.Notifications.start_link(state.meta.opts.()), 61 | {_, _} <- Postgrex.Notifications.listen(conn_notif, state.config[:channel_name]) do 62 | Logger.info(state.topology, "Connected to Postgres database") 63 | 64 | meta = %{ 65 | state.meta 66 | | conn: conn, 67 | conn_notif: conn_notif, 68 | heartbeat_ref: heartbeat(0) 69 | } 70 | 71 | {:noreply, put_in(state.meta, meta)} 72 | else 73 | reason -> 74 | Logger.error(state.topology, "Failed to connect to Postgres: #{inspect(reason)}") 75 | {:noreply, state} 76 | end 77 | end 78 | 79 | def handle_info(:heartbeat, state) do 80 | Process.cancel_timer(state.meta.heartbeat_ref) 81 | Postgrex.query(state.meta.conn, "NOTIFY #{state.config[:channel_name]}, '#{node()}'", []) 82 | ref = heartbeat(state.config[:heartbeat_interval]) 83 | {:noreply, put_in(state.meta.heartbeat_ref, ref)} 84 | end 85 | 86 | def handle_info({:notification, _, _, _, node}, state) do 87 | node = String.to_atom(node) 88 | 89 | if node != node() do 90 | topology = state.topology 91 | Logger.debug(topology, "Trying to connect to node: #{node}") 92 | 93 | case Strategy.connect_nodes(topology, state.connect, state.list_nodes, [node]) do 94 | :ok -> Logger.debug(topology, "Connected to node: #{node}") 95 | {:error, _} -> Logger.error(topology, "Failed to connect to node: #{node}") 96 | end 97 | end 98 | 99 | {:noreply, state} 100 | end 101 | 102 | def handle_info(msg, state) do 103 | Logger.error(state.topology, "Undefined message #{inspect(msg, pretty: true)}") 104 | {:noreply, state} 105 | end 106 | 107 | ### Internal functions 108 | @spec heartbeat(non_neg_integer()) :: reference() 109 | defp heartbeat(interval) when interval >= 0 do 110 | Process.send_after(self(), :heartbeat, interval) 111 | end 112 | 113 | # Replaces all non alphanumeric values into underescore 114 | defp clean_cookie(cookie) when is_atom(cookie), do: cookie |> Atom.to_string() |> clean_cookie() 115 | 116 | defp clean_cookie(str) when is_binary(str) do 117 | String.replace(str, ~r/\W/, "_") 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LibclusterPostgres.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | name: "libcluster_postgres", 7 | app: :libcluster_postgres, 8 | version: "0.1.3", 9 | elixir: "~> 1.14", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | package: package(), 13 | docs: docs(), 14 | aliases: aliases(), 15 | source_url: "https://github.com/supabase/libcluster_postgres", 16 | elixirc_paths: elixirc_paths(Mix.env()), 17 | description: "Postgres strategy for libcluster" 18 | ] 19 | end 20 | 21 | def application do 22 | [ 23 | extra_applications: [:logger] 24 | ] 25 | end 26 | 27 | defp deps do 28 | [ 29 | {:libcluster, "~> 3.3"}, 30 | {:postgrex, ">= 0.0.0"}, 31 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 32 | ] 33 | end 34 | 35 | defp package do 36 | [ 37 | files: ["lib", "test", "mix.exs", "README*", "LICENSE*"], 38 | maintainers: ["Supabase"], 39 | licenses: ["MIT"], 40 | links: %{"GitHub" => "https://github.com/supabase/libcluster_postgres"} 41 | ] 42 | end 43 | 44 | defp docs do 45 | [ 46 | main: "readme", 47 | extras: ["README.md"], 48 | formatters: ["html", "epub"] 49 | ] 50 | end 51 | 52 | defp elixirc_paths(:test), do: ["lib", "test", "test/support"] 53 | defp elixirc_paths(_), do: ["lib"] 54 | 55 | defp aliases() do 56 | [ 57 | test: ["cmd epmd -daemon", "test"] 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, 3 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 5 | "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, 6 | "global_flags": {:hex, :global_flags, "1.0.0", "ee6b864979a1fb38d1fbc67838565644baf632212bce864adca21042df036433", [:rebar3], [], "hexpm", "85d944cecd0f8f96b20ce70b5b16ebccedfcd25e744376b131e89ce61ba93176"}, 7 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 8 | "libcluster": {:hex, :libcluster, "3.3.3", "a4f17721a19004cfc4467268e17cff8b1f951befe428975dd4f6f7b84d927fe0", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7c0a2275a0bb83c07acd17dab3c3bfb4897b145106750eeccc62d302e3bdfee5"}, 9 | "local_cluster": {:hex, :local_cluster, "1.2.1", "8eab3b8a387680f0872eacfb1a8bd5a91cb1d4d61256eec6a655b07ac7030c73", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "aae80c9bc92c911cb0be085fdeea2a9f5b88f81b6bec2ff1fec244bb0acc232c"}, 10 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 14 | "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, 15 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 16 | } 17 | -------------------------------------------------------------------------------- /realtime_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase/libcluster_postgres/82e36ab33ec0d83c1f7ded57ac977f9ba1079654/realtime_example.png -------------------------------------------------------------------------------- /test/strategy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LibclusterPostgres.StrategyTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Postgrex.Notifications 5 | 6 | @config [ 7 | hostname: "localhost", 8 | username: "postgres", 9 | password: "postgres", 10 | database: "postgres", 11 | port: 5432, 12 | parameters: [], 13 | channel_name: "cluster" 14 | ] 15 | 16 | test "sends psql notification with cluster information to a configured channel name" do 17 | verify_conn_notification = start_supervised!({Notifications, @config}) 18 | Notifications.listen(verify_conn_notification, @config[:channel_name]) 19 | 20 | topologies = [postgres: [strategy: LibclusterPostgres.Strategy, config: @config]] 21 | start_supervised!({Cluster.Supervisor, [topologies]}) 22 | 23 | channel_name = @config[:channel_name] 24 | node = "#{node()}" 25 | 26 | assert_receive {:notification, _, _, ^channel_name, ^node}, 1000 27 | end 28 | 29 | describe "connect via cookie" do 30 | @cookie_config Keyword.put(@config, :channel_name, "my_random__123_long_cookie") 31 | @cookie :"my-random-#123-long-cookie" 32 | setup do 33 | cookie = Node.get_cookie() 34 | Node.set_cookie(Node.self(), @cookie) 35 | 36 | on_exit(fn -> 37 | Node.set_cookie(Node.self(), cookie) 38 | end) 39 | end 40 | 41 | test "cookie default connection" do 42 | verify_conn_notification = start_supervised!({Notifications, @cookie_config}) 43 | Notifications.listen(verify_conn_notification, @cookie_config[:channel_name]) 44 | 45 | config = Keyword.delete(@cookie_config, :channel_name) 46 | topologies = [postgres: [strategy: LibclusterPostgres.Strategy, config: config]] 47 | start_supervised!({Cluster.Supervisor, [topologies]}) 48 | 49 | channel_name = @cookie_config[:channel_name] 50 | node = "#{node()}" 51 | assert_receive {:notification, _, _, ^channel_name, ^node}, 1000 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # start distributed node 2 | :net_kernel.start([:"test@127.0.0.1"]) 3 | 4 | ExUnit.start() 5 | --------------------------------------------------------------------------------