Current section

Files

Jump to
yggdrasil_postgres lib yggdrasil subscriber adapter postgres.ex
Raw

lib/yggdrasil/subscriber/adapter/postgres.ex

defmodule Yggdrasil.Subscriber.Adapter.Postgres do
@moduledoc """
Yggdrasil subscriber adapter for Postgres. The name of the channel must be a
binary e.g:
Subscription to channel:
```
iex(2)> channel = %Yggdrasil.Channel{name: "pg_channel", adapter: :postgres}
iex(3)> Yggdrasil.subscribe(channel)
:ok
iex(4)> flush()
{:Y_CONNECTED, %Yggdrasil.Channel{name: "pg_channel", (...)}}
```
Publishing message:
```
iex(5)> Yggdrasil.publish(channel, "foo")
:ok
```
Subscriber receiving message:
```
iex(6)> flush()
{:Y_EVENT, %Yggdrasil.Channel{name: "pg_channel", (...)}, "foo"}
```
The subscriber can also unsubscribe from the channel:
```
iex(7)> Yggdrasil.unsubscribe(channel)
:ok
iex(8)> flush()
{:Y_DISCONNECTED, %Yggdrasil.Channel{name: "pg_channel", (...)}}
```
"""
use Yggdrasil.Subscriber.Adapter
use Bitwise
use Connection
require Logger
alias Yggdrasil.Channel
alias Yggdrasil.Subscriber.Publisher
alias Yggdrasil.Subscriber.Manager
alias Yggdrasil.Settings.Postgres, as: Settings
defstruct [:channel, :conn, :ref, :retries]
alias __MODULE__, as: State
############
# Client API
@impl true
def start_link(channel, options \\ [])
def start_link(%Channel{} = channel, options) do
arguments = %{channel: channel}
Connection.start_link(__MODULE__, arguments, options)
end
######################
# Connection callbacks
@impl true
def init(%{channel: %Channel{} = channel} = arguments) do
new_arguments = Map.put(arguments, :retries, 0)
state = struct(State, new_arguments)
Process.flag(:trap_exit, true)
Logger.debug(fn -> "Started #{__MODULE__} for #{inspect(channel)}" end)
{:connect, :init, state}
end
@impl true
def connect(
_info,
%State{channel: %Channel{name: name} = channel} = state
) do
options = postgres_options(channel)
{:ok, conn} = Postgrex.Notifications.start_link(options)
try do
Postgrex.Notifications.listen(conn, name)
catch
_, reason ->
backoff(reason, state)
else
{:ok, ref} ->
connected(conn, ref, state)
error ->
backoff(error, state)
end
end
##
# Backoff.
defp backoff(error, %State{channel: %Channel{} = channel} = state) do
{backoff, new_state} = calculate_backoff(state)
Logger.warn(fn ->
"#{__MODULE__} cannot connect to Postgres #{inspect(channel)}" <>
" due to #{inspect(error)}. Backing off for #{inspect(backoff)} ms"
end)
{:backoff, backoff, new_state}
end
##
# Connected.
defp connected(conn, ref, %State{channel: %Channel{} = channel} = state) do
Process.monitor(conn)
Logger.debug(fn ->
"#{__MODULE__} connected to Postgres #{inspect(channel)}"
end)
new_state = %State{state | conn: conn, ref: ref, retries: 0}
Manager.connected(channel)
{:ok, new_state}
end
@impl true
def disconnect(_info, %State{conn: nil, ref: nil} = state) do
disconnected(state)
end
def disconnect(:down, %State{channel: channel} = state) do
Manager.disconnected(channel)
disconnect(:down, %State{state | conn: nil, ref: nil})
end
def disconnect(:exit, %State{channel: channel} = state) do
Manager.disconnected(channel)
disconnect(:exit, %State{state | conn: nil, ref: nil})
end
##
# Disconnected.
defp disconnected(%State{channel: %Channel{} = channel} = state) do
Logger.warn(fn ->
"#{__MODULE__} disconnected from Postgres #{inspect(channel)}"
end)
backoff(:disconnected, state)
end
@impl true
def handle_info(
{:notification, _, _, _, message},
%State{channel: channel} = state
) do
Publisher.notify(channel, message)
{:noreply, state}
end
def handle_info({:DOWN, _, :process, _, _}, %State{} = state) do
{:disconnect, :down, state}
end
def handle_info({:EXIT, _, _}, %State{} = state) do
{:disconnect, :exit, state}
end
def handle_info(_, %State{} = state) do
{:noreply, state}
end
@impl true
def terminate(reason, %State{conn: nil, ref: nil} = state) do
terminated(reason, state)
end
def terminate(
reason,
%State{channel: channel, conn: conn, ref: ref} = state
) do
Postgrex.Notifications.unlisten(conn, ref)
GenServer.stop(conn)
Manager.disconnected(channel)
terminate(reason, %State{state | conn: nil, ref: nil})
end
##
# Terminated.
defp terminated(:normal, %State{channel: %Channel{} = channel}) do
Logger.debug(fn ->
"Stopped #{__MODULE__} for #{inspect(channel)}"
end)
end
defp terminated(reason, %State{channel: %Channel{} = channel}) do
Logger.warn(fn ->
"Stopped #{__MODULE__} for #{inspect(channel)} due to #{inspect(reason)}"
end)
end
#########
# Helpers
@doc false
def calculate_backoff(
%State{channel: %Channel{namespace: namespace}, retries: retries} =
state
) do
max_retries = Settings.yggdrasil_postgres_max_retries!(namespace)
new_retries = if retries == max_retries, do: retries, else: retries + 1
slot_size = Settings.yggdrasil_postgres_slot_size!(namespace)
# ms
new_backoff = (2 <<< new_retries) * Enum.random(1..slot_size)
new_state = %State{state | retries: new_retries}
{new_backoff, new_state}
end
@doc false
def postgres_options(%Channel{namespace: namespace}) do
[
hostname: Settings.yggdrasil_postgres_hostname!(namespace),
port: Settings.yggdrasil_postgres_port!(namespace),
username: Settings.yggdrasil_postgres_username!(namespace),
password: Settings.yggdrasil_postgres_password!(namespace),
database: Settings.yggdrasil_postgres_database!(namespace)
]
end
end