Subscribes to mqtt.pskreporter.info:1883 over plain TCP and folds every reception report into hourly aggregates per (band, sender_grid_4, receiver_grid_4). Each spot is the empirical "propagation actually occurred on this path right now" signal we'll correlate against HRRR atmospheric state to recalibrate the scoring weights. Aggregating at the path-hour bucket collapses 50-reporter QSOs to one row instead of 50. Topic filter anchors on USA (DXCC 291) on either sender or receiver side so the broker pre-filters out everything outside our HRRR domain. Bands subscribed: VHF and up (6m, 2m, 70cm, 23cm, + microwave) — HF is dominated by ionospheric propagation, which this project doesn't model. Pskr.Client cluster-elects a singleton via :global.register_name — all replicas start the GenServer but only one connects to MQTT; the rest stay in :standby and watch for the leader's nodedown to re-run election. Off in dev/test, on by default in prod (PSKR_MQTT_ENABLED=false as kill-switch). Pskr.Aggregator buffers in memory keyed by Pskr.path_key/1 and flushes every 60s via Repo.insert_all/3 with an additive ON CONFLICT (count summed, SNR envelope by GREATEST/LEAST, modes unioned via unnest+DISTINCT). Idempotent across overlapping flushes and across leader handover. Dockerfile sets BUILD_WITHOUT_QUIC=1 — emqtt's transitive quicer dep wants CMake + OpenSSL headers we'd otherwise have to add to the builder image just to never use QUIC over plain MQTT. Base image is unchanged; the new dep compiles cleanly into the existing prop-base runtime.
136 lines
4.7 KiB
Elixir
136 lines
4.7 KiB
Elixir
defmodule Microwaveprop.Pskr.Aggregator do
|
||
@moduledoc """
|
||
In-memory accumulator for incoming PSK Reporter spots.
|
||
|
||
The MQTT firehose can push 500+ messages/second when subscribed
|
||
broadly. Writing each spot as a row would (a) saturate the
|
||
Postgres write path and (b) bloat the dataset 50× because every
|
||
QSO is heard by many reporters. This GenServer keeps a small
|
||
state map keyed by `Pskr.path_key/1`, folds each spot into the
|
||
matching row with `Pskr.merge_spot/2`, and periodically batches
|
||
the whole map into a single `Repo.insert_all/3` upsert.
|
||
|
||
Conflict handling is additive: when a row with the same
|
||
`(hour_utc, band, sender_grid, receiver_grid)` already exists in
|
||
the DB, the upsert merges counts, expands the SNR envelope, and
|
||
unions the mode set rather than overwriting. That makes flushes
|
||
idempotent in the face of overlap (e.g. a flush firing a few
|
||
seconds before the hour boundary).
|
||
|
||
Memory profile: each pending row is small (~200 bytes), and the
|
||
unique key space is bounded by `bands × paths_per_hour`. With our
|
||
CONUS-anchored topic filter that's typically a few thousand keys
|
||
per hour — flushed every minute, the working set never exceeds a
|
||
handful of MiB.
|
||
"""
|
||
use GenServer
|
||
|
||
import Ecto.Query
|
||
|
||
alias Microwaveprop.Pskr
|
||
alias Microwaveprop.Pskr.SpotHourly
|
||
alias Microwaveprop.Repo
|
||
|
||
require Logger
|
||
|
||
@default_flush_ms 60_000
|
||
|
||
@spec start_link(keyword()) :: GenServer.on_start()
|
||
def start_link(opts \\ []) do
|
||
{name, opts} = Keyword.pop(opts, :name, __MODULE__)
|
||
GenServer.start_link(__MODULE__, opts, name: name)
|
||
end
|
||
|
||
@doc "Push one raw MQTT payload (binary JSON) into the accumulator."
|
||
@spec ingest(GenServer.server(), binary()) :: :ok
|
||
def ingest(server \\ __MODULE__, payload) when is_binary(payload) do
|
||
GenServer.cast(server, {:ingest, payload})
|
||
end
|
||
|
||
@doc "Force a synchronous flush. Returns the number of rows written."
|
||
@spec flush(GenServer.server()) :: non_neg_integer()
|
||
def flush(server \\ __MODULE__) do
|
||
GenServer.call(server, :flush, 30_000)
|
||
end
|
||
|
||
@doc "Inspect the current pending row count without flushing."
|
||
@spec pending_count(GenServer.server()) :: non_neg_integer()
|
||
def pending_count(server \\ __MODULE__), do: GenServer.call(server, :pending_count)
|
||
|
||
@impl true
|
||
def init(opts) do
|
||
flush_ms = Keyword.get(opts, :flush_ms, @default_flush_ms)
|
||
if flush_ms > 0, do: schedule_flush(flush_ms)
|
||
{:ok, %{rows: %{}, parsed: 0, dropped: 0, flush_ms: flush_ms}}
|
||
end
|
||
|
||
@impl true
|
||
def handle_cast({:ingest, payload}, state) do
|
||
case Pskr.parse_spot(payload) do
|
||
{:ok, spot} ->
|
||
key = Pskr.path_key(spot)
|
||
rows = Map.update(state.rows, key, Pskr.merge_spot(nil, spot), &Pskr.merge_spot(&1, spot))
|
||
{:noreply, %{state | rows: rows, parsed: state.parsed + 1}}
|
||
|
||
{:error, _reason} ->
|
||
{:noreply, %{state | dropped: state.dropped + 1}}
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def handle_call(:flush, _from, state) do
|
||
written = do_flush(state.rows)
|
||
{:reply, written, %{state | rows: %{}}}
|
||
end
|
||
|
||
def handle_call(:pending_count, _from, state) do
|
||
{:reply, map_size(state.rows), state}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info(:flush_tick, state) do
|
||
written = do_flush(state.rows)
|
||
|
||
if written > 0 or state.dropped > 0 do
|
||
Logger.info("Pskr.Aggregator flushed #{written} rows (parsed #{state.parsed}, dropped #{state.dropped})")
|
||
end
|
||
|
||
schedule_flush(state.flush_ms)
|
||
{:noreply, %{state | rows: %{}, parsed: 0, dropped: 0}}
|
||
end
|
||
|
||
defp schedule_flush(ms) when ms > 0, do: Process.send_after(self(), :flush_tick, ms)
|
||
defp schedule_flush(_), do: :ok
|
||
|
||
defp do_flush(rows) when map_size(rows) == 0, do: 0
|
||
|
||
defp do_flush(rows) do
|
||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||
|
||
entries =
|
||
rows
|
||
|> Map.values()
|
||
|> Enum.map(&Map.merge(&1, %{inserted_at: now, updated_at: now}))
|
||
|
||
{count, _} =
|
||
Repo.insert_all(SpotHourly, entries,
|
||
on_conflict:
|
||
from(s in SpotHourly,
|
||
update: [
|
||
set: [
|
||
spot_count: fragment("? + EXCLUDED.spot_count", s.spot_count),
|
||
max_snr_db: fragment("GREATEST(?, EXCLUDED.max_snr_db)", s.max_snr_db),
|
||
min_snr_db: fragment("LEAST(?, EXCLUDED.min_snr_db)", s.min_snr_db),
|
||
modes: fragment("ARRAY(SELECT DISTINCT unnest(? || EXCLUDED.modes))", s.modes),
|
||
first_spot_at: fragment("LEAST(?, EXCLUDED.first_spot_at)", s.first_spot_at),
|
||
last_spot_at: fragment("GREATEST(?, EXCLUDED.last_spot_at)", s.last_spot_at),
|
||
updated_at: fragment("EXCLUDED.updated_at")
|
||
]
|
||
]
|
||
),
|
||
conflict_target: [:hour_utc, :band, :sender_grid, :receiver_grid]
|
||
)
|
||
|
||
count
|
||
end
|
||
end
|