aprs.me/lib/aprsme/packets/prepared_queries.ex
Graham McIntire ff3a478bbd
perf: add LIMIT to unbounded DISTINCT ON, guard device table scan, add DB indexes
Bound batch DISTINCT ON queries by input count. Guard Repo.all(Devices) with exists? check. Add trigram GIN index on base_callsign and (has_weather, received_at DESC) partial index.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 16:49:48 -05:00

351 lines
12 KiB
Elixir

defmodule Aprsme.Packets.PreparedQueries do
@moduledoc """
High-performance queries using prepared statements and efficient patterns.
These queries are designed for frequently executed operations.
"""
import Ecto.Query
alias Aprsme.Packet
alias Aprsme.Repo
@doc """
Get the latest packet for a callsign using a prepared statement.
This is one of the most frequently called queries.
Searches by sender, object_name, or item_name.
Bounded by the configured `:packet_retention_days` so PostgreSQL can prune
unrelated daily partitions. Data older than retention is already cleaned up
by `Aprsme.Workers.PacketCleanupWorker`, so this bound does not exclude any
packets that would otherwise exist.
"""
@spec get_latest_packet_for_callsign(String.t()) :: Packet.t() | nil
def get_latest_packet_for_callsign(callsign) when is_binary(callsign) do
normalized = String.upcase(String.trim(callsign))
cutoff = retention_cutoff()
Repo.one(
from(p in Packet,
where: p.received_at >= ^cutoff,
where:
fragment("upper(?)", p.sender) == ^normalized or
fragment("upper(?)", p.object_name) == ^normalized or
fragment("upper(?)", p.item_name) == ^normalized,
order_by: [
desc: fragment("CASE WHEN ? IS NULL THEN 0 ELSE 1 END", p.location),
desc: p.received_at
],
limit: 1,
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
)
)
end
defp retention_cutoff do
retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
DateTime.add(DateTime.utc_now(), -retention_days * 86_400, :second)
end
@doc """
Get the latest full packet for each callsign in a single query.
Returns a list of `Packet` structs with lat/lon populated.
Uses DISTINCT ON to get only the most recent packet per callsign.
"""
@spec get_latest_packets_for_callsigns(list(String.t())) :: [Packet.t()]
def get_latest_packets_for_callsigns([]), do: []
def get_latest_packets_for_callsigns(callsigns) when is_list(callsigns) do
normalized = Enum.map(callsigns, &String.upcase(String.trim(&1)))
limit = length(callsigns)
Repo.all(
from(p in Packet,
where: fragment("upper(?)", p.sender) in ^normalized,
distinct: fragment("upper(?)", p.sender),
order_by: [asc: fragment("upper(?)", p.sender), desc: p.received_at],
limit: ^limit,
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
)
)
end
@doc """
Get latest positions for multiple callsigns in a single query.
Returns a list of `%{callsign: String.t(), lat: float(), lng: float()}` maps.
Uses DISTINCT ON to get only the most recent packet per callsign.
"""
@spec get_latest_positions_for_callsigns(list(String.t())) :: [map()]
def get_latest_positions_for_callsigns([]), do: []
def get_latest_positions_for_callsigns(callsigns) when is_list(callsigns) do
normalized = Enum.map(callsigns, &String.upcase(String.trim(&1)))
limit = length(callsigns)
Repo.all(
from(p in Packet,
where: fragment("upper(?)", p.sender) in ^normalized,
where: not is_nil(p.location),
distinct: fragment("upper(?)", p.sender),
order_by: [asc: fragment("upper(?)", p.sender), desc: p.received_at],
limit: ^limit,
select: %{callsign: p.sender, lat: fragment("ST_Y(?)", p.location), lng: fragment("ST_X(?)", p.location)}
)
)
end
@doc """
Get recent packets within bounds using prepared statement.
"""
@spec get_recent_packets_in_bounds(list(), integer()) :: [Packet.t()]
def get_recent_packets_in_bounds([north, south, east, west] = _bounds, hours_back \\ 1) do
time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
Repo.all(
from(p in Packet,
where: p.has_position == true,
where: p.received_at >= ^time_ago,
# Use the && operator with ST_MakeEnvelope so PostGIS can hit the GIST
# index on `location`. ST_Y / ST_X in the WHERE clause force a seq scan.
where: fragment("? && ST_MakeEnvelope(?, ?, ?, ?, 4326)", p.location, ^west, ^south, ^east, ^north),
order_by: [desc: p.received_at],
limit: 500,
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
)
)
end
@doc """
Check if callsign has weather packets using prepared statement.
"""
@spec has_weather_packets?(String.t()) :: boolean()
def has_weather_packets?(callsign) when is_binary(callsign) do
normalized = String.upcase(String.trim(callsign))
query =
from(p in Packet,
where: fragment("upper(?)", p.sender) == ^normalized,
where:
not is_nil(p.temperature) or
not is_nil(p.humidity) or
not is_nil(p.pressure) or
not is_nil(p.wind_speed),
limit: 1,
select: true
)
Repo.exists?(query)
end
@doc """
Check which callsigns from a list have weather packets.
Returns a MapSet of callsigns (uppercased) that have weather data.
Single query replaces N individual has_weather_packets? calls.
"""
@spec weather_callsigns(list(String.t())) :: MapSet.t(String.t())
def weather_callsigns(callsigns) when is_list(callsigns) do
if callsigns == [] do
MapSet.new()
else
normalized = Enum.map(callsigns, &String.upcase(String.trim(&1)))
from(p in Packet,
where: fragment("upper(?)", p.sender) in ^normalized,
where: p.has_weather == true,
distinct: fragment("upper(?)", p.sender),
select: fragment("upper(?)", p.sender)
)
|> Repo.all()
|> MapSet.new()
end
end
@doc """
Get nearby stations using KNN (K-nearest neighbors) search.
Uses the <-> operator for efficient spatial queries.
Options:
* `:limit` - max number of stations to return (default 10)
* `:hours_back` - only consider packets received in the last N hours (default 1)
* `:max_radius_km` - only consider stations within N kilometers of the point
(default 500 km / ~310 mi). This bound lets PostgreSQL pre-filter via
the GiST geography index before the DISTINCT ON sort, which is otherwise
the hot spot in this query.
"""
@spec get_nearby_stations_knn(float(), float(), String.t() | nil, map()) :: [map()]
def get_nearby_stations_knn(lat, lon, exclude_callsign \\ nil, opts \\ %{}) do
limit = Map.get(opts, :limit, 10)
hours_back = Map.get(opts, :hours_back, 1)
max_radius_meters = Map.get(opts, :max_radius_km, 500) * 1000
cutoff_time = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
# Build point for KNN search
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
base_query =
from(p in Packet,
where: p.has_position == true,
where: p.received_at >= ^cutoff_time,
where: not is_nil(p.location),
where: fragment("ST_DWithin(?::geography, ?::geography, ?)", p.location, ^point, ^max_radius_meters),
distinct: p.base_callsign,
order_by: [
asc: p.base_callsign,
desc: p.received_at
]
)
query =
if exclude_callsign do
from(p in base_query,
where: p.sender != ^exclude_callsign
)
else
base_query
end
# Subquery to get most recent packet per callsign, then order by distance
subquery =
from(p in subquery(query),
order_by: fragment("? <-> ?", p.location, ^point),
limit: ^limit,
select: %{
callsign: p.sender,
base_callsign: p.base_callsign,
lat: fragment("ST_Y(?)", p.location),
lon: fragment("ST_X(?)", p.location),
distance: fragment("ST_Distance(?::geography, ?::geography)", p.location, ^point),
received_at: p.received_at,
symbol_table_id: p.symbol_table_id,
symbol_code: p.symbol_code,
comment: p.comment
}
)
Repo.all(subquery)
end
@doc """
Get packet count for an area using prepared statement.
"""
@spec get_packet_count_in_area(list(), integer()) :: non_neg_integer()
def get_packet_count_in_area([north, south, east, west], hours_back \\ 24) do
time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
query =
from(p in Packet,
where: p.has_position == true,
where: p.received_at >= ^time_ago,
where: fragment("? && ST_MakeEnvelope(?, ?, ?, ?, 4326)", p.location, ^west, ^south, ^east, ^north),
select: count(p.id)
)
Repo.one(query) || 0
end
@doc """
Get nearby weather stations within a radius.
Returns weather stations within the specified radius (in miles) that have reported
weather data within the time window. Results are ordered by distance (closest first)
and deduplicated by base_callsign to avoid duplicate SSIDs.
## Parameters
* `lat` - Latitude of center point
* `lon` - Longitude of center point
* `radius_miles` - Search radius in miles
* `opts` - Options keyword list
* `:hours` - Time window in hours (default: 6)
* `:limit` - Maximum number of results (default: 50)
## Returns
List of maps with fields:
* `callsign` - Station callsign with SSID
* `base_callsign` - Base callsign without SSID
* `lat` - Latitude
* `lon` - Longitude
* `distance_miles` - Distance from center point in miles
* `temperature` - Temperature in Fahrenheit (may be nil)
* `humidity` - Humidity percentage (may be nil)
* `pressure` - Atmospheric pressure in hPa (may be nil)
* `wind_speed` - Wind speed in MPH (may be nil)
* `wind_direction` - Wind direction in degrees (may be nil)
* `wind_gust` - Wind gust in MPH (may be nil)
* `rain_1h` - Rain in last hour in inches (may be nil)
* `rain_24h` - Rain in last 24 hours in inches (may be nil)
* `rain_since_midnight` - Rain since midnight in inches (may be nil)
* `symbol_table_id` - APRS symbol table ID
* `symbol_code` - APRS symbol code
* `comment` - Station comment
* `received_at` - Time packet was received
"""
@spec get_nearby_weather_stations(float(), float(), float(), keyword()) :: [map()]
def get_nearby_weather_stations(lat, lon, radius_miles, opts \\ []) do
hours = Keyword.get(opts, :hours, 6)
limit = Keyword.get(opts, :limit, 50)
# Convert miles to meters for PostGIS (1 mile = 1609.34 meters)
radius_meters = radius_miles * 1609.34
cutoff_time =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> DateTime.add(-hours * 3600, :second)
# Build point for spatial query
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
# Query: Find weather stations within radius and time window
# Use DISTINCT ON base_callsign to deduplicate SSIDs (gets most recent)
query =
from(p in Packet,
where: p.has_weather == true,
where: p.has_position == true,
where: p.received_at >= ^cutoff_time,
where:
fragment(
"ST_DWithin(?::geography, ?::geography, ?)",
p.location,
^point,
^radius_meters
),
distinct: p.base_callsign,
order_by: [asc: p.base_callsign, desc: p.received_at]
)
# Subquery to get most recent per base_callsign, then order by distance
subquery =
from(p in subquery(query),
order_by: fragment("ST_Distance(?::geography, ?::geography)", p.location, ^point),
limit: ^limit,
select: %{
callsign: p.sender,
base_callsign: p.base_callsign,
lat: fragment("ST_Y(?)", p.location),
lon: fragment("ST_X(?)", p.location),
distance_miles:
fragment(
"ST_Distance(?::geography, ?::geography) / 1609.34",
p.location,
^point
),
temperature: p.temperature,
humidity: p.humidity,
pressure: p.pressure,
wind_speed: p.wind_speed,
wind_direction: p.wind_direction,
wind_gust: p.wind_gust,
rain_1h: p.rain_1h,
rain_24h: p.rain_24h,
rain_since_midnight: p.rain_since_midnight,
symbol_table_id: p.symbol_table_id,
symbol_code: p.symbol_code,
comment: p.comment,
received_at: p.received_at
}
)
Repo.all(subquery)
end
end