fix(admin_task_worker): unknown feature now returns error instead of falling through

Backtest branch's unknown-feature guard was dead code: the if block's
{:error, _} return was discarded and execution fell through, running the
backtest with a just-created atom and writing a report for nonsense
features. Two compounding issues:

- function_exported?/3 returns false for modules not yet loaded in the
  BEAM, so even valid feature names triggered the warning path on a
  cold VM.
- String.to_atom/1 on caller-supplied strings created unbounded atoms.

Fix: Code.ensure_loaded?(Features) before the check, String.to_existing_atom/1
with rescue (Features exports __info__(:functions) so every valid function
atom already exists), and an explicit early return via a with pipeline so
the error tuple actually leaves the worker.
This commit is contained in:
Graham McIntire 2026-04-16 14:58:37 -05:00
parent bba860f28d
commit 63a9417287
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 55 additions and 44 deletions

View file

@ -47,29 +47,22 @@ defmodule Microwaveprop.Workers.AdminTaskWorker do
def perform(%Oban.Job{args: %{"task" => "backtest", "feature" => feature_name} = args}) do
sample_size = Map.get(args, "sample_size", 5000)
fun_atom = String.to_atom(feature_name)
if !function_exported?(Features, fun_atom, 3) do
Logger.error("AdminTask: unknown feature #{feature_name}")
{:error, "unknown feature: #{feature_name}"}
# `function_exported?/3` returns false for any module that hasn't
# been loaded yet in the BEAM — force-load `Features` first so valid
# feature names aren't rejected on a cold VM. Using
# `String.to_existing_atom/1` inside a rescue guards against
# unbounded atom creation (the admin UI is the only dispatcher, but
# cheap insurance).
with true <- Code.ensure_loaded?(Features),
{:ok, fun_atom} <- safe_to_existing_atom(feature_name),
true <- function_exported?(Features, fun_atom, 3) do
run_backtest(feature_name, fun_atom, sample_size)
else
_ ->
Logger.error("AdminTask: unknown feature #{feature_name}")
{:error, "unknown feature: #{feature_name}"}
end
feature_fun = &apply(Features, fun_atom, [&1, &2, &3])
name = "Microwaveprop.Backtest.Features.#{feature_name}"
Logger.info("AdminTask: running backtest for #{feature_name}")
report = Backtest.evaluate(feature_fun, sample_size: sample_size, feature_name: name)
distance_bins = Backtest.lift_by_distance(feature_fun, sample_size: sample_size)
band_stats = Backtest.lift_by_band(feature_fun, sample_size: sample_size)
markdown = Backtest.to_markdown(report, distance_bins: distance_bins, band_stats: band_stats)
path = "priv/backtest_reports/#{feature_name}.md"
File.mkdir_p!(Path.dirname(path))
File.write!(path, markdown)
Logger.info("AdminTask: backtest for #{feature_name} complete, wrote #{path}")
:ok
end
def perform(%Oban.Job{args: %{"task" => "climatology"} = args}) do
@ -224,4 +217,29 @@ defmodule Microwaveprop.Workers.AdminTaskWorker do
Logger.error("AdminTask: unknown task #{task}")
{:error, "unknown task: #{task}"}
end
defp safe_to_existing_atom(name) do
{:ok, String.to_existing_atom(name)}
rescue
ArgumentError -> :error
end
defp run_backtest(feature_name, fun_atom, sample_size) do
feature_fun = &apply(Features, fun_atom, [&1, &2, &3])
name = "Microwaveprop.Backtest.Features.#{feature_name}"
Logger.info("AdminTask: running backtest for #{feature_name}")
report = Backtest.evaluate(feature_fun, sample_size: sample_size, feature_name: name)
distance_bins = Backtest.lift_by_distance(feature_fun, sample_size: sample_size)
band_stats = Backtest.lift_by_band(feature_fun, sample_size: sample_size)
markdown = Backtest.to_markdown(report, distance_bins: distance_bins, band_stats: band_stats)
path = "priv/backtest_reports/#{feature_name}.md"
File.mkdir_p!(Path.dirname(path))
File.write!(path, markdown)
Logger.info("AdminTask: backtest for #{feature_name} complete, wrote #{path}")
:ok
end
end

View file

@ -83,15 +83,11 @@ defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
describe "perform/1 — task=backtest" do
test "writes a per-feature markdown report for a known feature" do
# Note: the worker logs "unknown feature time_of_day" here even
# though `time_of_day` is a legitimate public function on
# `Features`. The guard uses `function_exported?/3`, which returns
# false until the target module has been loaded — and
# `Microwaveprop.Backtest.Features` isn't referenced anywhere
# before this branch, so it's not yet loaded in a cold VM. A
# `Code.ensure_loaded?/1` call would fix the guard; see the
# "silently succeeds for an unknown feature" test below for the
# full latent-bug characterization.
# `time_of_day` is a legitimate public function on
# `Microwaveprop.Backtest.Features`. The guard uses
# `Code.ensure_loaded?/1` before `function_exported?/3`, so the
# module is forced-loaded and the guard does not spuriously reject
# valid feature names on a cold VM.
with_tmp_cwd(fn tmp ->
args = %{"task" => "backtest", "feature" => "time_of_day", "sample_size" => 10}
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args})
@ -104,23 +100,20 @@ defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
end)
end
test "silently succeeds for an unknown feature when the QSO corpus is empty (latent bug)" do
# The worker's guard `if !function_exported?(Features, fun_atom, 3)`
# logs and evaluates `{:error, ...}` but the value is discarded and
# execution falls through. The closure `&apply(Features, fun_atom,
# [&1, &2, &3])` is only invoked if `Backtest.evaluate` has
# contacts to evaluate it against — with an empty QSO corpus (as
# in the sandbox), the undefined function is never called and the
# worker writes an empty report and returns `:ok`. This pins the
# current observable behavior; a proper fix should `{:error, _}`
# early rather than relying on downstream emptiness to mask the
# bug.
test "returns {:error, _} and writes no report for an unknown feature" do
# An unknown feature name must short-circuit: the worker returns
# `{:error, "unknown feature: ..."}` before invoking
# `Backtest.evaluate` and must NOT write a report file. Previously
# the guard's `{:error, _}` tuple was the `if` block's return
# value and got discarded, so execution fell through to write an
# empty report.
with_tmp_cwd(fn tmp ->
args = %{"task" => "backtest", "feature" => "does_not_exist_feature_xyz", "sample_size" => 10}
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args})
# File was still written — guard did not bail out.
assert File.exists?(Path.join(tmp, "priv/backtest_reports/does_not_exist_feature_xyz.md"))
assert {:error, "unknown feature: does_not_exist_feature_xyz"} =
AdminTaskWorker.perform(%Oban.Job{args: args})
refute File.exists?(Path.join(tmp, "priv/backtest_reports/does_not_exist_feature_xyz.md"))
end)
end
end