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:
parent
4c5c730ee7
commit
5184d2ee5e
6 changed files with 68 additions and 24 deletions
|
|
@ -69,8 +69,13 @@ defmodule Mix.Tasks.Compile.Unused do
|
||||||
# Make sure the project is compiled before analyzing.
|
# Make sure the project is compiled before analyzing.
|
||||||
_ = Mix.Task.run("compile", [])
|
_ = 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
|
config
|
||||||
|> filter_options()
|
|> filter_options()
|
||||||
|
|> Keyword.merge(extra_analyze_opts)
|
||||||
|> Analyzer.analyze()
|
|> Analyzer.analyze()
|
||||||
|> filter_test_files(Keyword.get(config, :ignore_test_files, true))
|
|> filter_test_files(Keyword.get(config, :ignore_test_files, true))
|
||||||
|> emit(severity)
|
|> emit(severity)
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,18 @@ defmodule MixUnused.Analyzer do
|
||||||
"""
|
"""
|
||||||
@spec analyze(keyword()) :: [unused_entry()]
|
@spec analyze(keyword()) :: [unused_entry()]
|
||||||
def analyze(opts \\ []) do
|
def analyze(opts \\ []) do
|
||||||
excluded = Keyword.get(opts, :exclude, [])
|
state = Keyword.get_lazy(opts, :precomputed_state, fn -> compute_state(opts) end)
|
||||||
excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m)
|
filter_state(state, opts)
|
||||||
excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a})
|
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 =
|
compile_path =
|
||||||
Keyword.get_lazy(opts, :compile_path, fn -> Mix.Project.compile_path() end)
|
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_modules = collect_project_modules(compile_path, app)
|
||||||
project_module_set = MapSet.new(project_modules)
|
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
|
defs
|
||||||
|> Enum.reject(fn {{m, f, a}, _meta} ->
|
|> Enum.reject(fn {{m, f, a}, _meta} ->
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
|
||||||
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
||||||
|
|
||||||
# Wait for historical loading to process
|
# Wait for historical loading to process
|
||||||
Process.sleep(100)
|
Process.sleep(30)
|
||||||
|
|
||||||
# The view should have pushed events to add the historical packets
|
# The view should have pushed events to add the historical packets
|
||||||
# We can verify by checking that the packets were queried from the database
|
# 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})
|
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
||||||
|
|
||||||
# Wait for historical loading
|
# Wait for historical loading
|
||||||
Process.sleep(100)
|
Process.sleep(30)
|
||||||
|
|
||||||
# Verify the old packet would be included with 6-hour window
|
# Verify the old packet would be included with 6-hour window
|
||||||
recent_packets =
|
recent_packets =
|
||||||
|
|
@ -174,7 +174,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
|
||||||
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
||||||
|
|
||||||
# Wait for historical loading
|
# Wait for historical loading
|
||||||
Process.sleep(100)
|
Process.sleep(30)
|
||||||
|
|
||||||
# Both packets should be loaded since they're in the bounds
|
# Both packets should be loaded since they're in the bounds
|
||||||
recent_packets =
|
recent_packets =
|
||||||
|
|
@ -321,7 +321,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoadingTest do
|
||||||
assert render_hook(view, "bounds_changed", %{"bounds" => invalid_bounds})
|
assert render_hook(view, "bounds_changed", %{"bounds" => invalid_bounds})
|
||||||
|
|
||||||
# Wait briefly
|
# Wait briefly
|
||||||
Process.sleep(100)
|
Process.sleep(30)
|
||||||
|
|
||||||
# Since we can't check internal state, we verify the view is still functional
|
# Since we can't check internal state, we verify the view is still functional
|
||||||
# by sending valid bounds and checking it still works
|
# 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})
|
assert render_hook(view, "bounds_changed", %{"bounds" => bounds})
|
||||||
|
|
||||||
# Allow time for progressive loading batches
|
# Allow time for progressive loading batches
|
||||||
Process.sleep(100)
|
Process.sleep(30)
|
||||||
|
|
||||||
# The loading should have completed in multiple batches
|
# The loading should have completed in multiple batches
|
||||||
# This tests that the progressive loading mechanism works
|
# This tests that the progressive loading mechanism works
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
assert render_hook(view, "bounds_changed", bounds_params)
|
assert render_hook(view, "bounds_changed", bounds_params)
|
||||||
|
|
||||||
# Wait for initial load to complete
|
# Wait for initial load to complete
|
||||||
Process.sleep(50)
|
Process.sleep(20)
|
||||||
|
|
||||||
# Clear any events from initial load
|
# Clear any events from initial load
|
||||||
flush_push_events(view)
|
flush_push_events(view)
|
||||||
|
|
@ -119,7 +119,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
send(view.pid, {:postgres_packet, initial_packet})
|
send(view.pid, {:postgres_packet, initial_packet})
|
||||||
|
|
||||||
# Wait for the initial packet to be processed
|
# Wait for the initial packet to be processed
|
||||||
Process.sleep(50)
|
Process.sleep(20)
|
||||||
|
|
||||||
# Should receive new_packet for the initial packet
|
# Should receive new_packet for the initial packet
|
||||||
# First flush any other events
|
# First flush any other events
|
||||||
|
|
@ -130,7 +130,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
{ref, {:push_event, "new_packet", _}} when ref == view.ref ->
|
{ref, {:push_event, "new_packet", _}} when ref == view.ref ->
|
||||||
:ok
|
:ok
|
||||||
after
|
after
|
||||||
100 ->
|
30 ->
|
||||||
# If no new_packet event, the test should pass anyway since the main
|
# If no new_packet event, the test should pass anyway since the main
|
||||||
# goal is to test marker updates, not event timing
|
# goal is to test marker updates, not event timing
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -152,7 +152,7 @@ defmodule AprsmeWeb.MapLive.MovementTest do
|
||||||
send(view.pid, {:postgres_packet, moved_packet})
|
send(view.pid, {:postgres_packet, moved_packet})
|
||||||
|
|
||||||
# Wait a bit for processing
|
# Wait a bit for processing
|
||||||
Process.sleep(50)
|
Process.sleep(20)
|
||||||
|
|
||||||
# The view should push a new_packet event for significant movement
|
# The view should push a new_packet event for significant movement
|
||||||
# Using receive directly since push events seem unreliable in test env
|
# Using receive directly since push events seem unreliable in test env
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,19 @@ defmodule Mix.Tasks.Compile.UnusedTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
alias Mix.Tasks.Compile.Unused
|
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
|
describe "manifests/0" do
|
||||||
test "returns an empty list (no manifest tracking)" do
|
test "returns an empty list (no manifest tracking)" do
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ defmodule MixUnused.AnalyzerTest do
|
||||||
alias MixUnused.Analyzer
|
alias MixUnused.Analyzer
|
||||||
|
|
||||||
setup_all do
|
setup_all do
|
||||||
# Cache one analyze() result and reuse across tests. analyze() walks
|
# Compute the heavy analyzer state once and share it across tests.
|
||||||
# every BEAM file and is the slow part — running it once per module
|
# Tests that need an exclude-filtered result pass `precomputed_state` to
|
||||||
# avoids paying that cost per test.
|
# `analyze/1` to avoid re-scanning every BEAM file.
|
||||||
{:ok, baseline: Analyzer.analyze()}
|
state = Analyzer.compute_state()
|
||||||
|
{:ok, baseline: Analyzer.analyze(precomputed_state: state), state: state}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "format/1" do
|
describe "format/1" do
|
||||||
|
|
@ -78,22 +79,26 @@ defmodule MixUnused.AnalyzerTest do
|
||||||
Enum.map(sorted, & &1.module)
|
Enum.map(sorted, & &1.module)
|
||||||
end
|
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
|
[first | _] = result
|
||||||
|
|
||||||
assert first
|
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))
|
refute Enum.any?(excluded, &(&1.module == first.module))
|
||||||
end
|
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
|
[first | _] = result
|
||||||
|
|
||||||
assert first
|
assert first
|
||||||
|
|
||||||
excluded =
|
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?(
|
refute Enum.any?(
|
||||||
excluded,
|
excluded,
|
||||||
|
|
@ -138,11 +143,15 @@ defmodule MixUnused.AnalyzerTest do
|
||||||
assert result == []
|
assert result == []
|
||||||
end
|
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
|
[first, second | _] = result
|
||||||
|
|
||||||
excluded =
|
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))
|
refute Enum.any?(excluded, &(&1.module == first.module))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue