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.
|
||||
_ = 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)
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue