Critical: - Remove :protected from WeatherCache ETS (conflicted with :public) - Register Aprsme.ReplayRegistry in supervision tree - Route :continue_replay dead message to :start_replay handler - Add 5000ms timeout to unbounded :rpc.call calls - Fix falsy lat/lng check rejecting valid 0 coordinates Medium: - Fix live_reload pattern (temp_web -> aprsme_web) - Remove duplicate ecto_repos config - Make DB SSL configurable via DB_SSL env var - Track sizeCheckTimeout on self for cleanup in destroyed() - Wrap pushEvent calls in try/catch - Remove unused size variable in marker cluster icon - Capture touch coords at touchstart instead of stale TouchEvent - Demote console.log to console.debug in production code - Add catch-all to normalize_bounds preventing GenServer crash Style: - Add missing @impl true annotations in is.ex - Improve migration failure error message - Use textContent instead of innerHTML for error messages - Use proper type for longPressTimer instead of any
400 lines
12 KiB
Elixir
400 lines
12 KiB
Elixir
defmodule Aprsme.Cluster.LeaderElection do
|
|
@moduledoc """
|
|
Manages leader election for APRS-IS connection using distributed Erlang.
|
|
Only the elected leader will maintain the APRS-IS connection.
|
|
"""
|
|
use GenServer
|
|
|
|
require Logger
|
|
|
|
@election_key {:aprs_is_leader, __MODULE__}
|
|
@default_check_interval 5_000
|
|
# Maximum time to wait for cluster formation before proceeding with election (30 seconds)
|
|
@default_max_cluster_wait 30_000
|
|
@default_cluster_check_interval 2_000
|
|
|
|
defp check_interval, do: Application.get_env(:aprsme, :election_check_interval_ms, @default_check_interval)
|
|
defp max_cluster_wait, do: Application.get_env(:aprsme, :election_max_cluster_wait_ms, @default_max_cluster_wait)
|
|
|
|
defp cluster_check_interval,
|
|
do: Application.get_env(:aprsme, :election_cluster_check_interval_ms, @default_cluster_check_interval)
|
|
|
|
defstruct is_leader: false,
|
|
leader_node: nil,
|
|
cluster_enabled: false,
|
|
election_forced: false
|
|
|
|
@type t :: %__MODULE__{
|
|
is_leader: boolean(),
|
|
leader_node: node() | nil,
|
|
cluster_enabled: boolean(),
|
|
election_forced: boolean()
|
|
}
|
|
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
def leader? do
|
|
GenServer.call(__MODULE__, :leader?)
|
|
end
|
|
|
|
@doc """
|
|
Fast cached leadership check using :persistent_term.
|
|
No GenServer.call overhead — suitable for hot paths like packet distribution.
|
|
"""
|
|
@spec leader_cached?() :: boolean()
|
|
def leader_cached? do
|
|
:persistent_term.get({__MODULE__, :is_leader}, false)
|
|
end
|
|
|
|
def current_leader do
|
|
GenServer.call(__MODULE__, :current_leader)
|
|
end
|
|
|
|
@doc """
|
|
Gets APRS-IS status from across the entire cluster.
|
|
Returns the status from whichever node has an active connection.
|
|
"""
|
|
def get_cluster_aprs_status do
|
|
fetch_cluster_status(Application.get_env(:aprsme, :cluster_enabled, false))
|
|
end
|
|
|
|
defp fetch_cluster_status(true), do: get_cluster_wide_status()
|
|
defp fetch_cluster_status(false), do: Aprsme.Is.get_status()
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
Logger.info("Starting leader election process")
|
|
cluster_enabled = Application.get_env(:aprsme, :cluster_enabled, false)
|
|
schedule_initial_election(cluster_enabled)
|
|
|
|
# Schedule periodic checks
|
|
Process.send_after(self(), :check_leadership, check_interval())
|
|
|
|
# Initialize cached leadership state
|
|
:persistent_term.put({__MODULE__, :is_leader}, false)
|
|
|
|
{:ok, %__MODULE__{cluster_enabled: cluster_enabled}}
|
|
end
|
|
|
|
# Clustered mode: wait for formation, then elect; schedule a backstop timeout.
|
|
defp schedule_initial_election(true) do
|
|
Logger.info("Clustering enabled - waiting for cluster formation before leader election")
|
|
Process.send_after(self(), :check_cluster_and_elect, cluster_check_interval())
|
|
Process.send_after(self(), :force_election_timeout, max_cluster_wait())
|
|
end
|
|
|
|
# Non-clustered mode: elect immediately.
|
|
defp schedule_initial_election(false) do
|
|
Logger.info("Clustering disabled - proceeding with immediate leader election")
|
|
delay = Application.get_env(:aprsme, :election_initial_delay_ms, 100)
|
|
|
|
if delay == 0,
|
|
do: send(self(), :attempt_election),
|
|
else: Process.send_after(self(), :attempt_election, delay)
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:check_cluster_and_elect, state) do
|
|
# Don't keep checking if election was already forced
|
|
if state.election_forced do
|
|
{:noreply, state}
|
|
else
|
|
connected_nodes = Node.list()
|
|
|
|
if connected_nodes == [] do
|
|
Logger.debug("Cluster not yet formed - waiting...")
|
|
# Check again in 2 seconds
|
|
Process.send_after(self(), :check_cluster_and_elect, 2_000)
|
|
{:noreply, state}
|
|
else
|
|
Logger.info("Cluster formed with #{length(connected_nodes)} other nodes: #{inspect(connected_nodes)}")
|
|
Logger.info("Proceeding with leader election")
|
|
schedule_election_attempt()
|
|
{:noreply, %{state | election_forced: true}}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:force_election_timeout, state) do
|
|
# Only force election if we haven't already started one
|
|
if not state.election_forced and not state.is_leader do
|
|
connected_nodes = Node.list()
|
|
|
|
if connected_nodes == [] do
|
|
Logger.warning(
|
|
"Cluster formation timeout reached after #{max_cluster_wait()}ms with no connected nodes. " <>
|
|
"Proceeding with leader election in single-node mode to ensure APRS-IS connection."
|
|
)
|
|
else
|
|
Logger.info(
|
|
"Forcing leader election after #{max_cluster_wait()}ms wait with #{length(connected_nodes)} connected nodes"
|
|
)
|
|
end
|
|
|
|
schedule_election_attempt()
|
|
{:noreply, %{state | election_forced: true}}
|
|
else
|
|
{:noreply, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:attempt_election, state) do
|
|
cleanup_stale_registrations()
|
|
|
|
case :global.whereis_name(@election_key) do
|
|
pid when pid == self() ->
|
|
_ =
|
|
if !state.is_leader do
|
|
Logger.info("Re-confirming leadership on node #{node()}")
|
|
:persistent_term.put({__MODULE__, :is_leader}, true)
|
|
:ok = notify_leadership_change(true)
|
|
end
|
|
|
|
{:noreply, %{state | is_leader: true, leader_node: node()}}
|
|
|
|
_ ->
|
|
attempt_registration_atomic(state)
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:check_leadership, state) do
|
|
state = verify_leadership(state)
|
|
|
|
# Re-attempt election if we're not leader
|
|
_ =
|
|
if not state.is_leader do
|
|
schedule_election_attempt()
|
|
end
|
|
|
|
# Schedule next check
|
|
_ = Process.send_after(self(), :check_leadership, check_interval())
|
|
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(msg, state) do
|
|
Logger.debug("LeaderElection received unexpected message: #{inspect(msg)}")
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:leader?, _from, state) do
|
|
{:reply, state.is_leader, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:current_leader, _from, state) do
|
|
{:reply, state.leader_node, state}
|
|
end
|
|
|
|
@impl true
|
|
def terminate(reason, state) do
|
|
_ =
|
|
if state.is_leader do
|
|
Logger.info("Leader stepping down due to: #{inspect(reason)}")
|
|
:persistent_term.put({__MODULE__, :is_leader}, false)
|
|
_ = :global.unregister_name(@election_key)
|
|
notify_leadership_change(false)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# Verify that a node which thinks it's leader still holds the :global registration.
|
|
# After :global conflict resolution (e.g. two partitions merging), the losing PID
|
|
# is silently unregistered — this detects that and steps down.
|
|
defp schedule_election_attempt do
|
|
delay = Application.get_env(:aprsme, :election_initial_delay_ms, 100)
|
|
|
|
if delay == 0,
|
|
do: send(self(), :attempt_election),
|
|
else: Process.send_after(self(), :attempt_election, delay)
|
|
end
|
|
|
|
defp verify_leadership(%{is_leader: true} = state) do
|
|
case :global.whereis_name(@election_key) do
|
|
pid when pid == self() ->
|
|
state
|
|
|
|
_ ->
|
|
Logger.warning("Lost global leadership registration, stepping down")
|
|
:persistent_term.put({__MODULE__, :is_leader}, false)
|
|
_ = notify_leadership_change(false)
|
|
%{state | is_leader: false, leader_node: nil}
|
|
end
|
|
end
|
|
|
|
defp verify_leadership(state), do: state
|
|
|
|
defp attempt_registration_atomic(state) do
|
|
result =
|
|
:global.trans({@election_key, node()}, fn ->
|
|
case :global.whereis_name(@election_key) do
|
|
:undefined ->
|
|
:global.register_name(@election_key, self(), &resolve_conflict/3)
|
|
|
|
pid ->
|
|
check_own_registration(pid)
|
|
end
|
|
end)
|
|
|
|
case result do
|
|
:yes ->
|
|
Logger.info("Elected as APRS-IS connection leader on node #{node()}")
|
|
:persistent_term.put({__MODULE__, :is_leader}, true)
|
|
_ = notify_leadership_change(true)
|
|
{:noreply, %{state | is_leader: true, leader_node: node()}}
|
|
|
|
:no ->
|
|
leader_pid = :global.whereis_name(@election_key)
|
|
leader_node = if leader_pid != :undefined and is_pid(leader_pid), do: node(leader_pid)
|
|
:persistent_term.put({__MODULE__, :is_leader}, false)
|
|
{:noreply, %{state | is_leader: false, leader_node: leader_node}}
|
|
end
|
|
end
|
|
|
|
defp check_own_registration(pid) do
|
|
if pid == self(), do: :yes, else: :no
|
|
end
|
|
|
|
# Conflict resolution - prefer the process on the lexicographically lower node
|
|
defp resolve_conflict(_name, pid1, pid2) do
|
|
node1 = node(pid1)
|
|
node2 = node(pid2)
|
|
|
|
Logger.info("Resolving leader conflict between #{node1} and #{node2}")
|
|
|
|
# Choose based on node name ordering for deterministic results
|
|
if node1 <= node2 do
|
|
pid1
|
|
else
|
|
pid2
|
|
end
|
|
end
|
|
|
|
defp cleanup_stale_registrations do
|
|
case :global.whereis_name(@election_key) do
|
|
:undefined ->
|
|
:ok
|
|
|
|
pid when is_pid(pid) ->
|
|
check_and_cleanup_registration(pid)
|
|
end
|
|
end
|
|
|
|
defp check_and_cleanup_registration(pid) do
|
|
pid_node = node(pid)
|
|
connected_nodes = [node() | Node.list()]
|
|
|
|
if pid_node in connected_nodes do
|
|
check_pid_liveness(pid, pid_node)
|
|
else
|
|
cleanup_registration("disconnected node #{pid_node}")
|
|
end
|
|
end
|
|
|
|
defp check_pid_liveness(pid, pid_node) do
|
|
if pid_alive?(pid, pid_node) do
|
|
:ok
|
|
else
|
|
reason = if pid_node == node(), do: "dead local process", else: "dead remote process"
|
|
cleanup_registration("#{reason} #{inspect(pid)}")
|
|
end
|
|
rescue
|
|
_error ->
|
|
cleanup_registration("problematic process #{inspect(pid)}")
|
|
end
|
|
|
|
defp pid_alive?(pid, pid_node) when pid_node == node() do
|
|
Process.alive?(pid)
|
|
end
|
|
|
|
defp pid_alive?(pid, pid_node) do
|
|
case :rpc.call(pid_node, Process, :alive?, [pid], 5000) do
|
|
{:badrpc, _} -> false
|
|
result -> result == true
|
|
end
|
|
end
|
|
|
|
defp cleanup_registration(reason) do
|
|
Logger.info("Cleaning up stale leader registration for #{reason}")
|
|
:global.unregister_name(@election_key)
|
|
end
|
|
|
|
defp get_cluster_wide_status do
|
|
all_nodes = [node() | Node.list()]
|
|
|
|
# Check each node for APRS-IS connection status
|
|
connected_statuses =
|
|
all_nodes
|
|
|> Enum.map(&get_node_status/1)
|
|
|> Enum.filter(fn status -> status.connected end)
|
|
|
|
case connected_statuses do
|
|
[status | _] ->
|
|
# At least one node is connected - return its status
|
|
# Add cluster info to indicate this is cluster-wide status
|
|
Map.put(status, :cluster_info, %{
|
|
total_nodes: length(all_nodes),
|
|
connected_nodes: length(connected_statuses),
|
|
leader_node: get_leader_node_name(),
|
|
all_nodes: all_nodes |> Enum.map(&to_string/1) |> Enum.sort()
|
|
})
|
|
|
|
[] ->
|
|
# No nodes are connected - return local status but mark as cluster-wide
|
|
local_status = Aprsme.Is.get_status()
|
|
|
|
Map.put(local_status, :cluster_info, %{
|
|
total_nodes: length(all_nodes),
|
|
connected_nodes: 0,
|
|
leader_node: get_leader_node_name(),
|
|
all_nodes: all_nodes |> Enum.map(&to_string/1) |> Enum.sort()
|
|
})
|
|
end
|
|
end
|
|
|
|
defp get_node_status(node_name) do
|
|
if node_name == node() do
|
|
# Local node - call directly
|
|
Aprsme.Is.get_status()
|
|
else
|
|
# Remote node - use RPC
|
|
case :rpc.call(node_name, Aprsme.Is, :get_status, [], 5000) do
|
|
{:badrpc, _reason} ->
|
|
# Node unreachable - return disconnected status
|
|
%{connected: false, server: "unreachable", port: 0}
|
|
|
|
status when is_map(status) ->
|
|
status
|
|
|
|
_ ->
|
|
%{connected: false, server: "error", port: 0}
|
|
end
|
|
end
|
|
rescue
|
|
_error ->
|
|
%{connected: false, server: "error", port: 0}
|
|
end
|
|
|
|
defp get_leader_node_name do
|
|
case :global.whereis_name(@election_key) do
|
|
:undefined -> "none"
|
|
pid when is_pid(pid) -> pid |> node() |> to_string()
|
|
end
|
|
end
|
|
|
|
defp notify_leadership_change(became_leader) do
|
|
Phoenix.PubSub.broadcast(
|
|
Aprsme.PubSub,
|
|
"cluster:leadership",
|
|
{:leadership_change, node(), became_leader}
|
|
)
|
|
end
|
|
end
|