aprs.me/lib/aprsme/workers/packet_cleanup_worker.ex
Graham McIntire 509f271eac
Improve packet pipeline reliability, security, and cleanup efficiency
- Fix XSS vulnerability in buildPopupContent fallback (escape callsign/comment)
- Add batch insert retry with individual fallback in PacketConsumer
- Add APRS-IS login response validation (logresp dispatch handler)
- Fix stale generation check bypass in HistoricalLoader
- Replace two-step SELECT+DELETE cleanup with single CTE-based batch DELETE
- Change default packet retention from 365 to 7 days
- Delete MapHelpers (100% duplicate of CoordinateUtils + BoundsUtils)
- Delete AprsIsConnection (dead code, second unused APRS-IS connection)
- Fix InfoMap hook memory leak (store/cancel setTimeout ref)
- Extract singleton loadMapBundle with callback queue in app.js
- Extract @heat_map_max_zoom constant in DisplayManager
- Add telemetry to cleanup worker and PacketProducer buffer overflow
- Track PacketProducer buffer_size as integer for O(1) length checks
- Remove duplicate bounds functions from index.ex
2026-02-19 14:11:48 -06:00

232 lines
7.5 KiB
Elixir

defmodule Aprsme.Workers.PacketCleanupWorker do
@moduledoc """
Worker for cleaning up old APRS packet data.
This worker is responsible for:
1. Removing packets older than the retention period (7 days by default)
2. Providing granular cleanup with custom age parameters
3. Logging statistics about cleanup operations
4. Supporting batch processing for large datasets
Uses a single-query DELETE with a CTE for efficient batch cleanup,
avoiding the overhead of a separate SELECT + DELETE per batch.
## Functions
- `perform/1` - Standard cleanup using configured retention period
- `cleanup_packets_older_than/1` - Cleanup packets older than specified days
- `cleanup_packets_in_batches/2` - Batch cleanup for large datasets
"""
import Ecto.Query
alias Aprsme.BadPacket
alias Aprsme.Packet
alias Aprsme.Repo
require Logger
# Batch size for cleanup operations to prevent long-running transactions
@batch_size 10_000
# Maximum time to spend on cleanup in milliseconds (5 minutes)
@max_cleanup_time 5 * 60 * 1000
@spec perform(map()) :: :ok | {:error, String.t()}
def perform(%{"cleanup_days" => days}) when is_integer(days) do
Logger.info("Starting scheduled APRS packet cleanup for packets older than #{days} days")
{deleted_count, _} = cleanup_packets_older_than_batched(days)
Logger.info("APRS packet cleanup complete: removed #{deleted_count} packets older than #{days} days")
:ok
rescue
error ->
Logger.error("Packet cleanup failed: #{inspect(error)}\n#{inspect(__STACKTRACE__)}")
{:error, "Cleanup failed: #{inspect(error)}"}
end
@spec perform(map()) :: :ok | {:error, String.t()}
def perform(_args) do
Logger.info("Starting scheduled APRS packet cleanup")
{deleted_count, _} = cleanup_old_packets_batched()
bad_packet_count = cleanup_old_bad_packets()
retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
Logger.info(
"APRS packet cleanup complete: removed #{deleted_count} packets and #{bad_packet_count} bad packets older than #{retention_days} days"
)
:ok
rescue
error ->
Logger.error("Packet cleanup failed: #{inspect(error)}\n#{inspect(__STACKTRACE__)}")
{:error, "Cleanup failed: #{inspect(error)}"}
end
@doc """
Perform cleanup of packets older than a specific number of days using batch processing.
## Parameters
- `days` - Number of days to retain packets (packets older than this will be deleted)
## Returns
- {deleted_count, batches_processed}
"""
@spec cleanup_packets_older_than_batched(pos_integer()) :: {non_neg_integer(), non_neg_integer()}
def cleanup_packets_older_than_batched(days) when is_integer(days) and days > 0 do
Logger.info("Starting batched APRS packet cleanup for packets older than #{days} days")
cutoff_time = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
start_time = System.monotonic_time(:millisecond)
{deleted_count, batches_processed} = cleanup_packets_in_batches(cutoff_time, start_time)
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:aprsme, :packet_cleanup, :complete],
%{deleted: deleted_count, batches: batches_processed, duration_ms: duration},
%{retention_days: days}
)
Logger.info(
"APRS packet cleanup complete: removed #{deleted_count} packets in #{batches_processed} batches over #{duration}ms"
)
{deleted_count, batches_processed}
end
@doc """
Clean packets older than a specific number of days.
## Parameters
- `days` - Number of days to keep (packets older than this will be deleted)
## Returns
- Number of packets deleted
"""
@spec cleanup_packets_older_than(pos_integer()) :: non_neg_integer()
def cleanup_packets_older_than(days) when is_integer(days) and days > 0 do
{deleted_count, _} = cleanup_packets_older_than_batched(days)
deleted_count
end
@doc """
Clean packets in batches to prevent long-running transactions.
Uses a single DELETE query with a subquery LIMIT per batch,
avoiding the overhead of separate SELECT + DELETE round-trips.
## Parameters
- `cutoff_time` - DateTime before which packets should be deleted
- `start_time` - Monotonic time when cleanup started (for duration tracking)
## Returns
- {total_deleted_count, batches_processed}
"""
@spec cleanup_packets_in_batches(DateTime.t(), integer()) :: {non_neg_integer(), non_neg_integer()}
def cleanup_packets_in_batches(cutoff_time, start_time) do
cleanup_packets_in_batches(cutoff_time, start_time, 0, 0)
end
@doc """
Get packet IDs for deletion in batches.
## Parameters
- `cutoff_time` - DateTime before which packets should be deleted
- `batch_size` - Maximum number of IDs to return
## Returns
- List of packet IDs to delete
"""
@spec get_packet_ids_for_deletion(DateTime.t(), pos_integer()) :: [integer()]
def get_packet_ids_for_deletion(cutoff_time, batch_size) do
Repo.all(
from(p in Packet,
where: p.received_at < ^cutoff_time,
select: p.id,
limit: ^batch_size
)
)
rescue
error ->
Logger.error("Failed to get packet IDs for deletion: #{inspect(error)}")
[]
end
# Private functions
defp cleanup_old_packets_batched do
retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
cleanup_packets_older_than_batched(retention_days)
end
defp cleanup_old_bad_packets do
retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
cutoff_time = DateTime.add(DateTime.utc_now(), -retention_days * 86_400, :second)
{deleted_count, _} =
Repo.delete_all(from(b in BadPacket, where: b.attempted_at < ^cutoff_time))
deleted_count
rescue
error ->
Logger.error("Failed to clean up bad packets: #{inspect(error)}")
0
end
defp cleanup_packets_in_batches(cutoff_time, start_time, total_deleted, batches_processed) do
current_time = System.monotonic_time(:millisecond)
if current_time - start_time > @max_cleanup_time do
Logger.info("Cleanup time limit reached (#{@max_cleanup_time}ms), stopping batch processing")
{total_deleted, batches_processed}
else
try do
deleted_count = delete_batch(cutoff_time, @batch_size)
if deleted_count == 0 do
{total_deleted, batches_processed}
else
new_total = total_deleted + deleted_count
new_batches = batches_processed + 1
Logger.debug("Cleanup batch #{new_batches}: deleted #{deleted_count} packets (total: #{new_total})")
cleanup_packets_in_batches(cutoff_time, start_time, new_total, new_batches)
end
rescue
error ->
Logger.error("Failed to delete batch of packets: #{inspect(error)}")
{total_deleted, batches_processed}
end
end
end
# Single-query batch delete using a CTE subquery.
# This is significantly faster than SELECT IDs + DELETE by IDs
# because it's a single database round-trip and allows PostgreSQL
# to optimize the delete plan (sequential scan on received_at index).
@spec delete_batch(DateTime.t(), pos_integer()) :: non_neg_integer()
defp delete_batch(cutoff_time, batch_size) do
# Use raw SQL for the CTE-based batch delete since Ecto doesn't
# natively support DELETE ... WHERE id IN (SELECT ... LIMIT ...)
{:ok, %{num_rows: deleted_count}} =
Repo.query(
"""
DELETE FROM packets
WHERE id IN (
SELECT id FROM packets
WHERE received_at < $1
LIMIT $2
)
""",
[cutoff_time, batch_size]
)
deleted_count
end
end