aprs.me/lib/aprsme/partition_manager.ex
Graham McIntire b8a9b8a465
chore(dialyzer): enable stricter flags and fix 97 resulting findings
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.
2026-04-21 10:07:01 -05:00

227 lines
5.9 KiB
Elixir

defmodule Aprsme.PartitionManager do
@moduledoc """
Manages daily partitions for the packets table.
Creates future partitions and drops old ones past the retention period.
Partition naming: packets_YYYYMMDD (e.g., packets_20260220).
Each partition covers [YYYY-MM-DD 00:00:00, YYYY-MM-DD+1 00:00:00) UTC.
"""
use GenServer
alias Aprsme.Repo
require Logger
@check_interval to_timeout(hour: 1)
@future_days 2
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Returns the partition table name for a given date.
"""
@spec partition_name(Date.t()) :: String.t()
def partition_name(%Date{} = date) do
"packets_#{Calendar.strftime(date, "%Y%m%d")}"
end
@doc """
Returns the {start, end} UTC datetime range for a partition covering the given date.
"""
@spec partition_range(Date.t()) :: {DateTime.t(), DateTime.t()}
def partition_range(%Date{} = date) do
{:ok, start_dt} = DateTime.new(date, ~T[00:00:00], "Etc/UTC")
next_date = Date.add(date, 1)
{:ok, end_dt} = DateTime.new(next_date, ~T[00:00:00], "Etc/UTC")
{start_dt, end_dt}
end
@doc """
Ensures partitions exist for today through today + #{@future_days} days.
Returns {:ok, list_of_created_partition_names}.
"""
@spec ensure_partitions_exist() :: {:ok, [String.t()]}
def ensure_partitions_exist do
today = Date.utc_today()
created =
for offset <- 0..@future_days, reduce: [] do
acc ->
date = Date.add(today, offset)
name = partition_name(date)
if partition_exists?(name) do
acc
else
_ = create_partition_with_lock(date)
[name | acc]
end
end
{:ok, Enum.reverse(created)}
end
defp create_partition_with_lock(%Date{} = date) do
name = partition_name(date)
lock_key = :erlang.phash2(name)
_ =
Repo.transaction(fn ->
_ = Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key])
if !partition_exists?(name) do
create_partition(date)
end
end)
name
end
@doc """
Drops partitions older than the given number of retention days.
Returns {:ok, list_of_dropped_partition_names}.
"""
@spec drop_old_partitions(pos_integer()) :: {:ok, [String.t()]}
def drop_old_partitions(retention_days \\ 7) do
cutoff_date = Date.add(Date.utc_today(), -retention_days)
dropped =
list_partitions()
|> Enum.filter(fn name ->
case partition_date(name) do
{:ok, date} -> Date.compare(date, cutoff_date) != :gt
_ -> false
end
end)
|> Enum.map(fn name ->
_ = drop_partition(name)
name
end)
{:ok, dropped}
end
@doc """
Lists all existing partition table names for the packets table.
"""
@spec list_partitions() :: [String.t()]
def list_partitions do
case Repo.query(
"SELECT inhrelid::regclass::text FROM pg_inherits WHERE inhparent = 'packets'::regclass ORDER BY inhrelid::regclass::text"
) do
{:ok, %{rows: rows}} ->
Enum.map(rows, fn [name] ->
String.replace(name, ~r/^public\./, "")
end)
{:error, _} ->
[]
end
end
# GenServer callbacks
@impl true
def init(_opts) do
if Application.get_env(:aprsme, :env) != :test do
send(self(), :manage_partitions)
end
{:ok, %{}}
end
@impl true
def handle_info(:manage_partitions, state) do
manage_partitions()
Process.send_after(self(), :manage_partitions, @check_interval)
{:noreply, state}
end
defp manage_partitions do
case ensure_partitions_exist() do
{:ok, []} ->
:ok
{:ok, created} ->
Logger.info("Created #{length(created)} partition(s): #{Enum.join(created, ", ")}")
end
retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
case drop_old_partitions(retention_days) do
{:ok, []} ->
:ok
{:ok, dropped} ->
Logger.info("Dropped #{length(dropped)} old partition(s): #{Enum.join(dropped, ", ")}")
end
rescue
error ->
Logger.error("Partition management failed: #{inspect(error)}")
end
defp partition_exists?(name) do
case Repo.query(
"SELECT 1 FROM pg_tables WHERE tablename = $1 AND schemaname = 'public'",
[name]
) do
{:ok, %{num_rows: n}} when n > 0 -> true
_ -> false
end
end
defp create_partition(%Date{} = date) do
name = partition_name(date)
{from_dt, to_dt} = partition_range(date)
from_str = DateTime.to_iso8601(from_dt)
to_str = DateTime.to_iso8601(to_dt)
validate_partition_name!(name)
_ =
Repo.query!(
"CREATE TABLE IF NOT EXISTS #{quote_identifier(name)} PARTITION OF packets FOR VALUES FROM ('#{from_str}') TO ('#{to_str}')"
)
Logger.debug("Created partition #{name} [#{from_str}, #{to_str})")
name
end
defp drop_partition(name) do
# Validate partition name to prevent SQL injection
validate_partition_name!(name)
_ = Repo.query!("DROP TABLE IF EXISTS #{quote_identifier(name)}")
Logger.debug("Dropped partition #{name}")
name
end
# Validates that partition name matches expected format: packets_YYYYMMDD
# Raises if name is invalid to prevent SQL injection
defp validate_partition_name!(name) do
if !String.match?(name, ~r/^packets_\d{8}$/) do
raise ArgumentError, "Invalid partition name: #{inspect(name)}"
end
end
# Quotes SQL identifier to prevent injection
defp quote_identifier(name) do
~s("#{String.replace(name, ~s("), ~s(""))}")
end
defp partition_date("packets_" <> date_str) do
case Date.from_iso8601(
String.slice(date_str, 0, 4) <>
"-" <> String.slice(date_str, 4, 2) <> "-" <> String.slice(date_str, 6, 2)
) do
{:ok, date} -> {:ok, date}
_ -> nil
end
end
defp partition_date(_), do: nil
end