aprs.me/lib/aprsme/connection_monitor.ex
Graham McIntire 48bc27e43c
fix: Resolve majority of dialyzer type errors
- Fix leader election guard test for :undefined pid
- Fix cpu_sup.util() pattern matching to handle numeric return
- Fix encoding_utils to always return explicit nil when needed
- Remove unused finite_float? function
- Fix signal handler to match :ok return from :os.set_signal/2
- Remove redundant catch-all pattern in database metrics
- Fix Gridsquare pattern matching in info_live template
- Remove unnecessary nil check for calculate_course result
- Fix get_packet_received_at to not check for nil (always returns DateTime)
- Remove redundant catch-all pattern in weather format_weather_value
- Fix query builder to use from(p in Packet) instead of bare Packet atom

Reduced dialyzer errors from 49 to 6. Remaining warnings are mostly
false positives from template compilation and overloaded function specs.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 13:30:21 -05:00

239 lines
5.8 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?() 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
# Get stats from all nodes
nodes = [Node.self() | Node.list()]
node_stats =
Enum.reduce(nodes, %{}, fn node, acc ->
stats = :rpc.call(node, __MODULE__, :get_stats, [])
case stats do
{:badrpc, _} -> acc
stats -> Map.put(acc, node, stats)
end
end)
%{state | node_stats: node_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(fn {_, active, total} ->
if total > 0, do: active / total, else: 0.0
end)
|> Enum.sum()
|> Kernel./(System.schedulers_online())
end
rescue
_ -> 0.0
end
defp get_memory_usage do
# Get memory usage as a percentage
mem_data = :erlang.memory()
total = Keyword.get(mem_data, :total, 0)
system = Keyword.get(mem_data, :system, 0)
if system > 0 do
total / system
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