- Bug #12: Lower rate limit to 15/min - Bug #13: Wrap model loading in Task.start - Bug #14: Fix classify_time_period guard gap at -3.0 - Bug #15: Not applicable (Elixir has no ?? operator) - Bug #16: Remove fallback Repo.get for station preload - Bug #17: Extract cache_key() in ContactMapController - Bug #18: Add has_many :contacts and :beacons to User schema - A&D #4: Move serve_markdown_if_requested after secure headers - Config #1: Move signing_salt to runtime.exs env var - Config #2: Use --check-unused instead of --unused - Config #3: Re-enable UnsafeToAtom Credo check - Config #5: Re-enable LeakyEnvironment Credo check - Add @spec annotations to fix re-enabled Specs violations - Replace String.to_atom with to_existing_atom where guarded
251 lines
9.9 KiB
Elixir
251 lines
9.9 KiB
Elixir
defmodule Microwaveprop.Application do
|
|
# See https://hexdocs.pm/elixir/Application.html
|
|
# for more information on OTP Applications
|
|
@moduledoc false
|
|
|
|
use Application
|
|
|
|
require Logger
|
|
|
|
@build_timestamp DateTime.utc_now()
|
|
|
|
@impl true
|
|
def start(_type, _args) do
|
|
# Eagerly create the API rate limiter's ETS table so the plug never
|
|
# races to create it on the request path.
|
|
:ok = MicrowavepropWeb.Api.RateLimiter.init_table()
|
|
|
|
topologies = Application.get_env(:libcluster, :topologies, [])
|
|
|
|
children = [
|
|
MicrowavepropWeb.Telemetry,
|
|
Microwaveprop.PromEx,
|
|
Microwaveprop.Repo,
|
|
# AprsRepo is optional — only starts when configured with a :url
|
|
# (dev/test from config blocks, prod from APRS_DATABASE_URL). Missing
|
|
# config logs a warning and skips startup so the app still boots.
|
|
aprs_repo_child_spec(),
|
|
{Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]},
|
|
pubsub_child_spec(),
|
|
# Partitioned Task.Supervisor — callers route heavy `async_stream`
|
|
# work through `{:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor,
|
|
# self()}}` so concurrent workers (recalibrator, HRRR/GEFS range
|
|
# downloads, GEFS scoring) do not serialize on a single supervisor PID.
|
|
{PartitionSupervisor,
|
|
child_spec: Task.Supervisor, name: Microwaveprop.TaskSupervisor, partitions: System.schedulers_online()},
|
|
# Valkey connection — must start before GridCache so the cache
|
|
# backend selector sees an alive Redix process. Returns :ignore
|
|
# when VALKEY_URL is unset (dev/test), and GridCache falls back
|
|
# to its ETS implementation.
|
|
Microwaveprop.Valkey,
|
|
Microwaveprop.Cache,
|
|
Microwaveprop.Buildings.Index,
|
|
Microwaveprop.Propagation.ScoreCache,
|
|
Microwaveprop.Weather.GridCache,
|
|
{Microwaveprop.Weather.IemRateLimiter,
|
|
interval_ms: Application.get_env(:microwaveprop, :iem_rate_limiter_interval_ms, 700)},
|
|
Microwaveprop.Weather.NexradCache,
|
|
Microwaveprop.Qrz.Client,
|
|
{Oban, Application.fetch_env!(:microwaveprop, Oban)},
|
|
Microwaveprop.RepoListener,
|
|
# Start to serve requests, typically the last entry
|
|
MicrowavepropWeb.Endpoint,
|
|
Microwaveprop.Propagation.FreshnessMonitor,
|
|
# PSK Reporter MQTT firehose. Aggregator runs on every pod
|
|
# (cheap idle GenServer); the Client also runs on every pod
|
|
# but only the cluster-elected leader actually opens an MQTT
|
|
# connection — the rest stay in `:standby` watching for the
|
|
# leader to drop. See `Microwaveprop.Pskr.Client`. Disabled
|
|
# in dev/test so `mix test` doesn't try to dial out.
|
|
Microwaveprop.Pskr.Aggregator,
|
|
pskr_client_child_spec(),
|
|
# Periodic GC for the API rate limiter ETS table; without this
|
|
# the table accumulates one row per distinct caller per window
|
|
# for the lifetime of the BEAM.
|
|
MicrowavepropWeb.Api.RateLimiter.Sweeper
|
|
]
|
|
|
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
|
# for other strategies and supported options
|
|
opts = [strategy: :one_for_one, name: Microwaveprop.Supervisor]
|
|
result = children |> Enum.reject(&is_nil/1) |> Supervisor.start_link(opts)
|
|
|
|
# Attach the Oban exception telemetry tap so per-worker recovery
|
|
# callbacks and structured logs fire on job failure. Must run after
|
|
# the Oban supervisor starts (above in `children`).
|
|
:ok = Microwaveprop.ObanErrorReporter.attach()
|
|
|
|
# Run migrations after Repo is started. Wrapped in a background
|
|
# Task so the application supervisor doesn't block on long-running
|
|
# migrations on pod boot. Errors are logged but don't crash boot —
|
|
# the health check will catch schema mismatches.
|
|
Task.start(fn ->
|
|
try do
|
|
results = Microwaveprop.Release.migrate()
|
|
Logger.info("Database migrations completed (#{length(results)} repos)")
|
|
rescue
|
|
e ->
|
|
Logger.error("Database migrations failed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
|
end
|
|
end)
|
|
|
|
# Warm GridCache from the latest persisted ProfilesFile so /weather
|
|
# has data immediately after a pod restart. Runs after the GridCache
|
|
# GenServer is up (it's in `children` above).
|
|
#
|
|
# Must run in a short-lived process, not inline in the application
|
|
# master: when executed here the loaded rows live on the app master's
|
|
# heap and never GC (it's idle after boot), pinning ~800 MiB for the
|
|
# lifetime of the pod.
|
|
{:ok, _pid} = Task.start(fn -> Microwaveprop.Weather.warm_grid_cache_from_latest_profile() end)
|
|
|
|
# One-shot backfill: enqueue a QRZ-lookup job for every user with a
|
|
# missing home QTH. Idempotent — the worker no-ops if home_grid is
|
|
# already set, so re-running on every boot is safe (and useful: any
|
|
# users registered while the worker module was unavailable get
|
|
# picked up the next time the pod restarts).
|
|
{:ok, _pid} =
|
|
Task.start(fn ->
|
|
try do
|
|
Microwaveprop.Accounts.backfill_missing_home_qth()
|
|
rescue
|
|
e ->
|
|
Logger.error("Application: backfill_missing_home_qth crashed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
|
end
|
|
end)
|
|
|
|
# Load ML model in dev/test only (Nx/Axon not compiled for prod)
|
|
if Application.get_env(:microwaveprop, :load_ml_model, false) do
|
|
{:ok, _pid} =
|
|
Task.start(fn ->
|
|
Microwaveprop.Propagation.load_ml_model()
|
|
end)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
@impl true
|
|
def config_change(changed, _new, removed) do
|
|
MicrowavepropWeb.Endpoint.config_change(changed, removed)
|
|
:ok
|
|
end
|
|
|
|
# PubSub adapter selector. When `:valkey_url` is configured (prod via
|
|
# VALKEY_URL env var), broadcasts ride Phoenix.PubSub.Redis on the
|
|
# shared Valkey instance — same backend that GridCache uses, so both
|
|
# cross-pod features go through one connection target. Decouples
|
|
# PubSub fan-out from libcluster's Erlang distribution: a flapping
|
|
# BEAM connection (e.g., the OOM-cascade scenario from 2026-05-03)
|
|
# no longer takes broadcasts down with it.
|
|
#
|
|
# When unset, default to Phoenix.PubSub.PG2 (in-process / Erlang
|
|
# distribution). Dev/test boot without Valkey running.
|
|
defp pubsub_child_spec do
|
|
case Application.get_env(:microwaveprop, :valkey_url) do
|
|
nil ->
|
|
{Phoenix.PubSub, name: Microwaveprop.PubSub}
|
|
|
|
"" ->
|
|
{Phoenix.PubSub, name: Microwaveprop.PubSub}
|
|
|
|
url ->
|
|
# node_name must be unique per pod or Redis-adapter loopback
|
|
# filtering breaks (every node would discard its own broadcasts
|
|
# mistakenly identifying them as another node's). Pod IP is
|
|
# the simplest cluster-wide-unique identifier; falls back to
|
|
# Erlang's node() name in non-K8s environments.
|
|
node_name = System.get_env("POD_IP") || to_string(node())
|
|
|
|
# phoenix_pubsub_redis 3.x: `redis_opts` accepts either a URL
|
|
# binary OR a keyword list of host/port/password/etc. The
|
|
# 3.1's deprecation warning ("Move :url inside the :redis_opts
|
|
# option instead") is misleading — `[url: url]` gets forwarded
|
|
# to Redix which rejects it as an unknown option (the valid
|
|
# keys are :host, :port, :password, …, NOT :url). The bare URL
|
|
# form takes the binary-clause and works correctly.
|
|
opts = [
|
|
name: Microwaveprop.PubSub,
|
|
adapter: Phoenix.PubSub.Redis,
|
|
redis_opts: url,
|
|
node_name: node_name
|
|
]
|
|
|
|
{Phoenix.PubSub, opts}
|
|
end
|
|
end
|
|
|
|
# PSK Reporter MQTT client. Returns the child spec when
|
|
# `:pskr_mqtt_enabled` is true, otherwise `nil` so the supervisor
|
|
# children list filters it out. Off by default — see runtime.exs
|
|
# for the env-var gate. Only one pod should run this.
|
|
defp pskr_client_child_spec do
|
|
if Application.get_env(:microwaveprop, :pskr_mqtt_enabled, false) do
|
|
Microwaveprop.Pskr.Client
|
|
end
|
|
end
|
|
|
|
# Returns Microwaveprop.AprsRepo child spec if configured (has a :url
|
|
# or :database key), otherwise logs a warning and returns nil so the
|
|
# supervisor children list can filter it out. APRS calibration is an
|
|
# optional integration with the sister aprs.me database — we never
|
|
# crash boot when it's unavailable.
|
|
defp aprs_repo_child_spec do
|
|
config = Application.get_env(:microwaveprop, Microwaveprop.AprsRepo, [])
|
|
|
|
if Keyword.has_key?(config, :url) or Keyword.has_key?(config, :database) do
|
|
Microwaveprop.AprsRepo
|
|
else
|
|
Logger.warning("Microwaveprop.AprsRepo not configured (no :url or :database) — APRS calibration disabled.")
|
|
|
|
nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the deployment (image build) timestamp.
|
|
|
|
Sources, in priority order:
|
|
|
|
1. The `/app/BUILD_TIMESTAMP` file baked into the Docker image at build
|
|
time (the CI pipeline passes `--build-arg BUILD_TIMESTAMP=...` in the
|
|
final stage, so this value is fresh on every image and is not affected
|
|
by earlier-stage layer caching). The path is overridable via
|
|
`config :microwaveprop, :build_timestamp_file` for tests.
|
|
2. The `DEPLOY_TIMESTAMP` environment variable, for environments that set
|
|
it imperatively (e.g. `kubectl set env`).
|
|
3. The compile-time fallback captured when this module was compiled.
|
|
|
|
All accepted values are ISO 8601, e.g. `2026-04-17T18:30:00Z`.
|
|
"""
|
|
@spec build_timestamp() :: DateTime.t()
|
|
def build_timestamp do
|
|
with :error <- from_file(),
|
|
:error <- from_env() do
|
|
@build_timestamp
|
|
end
|
|
end
|
|
|
|
defp from_file do
|
|
path = Application.get_env(:microwaveprop, :build_timestamp_file, "/app/BUILD_TIMESTAMP")
|
|
|
|
case File.read(path) do
|
|
{:ok, contents} -> parse_iso8601(contents)
|
|
{:error, _} -> :error
|
|
end
|
|
end
|
|
|
|
defp from_env do
|
|
case System.get_env("DEPLOY_TIMESTAMP") do
|
|
nil -> :error
|
|
string -> parse_iso8601(string)
|
|
end
|
|
end
|
|
|
|
defp parse_iso8601(string) do
|
|
case string |> String.trim() |> DateTime.from_iso8601() do
|
|
{:ok, dt, _offset} -> dt
|
|
{:error, _} -> :error
|
|
end
|
|
end
|
|
end
|