Cache MixUnused analyzer state to skip repeated BEAM scans in tests

- Split Analyzer.analyze/1 into compute_state/1 (slow, scans all BEAMs)
  and a fast filter step. analyze/1 now accepts :precomputed_state opt.
- Mix.Tasks.Compile.Unused.run/1 picks up precomputed state from process
  dict :mix_unused_extra_analyze_opts when present (test escape hatch).
- AnalyzerTest setup_all computes state once; exclude tests pass it
  through analyze/1, dropping per-test cost from ~700ms to <1ms.
- UnusedTest setup_all does the same — the consolidated severity-args
  test drops from 3.4s to ~5ms.
- Trim further sleeps in HistoricalLoadingTest (100->30ms) and
  MovementTest (50->20ms, receive timeouts 100->30ms).

Total suite: 40.6s -> 37.7s; 2488 tests, 0 failures.
This commit is contained in:
Graham McIntire 2026-05-09 11:17:08 -05:00
parent 4c5c730ee7
commit 5184d2ee5e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 68 additions and 24 deletions

View file

@ -69,8 +69,13 @@ defmodule Mix.Tasks.Compile.Unused do
# Make sure the project is compiled before analyzing.
_ = Mix.Task.run("compile", [])
# Tests may stash a precomputed Analyzer state in the process dict to
# avoid scanning all BEAM files on every Unused.run/1 call.
extra_analyze_opts = Process.get(:mix_unused_extra_analyze_opts, [])
config
|> filter_options()
|> Keyword.merge(extra_analyze_opts)
|> Analyzer.analyze()
|> filter_test_files(Keyword.get(config, :ignore_test_files, true))
|> emit(severity)

View file

@ -32,10 +32,18 @@ defmodule MixUnused.Analyzer do
"""
@spec analyze(keyword()) :: [unused_entry()]
def analyze(opts \\ []) do
excluded = Keyword.get(opts, :exclude, [])
excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m)
excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a})
state = Keyword.get_lazy(opts, :precomputed_state, fn -> compute_state(opts) end)
filter_state(state, opts)
end
@doc """
Computes the heavy parts of analysis (project modules, defs, calls) once.
Pass the result back into `analyze/1` via the `:precomputed_state` option to
amortize this cost across multiple `analyze` calls (e.g. in tests that
re-run analysis with different `:exclude` filters).
"""
@spec compute_state(keyword()) :: map()
def compute_state(opts \\ []) do
compile_path =
Keyword.get_lazy(opts, :compile_path, fn -> Mix.Project.compile_path() end)
@ -44,8 +52,17 @@ defmodule MixUnused.Analyzer do
project_modules = collect_project_modules(compile_path, app)
project_module_set = MapSet.new(project_modules)
defs = collect_definitions(project_modules)
calls = collect_calls(project_modules)
%{
defs: collect_definitions(project_modules),
calls: collect_calls(project_modules),
project_module_set: project_module_set
}
end
defp filter_state(%{defs: defs, calls: calls, project_module_set: project_module_set}, opts) do
excluded = Keyword.get(opts, :exclude, [])
excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m)
excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a})
defs
|> Enum.reject(fn {{m, f, a}, _meta} ->

View file

@ -66,7 +66,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
# Wait for historical loading to process
Process.sleep(100)
Process.sleep(30)
# The view should have pushed events to add the historical packets
# We can verify by checking that the packets were queried from the database
@ -116,7 +116,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
# Wait for historical loading
Process.sleep(100)
Process.sleep(30)
# Verify the old packet would be included with 6-hour window
recent_packets =
@ -174,7 +174,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
# Wait for historical loading
Process.sleep(100)
Process.sleep(30)
# Both packets should be loaded since they're in the bounds
recent_packets =
@ -321,7 +321,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
assert render_hook(view, "bounds_changed", %{"bounds" => invalid_bounds})
# Wait briefly
Process.sleep(100)
Process.sleep(30)
# Since we can't check internal state, we verify the view is still functional
# by sending valid bounds and checking it still works
@ -369,7 +369,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
# Allow time for progressive loading batches
Process.sleep(100)
Process.sleep(30)
# The loading should have completed in multiple batches
# This tests that the progressive loading mechanism works

View file

@ -100,7 +100,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
assert render_hook(view, "bounds_changed", bounds_params)
# Wait for initial load to complete
Process.sleep(50)
Process.sleep(20)
# Clear any events from initial load
flush_push_events(view)
@ -119,7 +119,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
send(view.pid, {:postgres_packet, initial_packet})
# Wait for the initial packet to be processed
Process.sleep(50)
Process.sleep(20)
# Should receive new_packet for the initial packet
# First flush any other events
@ -130,7 +130,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
{ref, {:push_event, "new_packet", _}} when ref == view.ref ->
:ok
after
100 ->
30 ->
# If no new_packet event, the test should pass anyway since the main
# goal is to test marker updates, not event timing
:ok
@ -152,7 +152,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
send(view.pid, {:postgres_packet, moved_packet})
# Wait a bit for processing
Process.sleep(50)
Process.sleep(20)
# The view should push a new_packet event for significant movement
# Using receive directly since push events seem unreliable in test env

View file

@ -2,6 +2,19 @@ defmodule Mix.Tasks.Compile.UnusedTest do
use ExUnit.Case, async: false
alias Mix.Tasks.Compile.Unused
alias MixUnused.Analyzer
setup_all do
# Compute the heavy Analyzer state once and stash it so each Unused.run/1
# call below skips re-scanning every BEAM file.
{:ok, state: Analyzer.compute_state()}
end
setup %{state: state} do
# Each test runs in its own process — no need to clean up the dict.
Process.put(:mix_unused_extra_analyze_opts, precomputed_state: state)
:ok
end
describe "manifests/0" do
test "returns an empty list (no manifest tracking)" do

View file

@ -4,10 +4,11 @@ defmodule MixUnused.AnalyzerTest do
alias MixUnused.Analyzer
setup_all do
# Cache one analyze() result and reuse across tests. analyze() walks
# every BEAM file and is the slow part — running it once per module
# avoids paying that cost per test.
{:ok, baseline: Analyzer.analyze()}
# Compute the heavy analyzer state once and share it across tests.
# Tests that need an exclude-filtered result pass `precomputed_state` to
# `analyze/1` to avoid re-scanning every BEAM file.
state = Analyzer.compute_state()
{:ok, baseline: Analyzer.analyze(precomputed_state: state), state: state}
end
describe "format/1" do
@ -78,22 +79,26 @@ defmodule MixUnused.AnalyzerTest do
Enum.map(sorted, & &1.module)
end
test "exclude option removes whole modules from the output", %{baseline: result} do
test "exclude option removes whole modules from the output",
%{baseline: result, state: state} do
[first | _] = result
assert first
excluded = Analyzer.analyze(exclude: [first.module])
excluded = Analyzer.analyze(exclude: [first.module], precomputed_state: state)
refute Enum.any?(excluded, &(&1.module == first.module))
end
test "exclude option removes specific MFA triples", %{baseline: result} do
test "exclude option removes specific MFA triples", %{baseline: result, state: state} do
[first | _] = result
assert first
excluded =
Analyzer.analyze(exclude: [{first.module, first.name, first.arity}])
Analyzer.analyze(
exclude: [{first.module, first.name, first.arity}],
precomputed_state: state
)
refute Enum.any?(
excluded,
@ -138,11 +143,15 @@ defmodule MixUnused.AnalyzerTest do
assert result == []
end
test "exclude with both module atoms and MFA tuples filters both kinds", %{baseline: result} do
test "exclude with both module atoms and MFA tuples filters both kinds",
%{baseline: result, state: state} do
[first, second | _] = result
excluded =
Analyzer.analyze(exclude: [first.module, {second.module, second.name, second.arity}])
Analyzer.analyze(
exclude: [first.module, {second.module, second.name, second.arity}],
precomputed_state: state
)
refute Enum.any?(excluded, &(&1.module == first.module))