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