Optimize historical packet loading by selecting only needed columns

Historical map loading previously fetched all 73 columns (SELECT *) from
the packets table (~2068 bytes/row). Added get_recent_packets_for_map/1
with select_map_fields/1 that fetches only the 22 columns needed for map
rendering (~200 bytes/row), yielding a ~3x query speedup.

Also removes unused all_packets tracking from PacketProcessor and
simplifies visible_packets cleanup in Index.
This commit is contained in:
Graham McIntire 2026-02-20 07:41:04 -06:00
parent 1832a8ef04
commit 8755bba9f0
No known key found for this signature in database
6 changed files with 188 additions and 46 deletions

View file

@ -538,6 +538,45 @@ defmodule Aprsme.Packets do
Repo.all(query)
end
@doc """
Gets recent packets for map display with only the columns needed for rendering.
Returns maps (not full Packet structs) to avoid loading all 73 columns.
"""
@spec get_recent_packets_for_map(map()) :: [map()]
def get_recent_packets_for_map(opts \\ %{}) do
hours_back = Map.get(opts, :hours_back, 24)
time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
limit = Map.get(opts, :limit, 200)
offset = Map.get(opts, :offset, 0)
callsign = opts[:callsign]
normalized_callsign = if is_binary(callsign), do: String.trim(callsign), else: ""
base_query =
if normalized_callsign == "" do
from(p in Packet,
where: p.has_position == true,
where: p.received_at >= ^time_ago
)
else
from(p in Packet,
where: p.received_at >= ^time_ago
)
end
bounds = Map.get(opts, :bounds)
base_query
|> QueryBuilder.maybe_filter_region(opts)
|> maybe_filter_by_callsign(opts)
|> maybe_filter_by_bounds(bounds)
|> order_by(desc: :received_at)
|> limit(^limit)
|> offset(^offset)
|> QueryBuilder.select_map_fields()
|> Repo.all()
end
@doc """
Gets the closest stations to a given point.
Uses PostGIS spatial indexes for efficient querying.

View file

@ -96,6 +96,39 @@ defmodule Aprsme.Packets.QueryBuilder do
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
end
@doc """
Selects only the fields needed for map display as a plain map.
Returns ~22 columns instead of all 73, reducing I/O significantly.
"""
@spec select_map_fields(Ecto.Query.t()) :: Ecto.Query.t()
def select_map_fields(query) do
from p in query,
select: %{
id: p.id,
sender: p.sender,
object_name: p.object_name,
item_name: p.item_name,
lat: fragment("?::float8", p.lat),
lon: fragment("?::float8", p.lon),
received_at: p.received_at,
symbol_table_id: p.symbol_table_id,
symbol_code: p.symbol_code,
comment: p.comment,
path: p.path,
temperature: p.temperature,
humidity: p.humidity,
wind_direction: p.wind_direction,
wind_speed: p.wind_speed,
wind_gust: p.wind_gust,
pressure: p.pressure,
rain_1h: p.rain_1h,
rain_24h: p.rain_24h,
rain_since_midnight: p.rain_since_midnight,
snow: p.snow,
luminosity: p.luminosity
}
end
@doc """
Filters by region if provided in options.
"""

View file

@ -231,7 +231,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
params = build_query_params(query_params)
Logger.debug("HistoricalLoader: Querying packets with params: #{inspect(params)}")
recent_packets = Packets.get_recent_packets(params)
recent_packets = Packets.get_recent_packets_for_map(params)
Logger.debug("HistoricalLoader: Got #{length(recent_packets)} packets")
maybe_include_latest_packet(recent_packets, query_params.callsign, batch_offset)

View file

@ -259,7 +259,6 @@ defmodule AprsmeWeb.MapLive.Index do
defp assign_defaults(socket, one_hour_ago) do
assign(socket,
packets: [],
all_packets: %{},
visible_packets: %{},
historical_packets: %{},
page_title: "APRS Map",
@ -330,15 +329,10 @@ defmodule AprsmeWeb.MapLive.Index do
@impl true
def handle_event("clear_and_reload_markers", _params, socket) do
# Get current visible packets from PacketManager
current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state)
# Filter by time and bounds
filtered_packets =
filter_packets_by_time_and_bounds_with_tracked(
Map.new(current_packets, fn packet ->
{get_callsign_key(packet), packet}
end),
socket.assigns.visible_packets,
socket.assigns.map_bounds,
socket.assigns.packet_age_threshold,
socket.assigns.tracked_callsign,
@ -1749,32 +1743,21 @@ defmodule AprsmeWeb.MapLive.Index do
# Schedule next cleanup
Process.send_after(self(), :cleanup_old_packets, 60_000)
# Use the current packet_age_threshold instead of hardcoded one hour
threshold = socket.assigns.packet_age_threshold
# Remove expired packets using PacketManager predicate
updated_packet_state =
PacketManager.remove_packets_where(socket.assigns.packet_state, fn packet ->
SharedPacketUtils.packet_within_time_threshold?(packet, threshold)
# Partition visible_packets into kept and expired
{kept, expired_keys} =
Enum.reduce(socket.assigns.visible_packets, {%{}, []}, fn {key, packet}, {kept_acc, expired_acc} ->
if SharedPacketUtils.packet_within_time_threshold?(packet, threshold) do
{Map.put(kept_acc, key, packet), expired_acc}
else
{kept_acc, [key | expired_acc]}
end
end)
# Get expired packet IDs that need to be removed from client
current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state)
remaining_packets = PacketManager.get_visible_packets(updated_packet_state)
remaining_keys = MapSet.new(remaining_packets, &get_callsign_key/1)
expired_keys =
current_packets
|> Enum.reject(fn current_packet ->
MapSet.member?(remaining_keys, get_callsign_key(current_packet))
end)
|> Enum.map(&get_callsign_key/1)
# Only update the client if there are expired markers
socket = DisplayManager.remove_markers_batch(socket, expired_keys)
{:noreply, assign(socket, packet_state: updated_packet_state)}
{:noreply, assign(socket, :visible_packets, kept)}
end
defp handle_update_time_display(socket) do
@ -2008,11 +1991,8 @@ defmodule AprsmeWeb.MapLive.Index do
)
# Remove out-of-bounds packets and markers immediately
current_packets = PacketManager.get_visible_packets(socket.assigns.packet_state)
current_packets_map = Map.new(current_packets, fn packet -> {get_callsign_key(packet), packet} end)
new_visible_packets = BoundsUtils.filter_packets_by_bounds(current_packets_map, map_bounds)
packets_to_remove = BoundsUtils.reject_packets_by_bounds(current_packets_map, map_bounds)
new_visible_packets = BoundsUtils.filter_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
packets_to_remove = BoundsUtils.reject_packets_by_bounds(socket.assigns.visible_packets, map_bounds)
# Remove markers for out-of-bounds packets
socket = remove_markers_batch(socket, packets_to_remove)

View file

@ -15,7 +15,6 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
alias Phoenix.LiveView.Socket
@max_visible_packets 1000
@max_all_packets 2000
@doc """
Process a packet for display on the map.
@ -25,18 +24,6 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
{lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet)
callsign_key = SharedPacketUtils.get_callsign_key(packet)
# Update all_packets with memory limit
all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet)
all_packets =
if map_size(all_packets) > @max_all_packets do
SharedPacketUtils.prune_oldest_packets(all_packets, @max_all_packets)
else
all_packets
end
socket = assign(socket, :all_packets, all_packets)
# Handle packet visibility logic
socket = handle_packet_visibility(packet, lat, lon, callsign_key, socket)

View file

@ -305,6 +305,109 @@ defmodule Aprsme.PacketsTest do
end
end
describe "get_recent_packets_for_map/1" do
setup do
now = DateTime.utc_now()
PacketsFixtures.packet_fixture(%{
sender: "MAP1",
received_at: DateTime.add(now, -1800, :second),
lat: 39.8,
lon: -98.5,
comment: "test comment",
symbol_table_id: "/",
symbol_code: ">",
temperature: 72.0,
humidity: 45.0
})
PacketsFixtures.packet_fixture(%{
sender: "MAP2",
received_at: DateTime.add(now, -3600, :second),
lat: 39.9,
lon: -98.4,
object_name: "TestObj"
})
PacketsFixtures.packet_fixture(%{
sender: "OLD_MAP",
received_at: DateTime.add(now, -48 * 3600, :second),
lat: 40.0,
lon: -98.3
})
:ok
end
test "returns maps with only needed fields" do
results = Packets.get_recent_packets_for_map(%{hours_back: 24})
assert length(results) >= 2
first = hd(results)
# Should be a map, not a Packet struct
refute is_struct(first, Packet)
assert is_map(first)
# Should have the essential fields
assert Map.has_key?(first, :id)
assert Map.has_key?(first, :sender)
assert Map.has_key?(first, :lat)
assert Map.has_key?(first, :lon)
assert Map.has_key?(first, :received_at)
assert Map.has_key?(first, :symbol_table_id)
assert Map.has_key?(first, :symbol_code)
assert Map.has_key?(first, :comment)
assert Map.has_key?(first, :path)
assert Map.has_key?(first, :object_name)
assert Map.has_key?(first, :item_name)
# Should have weather fields
assert Map.has_key?(first, :temperature)
assert Map.has_key?(first, :humidity)
# Should NOT have heavy fields
refute Map.has_key?(first, :raw_packet)
refute Map.has_key?(first, :information_field)
refute Map.has_key?(first, :body)
refute Map.has_key?(first, :data_extended)
end
test "respects time filtering" do
results = Packets.get_recent_packets_for_map(%{hours_back: 1})
callsigns = Enum.map(results, & &1.sender)
assert "MAP1" in callsigns
refute "OLD_MAP" in callsigns
end
test "respects bounds filtering" do
bounds = [-99.0, 39.5, -98.0, 40.5]
results = Packets.get_recent_packets_for_map(%{hours_back: 24, bounds: bounds})
callsigns = Enum.map(results, & &1.sender)
assert "MAP1" in callsigns
refute "OLD_MAP" in callsigns
end
test "respects limit and offset" do
results = Packets.get_recent_packets_for_map(%{hours_back: 24, limit: 1})
assert length(results) == 1
end
test "filters by callsign" do
results = Packets.get_recent_packets_for_map(%{hours_back: 24, callsign: "MAP1"})
assert length(results) == 1
assert hd(results).sender == "MAP1"
end
test "includes object_name for APRS objects" do
results = Packets.get_recent_packets_for_map(%{hours_back: 24})
map2 = Enum.find(results, &(&1.sender == "MAP2"))
assert map2.object_name == "TestObj"
end
end
describe "get_nearby_stations/4" do
setup do
# Create stations at various distances