Current section

Files

Jump to
ark lib mogs board players tracker.ex
Raw

lib/mogs/board/players/tracker.ex

defmodule Mogs.Players.Tracker do
use TODO
require Record
require Logger
@default_player_timeout 30_000
@opts_schema %{timeout: [type: :integer, required: true, default: @default_player_timeout]}
def validate_opts(opts) when is_list(opts) do
KeywordValidator.validate(opts, @opts_schema)
end
def validate_parent_opts(:tracker, false) do
[]
end
def validate_parent_opts(:tracker, opts) when is_list(opts) do
case validate_opts(opts) do
{:ok, _} -> []
{:error, invalid} -> Keyword.values(invalid)
end
end
def validate_parent_opts(:tracker, opts) do
["Keyword required, got: #{inspect(opts)}"]
end
# - p2ms: a map from 1 player_in to N monitor_refs
# - m2p: a map from 1 monitor_ref to 1 player_id
# - ptimeout: a timeout (integer) indicating the time after which we
# will consider that a player has left when there is no alive
# tracked process for them.
# - p2tref: a map from 1 player_id to 1 erlang timeout tref. It is
# important to keep the reference of a timer to prevent race
# conditions: If a player has no alive process and we start a
# timer for 30 seconds. Then at ~30 seconds the timer fires but we
# also receive a new process for the player, and immediately after
# this process exits (starting a new timer). The player should be
# considered alive for 30 more seconds, so we need a way to
# discard the first timer as it is not relevant anymore.
@todo "remove client in schema"
@todo "change record name or use a man do differ from server s()"
Record.defrecordp(:s, client: nil, p2ms: %{}, m2p: %{}, p2tref: %{}, ptimeout: nil)
@type monitor :: reference()
@type player_id :: term()
@hibernate_timeout 40_000
@opts_schema %{
timeout: [type: :integer, default: @default_player_timeout]
}
def new(opts) do
opts = KeywordValidator.validate!(opts, @opts_schema)
s(ptimeout: Keyword.fetch!(opts, :timeout))
end
def track(s(p2ms: p2ms, m2p: m2p) = state, player_id, pid) when is_pid(pid) do
Logger.debug("Monitoring process #{inspect(pid)} for player #{inspect(player_id)}")
ref = Process.monitor(pid)
p2ms = Map.update(p2ms, player_id, [ref], fn refs -> [ref | refs] end)
m2p = Map.put(m2p, ref, player_id)
state = maybe_cancel_timeout(state, player_id)
s(state, p2ms: p2ms, m2p: m2p)
end
# def forget(tracker, player_id) do
# call(tracker, {:forget, player_id})
# end
def handle_call({:forget, player_id}, _from, s(p2ms: p2ms, m2p: m2p, p2tref: p2tref) = state) do
{p2ms, m2p} =
case Map.pop(p2ms, player_id) do
{nil, _} ->
{p2ms, m2p}
{refs, new_p2ms} ->
# delete all monitors for this player
Enum.each(refs, &Process.demonitor(&1, [:flush]))
{_, new_m2p} = Map.split(m2p, refs)
{new_p2ms, new_m2p}
end
# Delete the running timeout if any
state = maybe_cancel_timeout(state, player_id)
{:reply, :ok, s(state, p2ms: p2ms, m2p: m2p, p2tref: p2tref), @hibernate_timeout}
end
# Cancels any timeout present in p2tref for player_id and also deletes
# the player_id key from the p2tref map in case the timeout is already in the
# messagebox so we can ignore it as it will not match
defp maybe_cancel_timeout(s(p2tref: p2tref) = state, player_id)
when is_map_key(p2tref, player_id) do
{ref, p2tref} = Map.pop(p2tref, player_id)
case ref do
nil -> :ok
ref -> :erlang.cancel_timer(ref, async: false)
end
s(state, p2tref: p2tref)
end
# No timeout to cancel
defp maybe_cancel_timeout(state, _) do
state
end
# A tracked process is down
def handle_down(s(m2p: m2p) = state, {:DOWN, ref, :process, _pid, _})
when is_map_key(m2p, ref) do
s(m2p: m2p, p2ms: p2ms, p2tref: p2tref, ptimeout: delay) = state
{player_id, m2p} = Map.pop(m2p, ref)
# remove the current ref from the player's refs. If there is
# no more ref left we will start a timer to check the player
# again after a timeout
{has_refs?, p2ms} =
Map.get_and_update(p2ms, player_id, fn refs ->
case List.delete(refs, ref) do
[] -> {false, []}
new_refs -> {true, new_refs}
end
end)
state = s(state, p2ms: p2ms, m2p: m2p)
# Start the timer and register it in state
state =
if has_refs? do
state
else
Logger.debug("Start erlang timer for #{delay}ms")
tref = :erlang.start_timer(delay, self(), {__MODULE__, player_id})
s(state, p2tref: Map.put(p2tref, player_id, tref))
end
{:ok, state}
end
def handle_down(_state, {:DOWN, ref, :process, pid, _}) do
Logger.error("Unknown monitor reference: #{inspect(ref)} pid: #{inspect(pid)}")
:unknown
end
def handle_timeout(s(p2tref: p2tref) = state, {:timeout, ref, {__MODULE__, player_id}})
when is_map_key(p2tref, player_id) and ref == :erlang.map_get(player_id, p2tref) do
# We receive a timeout for a player, and since this timeout ref is
# in our state it means the player was not tracked since we
# started the timer, so the player actually left
s(p2ms: p2ms, p2tref: p2tref) = state
p2tref = Map.delete(p2tref, player_id)
p2ms = Map.delete(p2ms, player_id)
state = s(state, p2tref: p2tref, p2ms: p2ms)
# We will return our new state and also tell the caller that there is a
# player timeout.
{:player_timeout, player_id, state}
end
def handle_timeout(_, {:timeout, _ref, {__MODULE__, _}}) do
# Ignore stale timeout
:stale
end
end