aprs.me/lib/aprsme/connection_monitor.ex
Graham McIntire ac51ecaada
Fix multiple TODO bugs: drain handler, callsign dedup, packet pipeline
- Fix drain_connections missing {:noreply, socket} wrapper
- Fix get_callsign_key to match atom keys from Ecto structs
- Add SpatialPubSub.broadcast_packet to PacketDistributor
- Fix pid_alive? treating {:badrpc, _} as truthy
- Fix SpatialPubSub ensure_float crash on integer strings
- Fix inverted memory calculation in ConnectionMonitor
- Remove dead SSL handler from Is module, consolidate into handle_socket_data
- Remove duplicate packet transformation in Is.dispatch
- Remove invalid placeholders option from insert_all calls
- Change PacketPipelineSupervisor to :rest_for_one strategy
- Update TODO.md marking fixed items
2026-02-19 18:27:25 -06:00

247 lines
6.3 KiB
Elixir

defmodule Aprsme.ConnectionMonitor do
@moduledoc """
Monitors system load and LiveView connections to implement connection draining
when load is imbalanced across cluster nodes.
"""
use GenServer
require Logger
@check_interval to_timeout(second: 30)
# Start draining if CPU usage > 70%
@cpu_threshold 0.7
# Drain if this node has 2x more connections than average
@connection_imbalance_ratio 2.0
# Drain 10% of connections when triggered
@drain_percentage 0.1
defstruct [
:node_stats,
:local_connections,
:draining,
:last_check
]
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
# Only start if clustering is enabled
if cluster_enabled?() do
schedule_check()
{:ok,
%__MODULE__{
node_stats: %{},
local_connections: 0,
draining: false,
last_check: System.monotonic_time(:second)
}}
else
:ignore
end
end
@doc """
Register a new LiveView connection
"""
def register_connection do
if cluster_enabled?() do
GenServer.cast(__MODULE__, :register_connection)
end
end
@doc """
Unregister a LiveView connection
"""
def unregister_connection do
if cluster_enabled?() do
GenServer.cast(__MODULE__, :unregister_connection)
end
end
@doc """
Check if this node should accept new connections
"""
def accepting_connections? do
if cluster_enabled?() and Process.whereis(__MODULE__) != nil do
GenServer.call(__MODULE__, :accepting_connections?)
else
true
end
end
@doc """
Get current node statistics
"""
def get_stats do
if cluster_enabled?() do
GenServer.call(__MODULE__, :get_stats)
else
%{connections: 0, cpu: 0.0, memory: 0.0}
end
end
@impl true
def handle_cast(:register_connection, state) do
{:noreply, %{state | local_connections: state.local_connections + 1}}
end
@impl true
def handle_cast(:unregister_connection, state) do
{:noreply, %{state | local_connections: max(0, state.local_connections - 1)}}
end
@impl true
def handle_call(:accepting_connections?, _from, state) do
{:reply, not state.draining, state}
end
@impl true
def handle_call(:get_stats, _from, state) do
stats = %{
connections: state.local_connections,
cpu: get_cpu_usage(),
memory: get_memory_usage(),
draining: state.draining
}
{:reply, stats, state}
end
@impl true
def handle_info(:check_load, state) do
new_state =
state
|> gather_cluster_stats()
|> analyze_load()
|> maybe_trigger_draining()
schedule_check()
{:noreply, new_state}
end
defp gather_cluster_stats(state) do
# Compute local stats directly to avoid deadlock — calling get_stats via
# RPC on Node.self() would GenServer.call back into this process which is
# blocked in handle_info(:check_load, ...).
local_stats = %{
connections: state.local_connections,
cpu: get_cpu_usage(),
memory: get_memory_usage(),
draining: state.draining
}
# Get stats from remote nodes via RPC
remote_stats =
Enum.reduce(Node.list(), %{}, fn remote_node, acc ->
case :rpc.call(remote_node, __MODULE__, :get_stats, []) do
{:badrpc, _} -> acc
stats -> Map.put(acc, remote_node, stats)
end
end)
%{state | node_stats: Map.put(remote_stats, Node.self(), local_stats)}
end
defp analyze_load(state) do
local_stats = Map.get(state.node_stats, Node.self(), %{})
cpu_usage = Map.get(local_stats, :cpu, 0.0)
# Calculate average connections across cluster
total_connections = Enum.sum(for {_, stats} <- state.node_stats, do: Map.get(stats, :connections, 0))
node_count = map_size(state.node_stats)
avg_connections = if node_count > 0, do: total_connections / node_count, else: 0
# Determine if we should be draining
should_drain =
cond do
# High CPU usage
cpu_usage > @cpu_threshold ->
Logger.info("Node #{Node.self()} CPU usage high: #{Float.round(cpu_usage * 100, 1)}%")
true
# Too many connections compared to average
node_count > 1 and state.local_connections > avg_connections * @connection_imbalance_ratio ->
Logger.info(
"Node #{Node.self()} has #{state.local_connections} connections, avg: #{Float.round(avg_connections, 1)}"
)
true
# Otherwise, stop draining if we were
true ->
false
end
%{state | draining: should_drain}
end
defp maybe_trigger_draining(%{draining: true, local_connections: connections} = state) when connections > 0 do
# Calculate how many connections to drain
to_drain = round(connections * @drain_percentage)
# Drain 1-10 connections at a time
to_drain = max(1, min(to_drain, 10))
Logger.info("Draining #{to_drain} connections from node #{Node.self()}")
# Broadcast drain event to LiveViews
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
"connection:drain:#{Node.self()}",
{:drain_connections, to_drain}
)
state
end
defp maybe_trigger_draining(state), do: state
defp get_cpu_usage do
# Get CPU usage from scheduler utilization
# Use Erlang's cpu_sup if available, otherwise estimate from scheduler utilization
case :cpu_sup.util() do
util when is_number(util) ->
util / 100.0
_ ->
# Fallback to scheduler wall time
:scheduler_wall_time_all
|> :erlang.statistics()
|> Enum.map(&calculate_scheduler_utilization/1)
|> Enum.sum()
|> Kernel./(System.schedulers_online())
end
rescue
_ -> 0.0
end
defp calculate_scheduler_utilization({_, active, total}) do
if total > 0, do: active / total, else: 0.0
end
defp get_memory_usage do
mem_data = :erlang.memory()
total = Keyword.get(mem_data, :total, 0)
# processes + system = total; report process memory as fraction of total
processes = Keyword.get(mem_data, :processes, 0)
if total > 0 do
processes / total
else
0.0
end
rescue
_ -> 0.0
end
defp schedule_check do
Process.send_after(self(), :check_load, @check_interval)
end
defp cluster_enabled? do
Application.get_env(:aprsme, :cluster_enabled, false)
end
end