Current section

Files

Jump to
yggdrasil lib yggdrasil subscriber manager.ex
Raw

lib/yggdrasil/subscriber/manager.ex

defmodule Yggdrasil.Subscriber.Manager do
@moduledoc """
Manages subscription to a channel.
"""
use GenServer
require Logger
alias Yggdrasil.Backend
alias Yggdrasil.Channel
alias Yggdrasil.Subscriber.Generator
@doc false
defstruct [:status, :channel, :cache]
alias __MODULE__, as: State
@typedoc """
Subscription status.
"""
@type status :: :connected | :disconnected
@typedoc false
@type t :: %__MODULE__{
status: status(),
channel: Channel.t(),
cache: reference()
}
############
# Public API
@doc """
Starts a manager with a `channel`.
"""
@spec start_link(Channel.t(), pid()) :: GenServer.on_start()
@spec start_link(Channel.t(), pid(), GenServer.options()) ::
GenServer.on_start()
def start_link(channel, pid, options \\ [])
def start_link(%Channel{} = channel, first_pid, options) do
GenServer.start_link(__MODULE__, [channel, first_pid], options)
end
@doc """
Stops a `manager` with an optional `reason`.
"""
@spec stop(GenServer.name()) :: :ok
@spec stop(GenServer.name(), term()) :: :ok
def stop(manager, reason \\ :normal)
def stop(manager, reason) do
GenServer.stop(manager, reason)
end
@doc """
Adds a `pid` to the `channel`.
"""
@spec add(Channel.t(), pid()) :: :ok | {:error, binary()}
def add(channel, pid)
def add(%Channel{} = channel, pid) do
name = {__MODULE__, channel}
case ExReg.whereis_name(name) do
:undefined ->
{:error, "Manager is not available for subscriptions"}
manager ->
GenServer.call(manager, {:add, pid})
end
catch
:exit, {:timeout, _} ->
{:error, "Manager is not available for subscriptions"}
end
@doc """
Removes a `pid` from the `channel`.
"""
@spec remove(Channel.t(), pid()) :: :ok | {:error, binary()}
def remove(channel, pid)
def remove(%Channel{} = channel, pid) do
name = {__MODULE__, channel}
case ExReg.whereis_name(name) do
:undefined ->
{:error, "Manager is not available for unsubscriptions"}
manager ->
GenServer.call(manager, {:remove, pid})
end
catch
:exit, {:timeout, _} ->
{:error, "Manager not available for unsubscriptions"}
end
@doc """
Reports the connection of the adapter.
"""
@spec connected(Channel.t()) :: :ok | {:error, binary()}
def connected(channel)
def connected(%Channel{} = channel) do
name = {__MODULE__, channel}
case ExReg.whereis_name(name) do
:undefined ->
{:error, "Manager is not available for subscriptions"}
manager ->
GenServer.call(manager, :connected)
end
catch
:exit, _ ->
{:error, "Manager is not available for subscriptions"}
end
@doc """
Reports the disconnection of the adapter.
"""
@spec disconnected(Channel.t()) :: :ok | {:error, binary()}
def disconnected(channel)
def disconnected(%Channel{} = channel) do
name = {__MODULE__, channel}
case ExReg.whereis_name(name) do
:undefined ->
{:error, "Manager is not available for subscriptions"}
manager ->
GenServer.call(manager, :disconnected)
end
catch
:exit, _ ->
{:error, "Manager is not available for subscriptions"}
end
@doc """
Whether the `pid` is subscribed or not to the `channel`.
"""
@spec subscribed?(Channel.t()) :: boolean()
@spec subscribed?(Channel.t(), nil | pid()) :: boolean()
def subscribed?(channel, pid \\ nil)
def subscribed?(%Channel{} = channel, nil) do
subscribed?(channel, self())
end
def subscribed?(%Channel{} = channel, pid) when is_pid(pid) do
subscribed?(:connected, channel, pid) or
subscribed?(:disconnected, channel, pid)
end
@doc false
@spec subscribed?(status(), Channel.t(), pid()) :: boolean()
def subscribed?(status, channel, pid)
def subscribed?(status, %Channel{} = channel, pid) do
pid in :pg.get_members(__MODULE__, {status, channel})
end
#####################
# GenServer callbacks
@impl GenServer
def init(args)
def init([%Channel{} = channel, first_pid]) do
state = %State{
status: :disconnected,
channel: channel,
cache: :ets.new(:monitored, [:set])
}
Logger.debug(fn -> "Started #{__MODULE__} for #{inspect(channel)}" end)
{:ok, state, {:continue, {:first_pid, first_pid}}}
end
@impl GenServer
def handle_continue(message, state)
def handle_continue({:first_pid, pid}, %State{} = state) do
do_join(pid, state)
{:noreply, state}
end
@impl GenServer
def handle_call(message, from, state)
def handle_call(:connected, _from, %State{} = state) do
new_state = do_connected(state)
if has_subscribers?(new_state) do
{:reply, :ok, new_state}
else
{:stop, :normal, :ok, new_state}
end
end
def handle_call(:disconnected, _from, %State{} = state) do
new_state = do_disconnected(state)
if has_subscribers?(new_state) do
{:reply, :ok, new_state}
else
{:stop, :normal, :ok, new_state}
end
end
def handle_call({:add, pid}, _from, %State{} = state) do
do_join(pid, state)
if has_subscribers?(state) do
{:reply, :ok, state}
else
{:stop, :normal, :ok, state}
end
end
def handle_call({:remove, pid}, _from, %State{} = state) do
do_leave(pid, state)
if has_subscribers?(state) do
{:reply, :ok, state}
else
{:stop, :normal, :ok, state}
end
end
def handle_call(_msg, _from, %State{} = state) do
{:noreply, state}
end
@impl GenServer
def handle_info(message, state)
def handle_info({:DOWN, _, _, pid, _}, %State{} = state) do
do_leave(pid, state)
if has_subscribers?(state) do
{:noreply, state}
else
{:stop, :normal, state}
end
end
def handle_info(_msg, %State{} = state) do
{:noreply, state}
end
@impl GenServer
def terminate(reason, state)
def terminate(:normal, %State{channel: channel}) do
Generator.stop_distributor(channel)
Logger.debug(fn ->
"Stopped #{__MODULE__} for #{inspect(channel)}"
end)
end
def terminate(reason, %State{channel: channel} = _state) do
Logger.warn(fn ->
"Stopped #{__MODULE__} for #{inspect(channel)} due to #{inspect(reason)}"
end)
end
########################
# Subscription functions
@spec has_subscribers?(State.t()) :: boolean()
defp has_subscribers?(state)
defp has_subscribers?(%State{status: status, channel: channel}) do
[] != :pg.get_members(__MODULE__, {status, channel})
end
######################
# Connection functions
@spec do_connected(State.t()) :: State.t()
defp do_connected(state)
defp do_connected(%State{channel: channel} = state) do
name = {:disconnected, channel}
members = :pg.get_members(__MODULE__, name)
do_leave(members, state)
new_state = %State{state | status: :connected}
do_join(members, new_state)
new_state
end
@spec do_disconnected(State.t()) :: State.t()
defp do_disconnected(state)
defp do_disconnected(%State{channel: channel} = state) do
name = {:connected, channel}
members = :pg.get_members(__MODULE__, name)
do_leave(members, state)
new_state = %State{state | status: :disconnected}
do_join(members, new_state)
new_state
end
##############
# PG functions
@spec do_join(pid() | [pid()], State.t()) :: :ok
defp do_join(pid, state)
defp do_join([], %State{} = _state) do
:ok
end
defp do_join([_ | _] = pids, %State{} = state) do
Enum.each(pids, fn pid -> do_join(pid, state) end)
end
defp do_join(pid, %State{status: status, channel: channel} = state)
when is_pid(pid) do
name = {status, channel}
members = :pg.get_members(__MODULE__, name)
monitor(pid, state)
if pid not in members do
:pg.join(__MODULE__, name, pid)
if status == :connected, do: Backend.connected(channel, pid)
end
:ok
end
@doc false
@spec do_leave(pid() | [pid()], State.t()) :: :ok
defp do_leave(pid, state)
defp do_leave([], %State{} = _state) do
:ok
end
defp do_leave([_ | _] = pids, %State{} = state) do
Enum.each(pids, fn pid -> do_leave(pid, state) end)
end
defp do_leave(pid, %State{status: status, channel: channel} = state)
when is_pid(pid) do
name = {status, channel}
members = :pg.get_members(__MODULE__, name)
demonitor(pid, state)
if pid in members do
:pg.leave(__MODULE__, name, pid)
if status == :connected, do: Backend.disconnected(channel, pid)
end
:ok
end
######################
# Monitoring functions
@spec monitor(pid(), State.t()) :: :ok
defp monitor(pid, state)
defp monitor(pid, %State{cache: cache}) do
with [] <- :ets.lookup(cache, pid) do
ref = Process.monitor(pid)
:ets.insert(cache, {pid, ref})
end
:ok
end
@spec demonitor(pid(), State.t()) :: :ok
defp demonitor(pid, state)
defp demonitor(pid, %State{cache: cache}) do
with [{^pid, ref} | _] <- :ets.lookup(cache, pid) do
:ets.delete(cache, pid)
Process.demonitor(ref)
end
:ok
end
end