Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
475 lines
12 KiB
Elixir
475 lines
12 KiB
Elixir
defmodule Aprsme.SpatialPubSub do
|
|
@moduledoc """
|
|
Spatial-aware PubSub system that broadcasts packets only to clients
|
|
whose viewports contain the packet's location.
|
|
"""
|
|
use GenServer
|
|
|
|
require Logger
|
|
|
|
# Grid size in degrees for spatial indexing
|
|
@grid_size 1.0
|
|
# Maximum number of concurrent clients
|
|
@max_clients 10_000
|
|
|
|
def start_link(opts \\ []) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Register a client with their current viewport bounds.
|
|
"""
|
|
def register_viewport(client_id, bounds) do
|
|
with_server({:error, :not_running}, fn ->
|
|
GenServer.call(__MODULE__, {:register_viewport, client_id, bounds})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Update a client's viewport bounds.
|
|
"""
|
|
def update_viewport(client_id, bounds) do
|
|
with_server({:error, :not_running}, fn ->
|
|
GenServer.call(__MODULE__, {:update_viewport, client_id, bounds})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Unregister a client.
|
|
"""
|
|
def unregister_client(client_id) do
|
|
with_server(:ok, fn ->
|
|
GenServer.cast(__MODULE__, {:unregister_client, client_id})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Broadcast a packet to all clients whose viewports contain the packet's location.
|
|
"""
|
|
def broadcast_packet(packet) do
|
|
with_server(:ok, fn ->
|
|
GenServer.cast(__MODULE__, {:broadcast_packet, packet})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Get statistics about spatial filtering.
|
|
"""
|
|
def get_stats do
|
|
with_server(
|
|
%{
|
|
total_broadcasts: 0,
|
|
filtered_broadcasts: 0,
|
|
total_packets: 0,
|
|
clients_count: 0,
|
|
grid_cells: 0,
|
|
avg_clients_per_cell: 0.0
|
|
},
|
|
fn ->
|
|
GenServer.call(__MODULE__, :get_stats)
|
|
end
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Start telemetry reporting for LiveDashboard integration.
|
|
"""
|
|
def start_telemetry_reporting do
|
|
with_server(:ok, fn ->
|
|
GenServer.cast(__MODULE__, :start_telemetry_reporting)
|
|
end)
|
|
end
|
|
|
|
# Server callbacks
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
# Start telemetry reporting
|
|
Process.send_after(self(), :report_telemetry, 5_000)
|
|
|
|
state = %{
|
|
# client_id => %{bounds: bounds, topic: topic, pid: pid}
|
|
clients: %{},
|
|
# grid_key => MapSet of client_ids
|
|
spatial_index: %{},
|
|
# Statistics
|
|
stats: %{
|
|
total_broadcasts: 0,
|
|
filtered_broadcasts: 0,
|
|
total_packets: 0,
|
|
clients_count: 0
|
|
}
|
|
}
|
|
|
|
{:ok, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:register_viewport, client_id, bounds}, {pid, _}, state) do
|
|
if map_size(state.clients) >= @max_clients do
|
|
{:reply, {:error, :client_limit_exceeded}, state}
|
|
else
|
|
topic = "spatial:#{client_id}"
|
|
|
|
state = replace_existing_client(state, client_id)
|
|
|
|
ref = Process.monitor(pid)
|
|
|
|
client_info = %{
|
|
bounds: normalize_bounds(bounds),
|
|
topic: topic,
|
|
pid: pid,
|
|
monitor_ref: ref
|
|
}
|
|
|
|
new_state =
|
|
state
|
|
|> put_in([:clients, client_id], client_info)
|
|
|> update_spatial_index(client_id, client_info.bounds)
|
|
|> update_in([:stats, :clients_count], &(&1 + 1))
|
|
|
|
{:reply, {:ok, topic}, new_state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:update_viewport, client_id, bounds}, _from, state) do
|
|
case Map.get(state.clients, client_id) do
|
|
nil ->
|
|
{:reply, {:error, :not_registered}, state}
|
|
|
|
client_info ->
|
|
# Remove old spatial index entries
|
|
old_bounds = client_info.bounds
|
|
state = remove_from_spatial_index(state, client_id, old_bounds)
|
|
|
|
# Update with new bounds
|
|
normalized_bounds = normalize_bounds(bounds)
|
|
updated_client = %{client_info | bounds: normalized_bounds}
|
|
|
|
new_state =
|
|
state
|
|
|> put_in([:clients, client_id], updated_client)
|
|
|> update_spatial_index(client_id, normalized_bounds)
|
|
|
|
{:reply, :ok, new_state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_stats, _from, state) do
|
|
stats =
|
|
Map.merge(state.stats, %{
|
|
grid_cells: map_size(state.spatial_index),
|
|
avg_clients_per_cell: calculate_avg_clients_per_cell(state.spatial_index)
|
|
})
|
|
|
|
{:reply, stats, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:unregister_client, client_id}, state) do
|
|
new_state = remove_client(state, client_id)
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:broadcast_packet, packet}, state) do
|
|
state = update_in(state, [:stats, :total_packets], &(&1 + 1))
|
|
|
|
case extract_location(packet) do
|
|
{lat, lon} when is_number(lat) and is_number(lon) ->
|
|
# Find all clients whose viewports contain this location
|
|
client_ids = find_clients_for_location(state, lat, lon)
|
|
|
|
# Optimize: Batch collect topics then spawn async task for broadcasts
|
|
topics =
|
|
client_ids
|
|
|> Enum.map(fn client_id -> Map.get(state.clients, client_id) end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.map(& &1.topic)
|
|
|
|
# Use dedicated broadcast task supervisor for better performance
|
|
_ =
|
|
Aprsme.BroadcastTaskSupervisor.broadcast_async(
|
|
topics,
|
|
{:spatial_packet, packet}
|
|
)
|
|
|
|
# Update statistics (immediately return control to GenServer)
|
|
state =
|
|
state
|
|
|> update_in([:stats, :total_broadcasts], &(&1 + length(client_ids)))
|
|
|> update_in([:stats, :filtered_broadcasts], &(&1 + max(0, map_size(state.clients) - length(client_ids))))
|
|
|
|
{:noreply, state}
|
|
|
|
_ ->
|
|
# No valid location, skip broadcasting
|
|
{:noreply, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
|
|
# Find and remove the client associated with this pid
|
|
client_id =
|
|
Enum.find_value(state.clients, fn {id, %{pid: client_pid}} ->
|
|
if client_pid == pid, do: id
|
|
end)
|
|
|
|
new_state =
|
|
if client_id do
|
|
remove_client(state, client_id)
|
|
else
|
|
state
|
|
end
|
|
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:report_telemetry, state) do
|
|
# Emit telemetry metrics
|
|
emit_telemetry_metrics(state)
|
|
|
|
# Schedule next report
|
|
Process.send_after(self(), :report_telemetry, 5_000)
|
|
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(_, state), do: {:noreply, state}
|
|
|
|
# Private functions
|
|
|
|
defp normalize_bounds(%{north: n, south: s, east: e, west: w}) do
|
|
%{
|
|
north: ensure_float(n),
|
|
south: ensure_float(s),
|
|
east: ensure_float(e),
|
|
west: ensure_float(w)
|
|
}
|
|
end
|
|
|
|
defp ensure_float(val) when is_binary(val) do
|
|
case Float.parse(val) do
|
|
{f, _} -> f
|
|
:error -> 0.0
|
|
end
|
|
end
|
|
|
|
defp ensure_float(val) when is_integer(val), do: val * 1.0
|
|
defp ensure_float(val) when is_float(val), do: val
|
|
defp ensure_float(_), do: 0.0
|
|
|
|
defp update_spatial_index(state, client_id, bounds) do
|
|
# Get all grid cells that intersect with the bounds
|
|
grid_cells = get_intersecting_grid_cells(bounds)
|
|
|
|
# Add client to each grid cell
|
|
Enum.reduce(grid_cells, state, fn grid_key, acc_state ->
|
|
update_in(acc_state, [:spatial_index, grid_key], fn
|
|
nil -> MapSet.new([client_id])
|
|
set -> MapSet.put(set, client_id)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
defp remove_from_spatial_index(state, client_id, bounds) do
|
|
grid_cells = get_intersecting_grid_cells(bounds)
|
|
|
|
Enum.reduce(grid_cells, state, fn grid_key, acc_state ->
|
|
remove_client_from_grid_cell(acc_state, grid_key, client_id)
|
|
end)
|
|
end
|
|
|
|
defp remove_client_from_grid_cell(state, grid_key, client_id) do
|
|
case Map.get(state.spatial_index, grid_key) do
|
|
nil ->
|
|
state
|
|
|
|
set ->
|
|
new_set = MapSet.delete(set, client_id)
|
|
update_grid_cell_after_removal(state, grid_key, new_set)
|
|
end
|
|
end
|
|
|
|
defp update_grid_cell_after_removal(state, grid_key, new_set) do
|
|
if MapSet.size(new_set) == 0 do
|
|
update_in(state, [:spatial_index], &Map.delete(&1, grid_key))
|
|
else
|
|
put_in(state, [:spatial_index, grid_key], new_set)
|
|
end
|
|
end
|
|
|
|
defp get_intersecting_grid_cells(%{north: n, south: s, east: e, west: w}) do
|
|
min_lat_cell = floor(s / @grid_size)
|
|
max_lat_cell = floor(n / @grid_size)
|
|
min_lon_cell = floor(w / @grid_size)
|
|
max_lon_cell = floor(e / @grid_size)
|
|
|
|
lon_cells =
|
|
if min_lon_cell > max_lon_cell do
|
|
max_positive = floor(180.0 / @grid_size)
|
|
min_negative = floor(-180.0 / @grid_size)
|
|
Enum.to_list(min_lon_cell..max_positive) ++ Enum.to_list(min_negative..max_lon_cell)
|
|
else
|
|
Enum.to_list(min_lon_cell..max_lon_cell)
|
|
end
|
|
|
|
for lat_cell <- min_lat_cell..max_lat_cell,
|
|
lon_cell <- lon_cells do
|
|
{lat_cell, lon_cell}
|
|
end
|
|
end
|
|
|
|
defp find_clients_for_location(state, lat, lon) do
|
|
grid_key = {floor(lat / @grid_size), floor(lon / @grid_size)}
|
|
|
|
case Map.get(state.spatial_index, grid_key) do
|
|
nil ->
|
|
[]
|
|
|
|
client_set ->
|
|
filter_clients_by_bounds(state, client_set, lat, lon)
|
|
end
|
|
end
|
|
|
|
defp filter_clients_by_bounds(state, client_set, lat, lon) do
|
|
Enum.filter(client_set, fn client_id ->
|
|
client_contains_point?(state, client_id, lat, lon)
|
|
end)
|
|
end
|
|
|
|
defp client_contains_point?(state, client_id, lat, lon) do
|
|
case Map.get(state.clients, client_id) do
|
|
%{bounds: bounds} -> point_in_bounds?(lat, lon, bounds)
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp point_in_bounds?(lat, lon, %{north: n, south: s, east: e, west: w}) do
|
|
lat_in_bounds = lat >= s and lat <= n
|
|
|
|
# Handle longitude wrap-around at international date line
|
|
lon_in_bounds =
|
|
if w > e do
|
|
# Bounds cross the date line
|
|
lon >= w or lon <= e
|
|
else
|
|
# Normal bounds
|
|
lon >= w and lon <= e
|
|
end
|
|
|
|
lat_in_bounds and lon_in_bounds
|
|
end
|
|
|
|
defp extract_location(packet) do
|
|
case packet do
|
|
%{lat: lat, lon: lon} when not is_nil(lat) and not is_nil(lon) ->
|
|
{ensure_float(lat), ensure_float(lon)}
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp remove_client(state, client_id) do
|
|
case Map.get(state.clients, client_id) do
|
|
nil ->
|
|
state
|
|
|
|
%{bounds: bounds, monitor_ref: ref} ->
|
|
Process.demonitor(ref, [:flush])
|
|
|
|
state
|
|
|> remove_from_spatial_index(client_id, bounds)
|
|
|> update_in([:clients], &Map.delete(&1, client_id))
|
|
|> update_in([:stats, :clients_count], &max(0, &1 - 1))
|
|
|
|
%{bounds: bounds} ->
|
|
state
|
|
|> remove_from_spatial_index(client_id, bounds)
|
|
|> update_in([:clients], &Map.delete(&1, client_id))
|
|
|> update_in([:stats, :clients_count], &max(0, &1 - 1))
|
|
end
|
|
end
|
|
|
|
defp replace_existing_client(state, client_id) do
|
|
case Map.get(state.clients, client_id) do
|
|
nil -> state
|
|
_client_info -> remove_client(state, client_id)
|
|
end
|
|
end
|
|
|
|
defp calculate_avg_clients_per_cell(spatial_index) do
|
|
non_nil_cells =
|
|
spatial_index
|
|
|> Map.values()
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
if Enum.empty?(non_nil_cells) do
|
|
0.0
|
|
else
|
|
total_clients =
|
|
non_nil_cells
|
|
|> Enum.map(&MapSet.size/1)
|
|
|> Enum.sum()
|
|
|
|
total_clients / length(non_nil_cells)
|
|
end
|
|
end
|
|
|
|
defp emit_telemetry_metrics(state) do
|
|
# Client metrics
|
|
:telemetry.execute(
|
|
[:aprsme, :spatial_pubsub, :clients],
|
|
%{
|
|
count: state.stats.clients_count,
|
|
grid_cells: map_size(state.spatial_index),
|
|
avg_clients_per_cell: calculate_avg_clients_per_cell(state.spatial_index)
|
|
},
|
|
%{}
|
|
)
|
|
|
|
# Broadcast metrics
|
|
:telemetry.execute(
|
|
[:aprsme, :spatial_pubsub, :broadcasts],
|
|
%{
|
|
total: state.stats.total_broadcasts,
|
|
filtered: state.stats.filtered_broadcasts,
|
|
packets: state.stats.total_packets
|
|
},
|
|
%{}
|
|
)
|
|
|
|
# Calculate and emit efficiency metrics
|
|
total_potential_broadcasts = state.stats.total_packets * state.stats.clients_count
|
|
|
|
efficiency =
|
|
if total_potential_broadcasts > 0 do
|
|
1.0 - state.stats.total_broadcasts / total_potential_broadcasts
|
|
else
|
|
0.0
|
|
end
|
|
|
|
:telemetry.execute(
|
|
[:aprsme, :spatial_pubsub, :efficiency],
|
|
%{
|
|
ratio: efficiency,
|
|
saved_broadcasts: state.stats.filtered_broadcasts
|
|
},
|
|
%{}
|
|
)
|
|
end
|
|
|
|
defp with_server(default, fun) do
|
|
if Process.whereis(__MODULE__) do
|
|
fun.()
|
|
else
|
|
default
|
|
end
|
|
end
|
|
end
|