perf(packets): keyset-based pagination for recent-packet queries

get_recent_packets/1 and get_recent_packets_for_map/1 now accept a
:cursor option (%{received_at: DateTime, id: uuid}) instead of :offset
for deep paging. On the 100M-row partitioned packets table, offset
scanning was linear in page depth; keyset is constant.

Ordering tightened to (received_at DESC, id DESC) so the cursor is
stable across ties. idx_packets_received_desc still covers the lookup;
a dedicated (received_at DESC, id DESC) index isn't worth its size
given microsecond timestamp granularity.

historical_loader migrated from batch_offset*batch_size scheduling to
a sequential cursor chain. The old loader fanned out N Process.send_after
batches up-front which could arrive out-of-order; the cursor version
waits for batch N before scheduling N+1, and also short-circuits the
remaining schedule when an under-full batch signals exhaustion.

mobile_channel load_historical_packets dropped its meaningless
`offset: 0`; callsign history already lacked offset.

No LiveView or WebSocket payload shapes changed. offset: 0 (or omitted)
still behaves as first page; non-zero offsets are silently ignored.
This commit is contained in:
Graham McIntire 2026-04-21 10:14:59 -05:00
parent ead0be4434
commit f225f57044
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 128 additions and 92 deletions

View file

@ -484,21 +484,49 @@ defmodule Aprsme.Packets do
@doc """ @doc """
Gets recent packets for initial map load. Gets recent packets for initial map load.
This uses an efficient query pattern for the most common use case. This uses an efficient query pattern for the most common use case.
Ordering is `received_at DESC, id DESC`. Pagination is keyset/cursor-based:
* `:cursor` - Optional `%{received_at: DateTime.t(), id: Ecto.UUID.t()}` marking
the last row from the previous page. When present, only rows strictly older
than the cursor are returned. When absent, the first page is returned.
* `:limit` - Maximum number of packets to return (default 200).
Offset-based pagination is no longer supported; callers doing deep paging must
thread `{received_at, id}` from the last returned packet back in as `:cursor`.
Passing `offset: 0` (or omitting it) is still accepted and behaves as "first page".
""" """
@impl true @impl true
@spec get_recent_packets(map()) :: [struct()] @spec get_recent_packets(map()) :: [struct()]
def get_recent_packets(opts \\ %{}) do def get_recent_packets(opts \\ %{}) do
require Logger opts
|> build_recent_packets_query()
|> QueryBuilder.with_coordinates()
|> Repo.all()
end
# Use hours_back from opts if provided, otherwise default to 24 hours @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.
Pagination is cursor/keyset-based see `get_recent_packets/1`.
"""
@spec get_recent_packets_for_map(map()) :: [map()]
def get_recent_packets_for_map(opts \\ %{}) do
opts
|> build_recent_packets_query()
|> QueryBuilder.select_map_fields()
|> Repo.all()
end
# Shared query construction for get_recent_packets/1 and get_recent_packets_for_map/1.
# Applies time filter, position/callsign/bounds/region filters, cursor keyset,
# and ordering/limit. The two public functions differ only in their select shape.
defp build_recent_packets_query(opts) do
hours_back = Map.get(opts, :hours_back, 24) hours_back = Map.get(opts, :hours_back, 24)
time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second) time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
limit = Map.get(opts, :limit, 200) limit = Map.get(opts, :limit, 200)
offset = Map.get(opts, :offset, 0)
# Build base query with time filter
# Only filter by position if not tracking a specific callsign
# Also normalize callsign to handle edge cases
callsign = opts[:callsign] callsign = opts[:callsign]
normalized_callsign = if is_binary(callsign), do: String.trim(callsign), else: "" normalized_callsign = if is_binary(callsign), do: String.trim(callsign), else: ""
@ -516,62 +544,29 @@ defmodule Aprsme.Packets do
) )
end end
# Apply spatial and other filters BEFORE limiting
# This ensures we get the most recent packets within the specified bounds
bounds = Map.get(opts, :bounds)
query =
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.with_coordinates()
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) bounds = Map.get(opts, :bounds)
base_query base_query
|> QueryBuilder.maybe_filter_region(opts) |> QueryBuilder.maybe_filter_region(opts)
|> maybe_filter_by_callsign(opts) |> maybe_filter_by_callsign(opts)
|> maybe_filter_by_bounds(bounds) |> maybe_filter_by_bounds(bounds)
|> order_by(desc: :received_at) |> maybe_apply_cursor(Map.get(opts, :cursor))
|> order_by([p], desc: p.received_at, desc: p.id)
|> limit(^limit) |> limit(^limit)
|> offset(^offset)
|> QueryBuilder.select_map_fields()
|> Repo.all()
end end
# Keyset pagination: only rows strictly older than the cursor in the
# (received_at DESC, id DESC) ordering.
defp maybe_apply_cursor(query, %{received_at: %DateTime{} = cursor_received_at, id: cursor_id})
when is_binary(cursor_id) do
from p in query,
where:
p.received_at < ^cursor_received_at or
(p.received_at == ^cursor_received_at and p.id < ^cursor_id)
end
defp maybe_apply_cursor(query, _), do: query
@doc """ @doc """
Gets the closest stations to a given point. Gets the closest stations to a given point.
Uses PostGIS spatial indexes for efficient querying. Uses PostGIS spatial indexes for efficient querying.

View file

@ -439,7 +439,6 @@ defmodule AprsmeWeb.MobileChannel do
params = %{ params = %{
bounds: bounds_list, bounds: bounds_list,
limit: limit, limit: limit,
offset: 0,
hours_back: hours_back hours_back: hours_back
} }

View file

@ -69,29 +69,23 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
|> assign(:total_batches, 1) |> assign(:total_batches, 1)
|> assign(:historical_loading, true) |> assign(:historical_loading, true)
|> assign(:loading_generation, new_generation) |> assign(:loading_generation, new_generation)
|> assign(:historical_cursor, nil)
|> load_historical_batch(0, new_generation) |> load_historical_batch(0, new_generation)
else else
# Low zoom - use progressive loading to prevent overwhelming # Low zoom - use progressive loading to prevent overwhelming.
# Batches are keyset-paginated via :historical_cursor, so they MUST run
# sequentially — each batch's cursor comes from the tail of the previous
# batch. Subsequent batches are scheduled from process_loaded_packets/4
# (and the empty-batch branch) after the previous batch completes.
total_batches = calculate_batch_count_for_zoom(zoom) total_batches = calculate_batch_count_for_zoom(zoom)
# Start with first batch
socket =
socket
|> assign(:loading_batch, 0)
|> assign(:total_batches, total_batches)
|> assign(:historical_loading, true)
|> assign(:loading_generation, new_generation)
|> load_historical_batch(0, new_generation)
# Schedule remaining batches with generation check
batch_refs =
Enum.map(1..(total_batches - 1), fn batch_index ->
Process.send_after(self(), {:load_historical_batch, batch_index, new_generation}, batch_index * 50)
end)
socket = assign(socket, :pending_batch_tasks, batch_refs)
socket socket
|> assign(:loading_batch, 0)
|> assign(:total_batches, total_batches)
|> assign(:historical_loading, true)
|> assign(:loading_generation, new_generation)
|> assign(:historical_cursor, nil)
|> load_historical_batch(0, new_generation)
end end
end end
@ -149,22 +143,26 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
# Calculate zoom-based batch size - higher zoom = smaller batches for faster response # Calculate zoom-based batch size - higher zoom = smaller batches for faster response
zoom = socket.assigns.map_zoom || 5 zoom = socket.assigns.map_zoom || 5
batch_size = calculate_batch_size_for_zoom(zoom) batch_size = calculate_batch_size_for_zoom(zoom)
offset = batch_offset * batch_size
# Track cumulative packets loaded so far (batch_offset * batch_size is the
# pre-keyset approximation; we stop when we've fetched at least
# max_packets_for_zoom rows.)
packets_so_far = batch_offset * batch_size
# Get total packet limit based on zoom level # Get total packet limit based on zoom level
max_packets_for_zoom = get_packet_limit_for_zoom(zoom) max_packets_for_zoom = get_packet_limit_for_zoom(zoom)
# Check if we've reached the zoom-based limit # Check if we've reached the zoom-based limit
if offset >= max_packets_for_zoom do if packets_so_far >= max_packets_for_zoom do
finish_historical_loading(socket) finish_historical_loading(socket)
else else
# Adjust batch size if it would exceed the limit # Adjust batch size if it would exceed the limit
adjusted_batch_size = min(batch_size, max_packets_for_zoom - offset) adjusted_batch_size = min(batch_size, max_packets_for_zoom - packets_so_far)
query_params = %{ query_params = %{
bounds: bounds_list, bounds: bounds_list,
limit: adjusted_batch_size, limit: adjusted_batch_size,
offset: offset, cursor: socket.assigns[:historical_cursor],
zoom: zoom, zoom: zoom,
callsign: socket.assigns.tracked_callsign, callsign: socket.assigns.tracked_callsign,
hours_back: socket.assigns.historical_hours || "1" hours_back: socket.assigns.historical_hours || "1"
@ -197,7 +195,15 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
# If tracking a callsign and this is the first batch, also load RF path stations # If tracking a callsign and this is the first batch, also load RF path stations
socket = maybe_load_rf_path_stations(socket, batch_offset, historical_packets) socket = maybe_load_rf_path_stations(socket, batch_offset, historical_packets)
process_loaded_packets(socket, historical_packets, packet_data_list, batch_offset) # Advance the cursor to the last packet returned (packets are ordered
# received_at DESC, id DESC, so the tail is the next page's boundary).
socket = advance_historical_cursor(socket, historical_packets)
# Signal "there may be more" when the batch was fully saturated; an
# under-full batch means we've drained the result set for this window.
has_more = length(historical_packets) >= adjusted_batch_size
process_loaded_packets(socket, historical_packets, packet_data_list, batch_offset, has_more)
else else
# No packets found - handle this as a completed batch to prevent infinite loading # No packets found - handle this as a completed batch to prevent infinite loading
require Logger require Logger
@ -206,21 +212,40 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
"No historical packets found for batch #{batch_offset}, hours_back: #{Map.get(socket.assigns, :historical_hours, "1")}, callsign: #{Map.get(socket.assigns, :tracked_callsign, "")}" "No historical packets found for batch #{batch_offset}, hours_back: #{Map.get(socket.assigns, :historical_hours, "1")}, callsign: #{Map.get(socket.assigns, :tracked_callsign, "")}"
) )
total_batches = socket.assigns.total_batches || 1 # Empty batch → we've drained the window; stop regardless of batch count.
is_final_batch = batch_offset >= total_batches - 1 socket =
socket
|> assign(:loading_batch, batch_offset + 1)
|> update_loading_status(true)
# Update progress for user feedback # Process any pending bounds update
socket = assign(socket, :loading_batch, batch_offset + 1) handle_pending_bounds(socket, true)
# Mark loading as complete if this was the final batch or if we're doing single-batch loading
socket = update_loading_status(socket, is_final_batch)
# Process any pending bounds update if loading is complete
handle_pending_bounds(socket, is_final_batch)
end end
end end
end end
# Extract the next keyset cursor from the last packet in the list.
defp advance_historical_cursor(socket, packets) do
case List.last(packets) do
nil ->
socket
last ->
received_at = get_cursor_field(last, :received_at)
id = get_cursor_field(last, :id)
if is_struct(received_at, DateTime) and is_binary(id) do
assign(socket, :historical_cursor, %{received_at: received_at, id: id})
else
socket
end
end
end
defp get_cursor_field(packet, key) when is_map(packet) do
Map.get(packet, key) || Map.get(packet, to_string(key))
end
defp query_historical_packets(query_params, batch_offset) do defp query_historical_packets(query_params, batch_offset) do
require Logger require Logger
@ -240,7 +265,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
packets_module.get_recent_packets(%{ packets_module.get_recent_packets(%{
bounds: query_params.bounds, bounds: query_params.bounds,
limit: query_params.limit, limit: query_params.limit,
offset: query_params.offset cursor: query_params.cursor
}) })
end end
rescue rescue
@ -257,7 +282,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
params = %{ params = %{
bounds: query_params.bounds, bounds: query_params.bounds,
limit: query_params.limit, limit: query_params.limit,
offset: query_params.offset, cursor: query_params.cursor,
zoom: query_params.zoom, zoom: query_params.zoom,
hours_back: historical_hours hours_back: historical_hours
} }
@ -283,12 +308,15 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
defp maybe_include_latest_packet(recent_packets, _callsign, _batch_offset), do: recent_packets defp maybe_include_latest_packet(recent_packets, _callsign, _batch_offset), do: recent_packets
defp process_loaded_packets(socket, _historical_packets, [], _batch_offset), do: socket defp process_loaded_packets(socket, _historical_packets, [], _batch_offset, _has_more), do: socket
defp process_loaded_packets(socket, historical_packets, packet_data_list, batch_offset) do defp process_loaded_packets(socket, historical_packets, packet_data_list, batch_offset, has_more) do
# Check zoom level to decide between heat map and markers # Check zoom level to decide between heat map and markers
total_batches = socket.assigns.total_batches || 4 total_batches = socket.assigns.total_batches || 4
is_final_batch = batch_offset >= total_batches - 1
# A batch is final when: we've hit the configured batch count, or the
# result set has been fully drained (has_more = false).
is_final_batch = batch_offset >= total_batches - 1 or not has_more
socket = handle_zoom_based_display(socket, historical_packets, packet_data_list, is_final_batch, batch_offset) socket = handle_zoom_based_display(socket, historical_packets, packet_data_list, is_final_batch, batch_offset)
@ -298,10 +326,24 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do
# Mark loading as complete if this was the final batch # Mark loading as complete if this was the final batch
socket = update_loading_status(socket, is_final_batch) socket = update_loading_status(socket, is_final_batch)
# Schedule the next batch if there's more to load. Batches run sequentially
# because each inherits the :historical_cursor advanced by the previous one.
socket = maybe_schedule_next_batch(socket, batch_offset, is_final_batch)
# Process any pending bounds update if loading is complete # Process any pending bounds update if loading is complete
handle_pending_bounds(socket, is_final_batch) handle_pending_bounds(socket, is_final_batch)
end end
defp maybe_schedule_next_batch(socket, _batch_offset, true), do: socket
defp maybe_schedule_next_batch(socket, batch_offset, false) do
next_batch = batch_offset + 1
generation = socket.assigns.loading_generation
timer_ref = Process.send_after(self(), {:load_historical_batch, next_batch, generation}, 50)
assign(socket, :pending_batch_tasks, [timer_ref | socket.assigns.pending_batch_tasks])
end
# Handle low zoom (heat map) # Handle low zoom (heat map)
defp handle_zoom_based_display( defp handle_zoom_based_display(
%{assigns: %{map_zoom: zoom}} = socket, %{assigns: %{map_zoom: zoom}} = socket,