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.
227 lines
5.9 KiB
Elixir
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
|