Skip to content

Commit 844c4c3

Browse files
authored
use a :queue to store modules in ExUnit.Server (#13636)
In order to have more deterministic test runs when using `--max-cases 1` and `--max-requires 1` (#13635) (see also #13589), we need to run tests in compilation order (FIFO). In the past, ExUnit.Server appended new tests to the front of a list, which would result in the most recently added test to be run first. Let's quickly demonstrate the problem this causes for deterministic runs with a simple example: Imagine a test (let's call if FooTest) that takes a non-deterministic amount of time to run. For now let's assume that it sometimes takes 1 second and sometimes up to 5. And as async tests execute in parallel with compilation of other test files, we could have the following scenario: FooTest is compiled and because it's async it is immediately started. It takes 1 second to run. In this 1 second two more tests are compiled. First BarTest is prepended to the list, then BazTest. The order of test runs now is: FooTest, BazTest, then whatever is last compiled while BazTest runs, ... Now another run, FooTest takes 5 seconds to run. While FooTest runs, more than two other tests are compiled. The order of test runs is: FooTest, LastCompiledTest, SecondLastCompiledTest, ..., BazTest, BarTest This can be fixed either by appending new test modules to the end of the list, or - and that's what this commit does - by using a `:queue` instead.
1 parent d6e6924 commit 844c4c3

File tree

10 files changed

+102
-33
lines changed

10 files changed

+102
-33
lines changed

lib/elixir/test/elixir/exception_test.exs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,8 @@ defmodule ExceptionTest do
430430
end
431431
)
432432

433-
:code.delete(OperatorPrecedence)
434433
:code.purge(OperatorPrecedence)
434+
:code.delete(OperatorPrecedence)
435435

436436
assert blame_message(OperatorPrecedence, & &1.test!(1, 2)) =~ """
437437
no function clause matching in ExceptionTest.OperatorPrecedence.test!/2
@@ -469,8 +469,8 @@ defmodule ExceptionTest do
469469
end
470470
)
471471

472-
:code.delete(Req)
473472
:code.purge(Req)
473+
:code.delete(Req)
474474

475475
assert blame_message(Req, & &1.get!(url: "https://elixir-lang.org")) =~ """
476476
no function clause matching in ExceptionTest.Req.get!/1
@@ -623,8 +623,8 @@ defmodule ExceptionTest do
623623
"""
624624

625625
for module <- modules do
626-
:code.delete(module)
627626
:code.purge(module)
627+
:code.delete(module)
628628
end
629629
end
630630

@@ -839,8 +839,8 @@ defmodule ExceptionTest do
839839
end
840840
)
841841

842-
:code.delete(Blaming)
843842
:code.purge(Blaming)
843+
:code.delete(Blaming)
844844

845845
{:ok, :def, clauses} = Exception.blame_mfa(Blaming, :with_elem, [1, 2])
846846

lib/elixir/test/elixir/inspect_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,8 +795,8 @@ defmodule Inspect.OthersTest do
795795
Application.put_env(:elixir, :anony, V.fun())
796796
Application.put_env(:elixir, :named, &V.fun/0)
797797

798-
:code.delete(V)
799798
:code.purge(V)
799+
:code.delete(V)
800800

801801
anony = Application.get_env(:elixir, :anony)
802802
named = Application.get_env(:elixir, :named)

lib/elixir/test/elixir/kernel/raise_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,8 @@ defmodule Kernel.RaiseTest do
434434

435435
fun = BadFunction.Missing.fun()
436436

437-
:code.delete(BadFunction.Missing)
438437
:code.purge(BadFunction.Missing)
438+
:code.delete(BadFunction.Missing)
439439

440440
defmodule BadFunction.Missing do
441441
def fun, do: fn -> :another end

lib/elixir/test/elixir/kernel/warning_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2233,7 +2233,7 @@ defmodule Kernel.WarningTest do
22332233
end
22342234

22352235
defp purge(module) when is_atom(module) do
2236-
:code.delete(module)
22372236
:code.purge(module)
2237+
:code.delete(module)
22382238
end
22392239
end

lib/elixir/test/elixir/kernel_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ defmodule KernelTest do
1111
def empty_map, do: %{}
1212

1313
defp purge(module) do
14-
:code.delete(module)
1514
:code.purge(module)
15+
:code.delete(module)
1616
end
1717

1818
defp assert_eval_raise(error, msg, string) do

lib/elixir/test/elixir/record_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,8 @@ defmodule RecordTest do
374374
end
375375

376376
defp purge(module) when is_atom(module) do
377-
:code.delete(module)
378377
:code.purge(module)
378+
:code.delete(module)
379379
end
380380
end
381381
end

lib/elixir/test/elixir/typespec_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ defmodule TypespecTest do
1414
unquote(block)
1515
end
1616

17-
:code.delete(TypespecSample)
1817
:code.purge(TypespecSample)
18+
:code.delete(TypespecSample)
1919
bytecode
2020
end
2121
end
@@ -1070,8 +1070,8 @@ defmodule TypespecTest do
10701070
{TypeModuleAttributes, _}}
10711071
] = TypeModuleAttributes.typep2()
10721072
after
1073-
:code.delete(TypeModuleAttributes)
10741073
:code.purge(TypeModuleAttributes)
1074+
:code.delete(TypeModuleAttributes)
10751075
end
10761076

10771077
test "@spec, @callback, and @macrocallback as module attributes" do
@@ -1129,8 +1129,8 @@ defmodule TypespecTest do
11291129
{SpecModuleAttributes, _}}
11301130
] = SpecModuleAttributes.macrocallback()
11311131
after
1132-
:code.delete(SpecModuleAttributes)
11331132
:code.purge(SpecModuleAttributes)
1133+
:code.delete(SpecModuleAttributes)
11341134
end
11351135

11361136
test "@callback" do

lib/ex_unit/lib/ex_unit/server.ex

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ defmodule ExUnit.Server do
5151
state = %{
5252
loaded: System.monotonic_time(),
5353
waiting: nil,
54-
async_modules: [],
55-
sync_modules: []
54+
async_modules: :queue.new(),
55+
sync_modules: :queue.new()
5656
}
5757

5858
{:ok, state}
@@ -65,8 +65,11 @@ defmodule ExUnit.Server do
6565

6666
# Called once after all async modules have been sent and reverts the state.
6767
def handle_call(:take_sync_modules, _from, state) do
68-
%{waiting: nil, loaded: :done, async_modules: []} = state
69-
{:reply, state.sync_modules, %{state | sync_modules: [], loaded: System.monotonic_time()}}
68+
%{waiting: nil, loaded: :done, async_modules: async_modules} = state
69+
0 = :queue.len(async_modules)
70+
71+
{:reply, :queue.to_list(state.sync_modules),
72+
%{state | sync_modules: :queue.new(), loaded: System.monotonic_time()}}
7073
end
7174

7275
# Called by the runner when --repeat-until-failure is used.
@@ -75,8 +78,8 @@ defmodule ExUnit.Server do
7578
%{
7679
state
7780
| loaded: :done,
78-
async_modules: async_modules,
79-
sync_modules: sync_modules
81+
async_modules: :queue.from_list(async_modules),
82+
sync_modules: :queue.from_list(sync_modules)
8083
}}
8184
end
8285

@@ -88,10 +91,13 @@ defmodule ExUnit.Server do
8891
when is_integer(loaded) do
8992
state =
9093
if uniq? do
94+
async_modules = :queue.to_list(state.async_modules) |> Enum.uniq() |> :queue.from_list()
95+
sync_modules = :queue.to_list(state.sync_modules) |> Enum.uniq() |> :queue.from_list()
96+
9197
%{
9298
state
93-
| async_modules: Enum.uniq(state.async_modules),
94-
sync_modules: Enum.uniq(state.sync_modules)
99+
| async_modules: async_modules,
100+
sync_modules: sync_modules
95101
}
96102
else
97103
state
@@ -103,13 +109,20 @@ defmodule ExUnit.Server do
103109

104110
def handle_call({:add, true, names}, _from, %{loaded: loaded} = state)
105111
when is_integer(loaded) do
106-
state = update_in(state.async_modules, &(names ++ &1))
112+
state =
113+
update_in(
114+
state.async_modules,
115+
&Enum.reduce(names, &1, fn name, q -> :queue.in(name, q) end)
116+
)
117+
107118
{:reply, :ok, take_modules(state)}
108119
end
109120

110121
def handle_call({:add, false, names}, _from, %{loaded: loaded} = state)
111122
when is_integer(loaded) do
112-
state = update_in(state.sync_modules, &(names ++ &1))
123+
state =
124+
update_in(state.sync_modules, &Enum.reduce(names, &1, fn name, q -> :queue.in(name, q) end))
125+
113126
{:reply, :ok, state}
114127
end
115128

@@ -120,18 +133,35 @@ defmodule ExUnit.Server do
120133
state
121134
end
122135

123-
defp take_modules(%{waiting: {from, _count}, async_modules: [], loaded: :done} = state) do
124-
GenServer.reply(from, nil)
125-
%{state | waiting: nil}
126-
end
136+
defp take_modules(%{waiting: {from, count}} = state) do
137+
has_async_modules? = not :queue.is_empty(state.async_modules)
127138

128-
defp take_modules(%{async_modules: []} = state) do
129-
state
139+
cond do
140+
not has_async_modules? and state.loaded == :done ->
141+
GenServer.reply(from, nil)
142+
%{state | waiting: nil}
143+
144+
not has_async_modules? ->
145+
state
146+
147+
true ->
148+
{modules, async_modules} = take_until(count, state.async_modules)
149+
GenServer.reply(from, modules)
150+
%{state | async_modules: async_modules, waiting: nil}
151+
end
130152
end
131153

132-
defp take_modules(%{waiting: {from, count}, async_modules: modules} = state) do
133-
{reply, modules} = Enum.split(modules, count)
134-
GenServer.reply(from, reply)
135-
%{state | async_modules: modules, waiting: nil}
154+
# :queue.split fails if the provided count is larger than the queue size;
155+
# as we also want to return the values as a list later, we directly
156+
# return {list, queue} instead of {queue, queue}
157+
defp take_until(n, queue), do: take_until(n, queue, [])
158+
159+
defp take_until(0, queue, acc), do: {Enum.reverse(acc), queue}
160+
161+
defp take_until(n, queue, acc) do
162+
case :queue.out(queue) do
163+
{{:value, item}, queue} -> take_until(n - 1, queue, [item | acc])
164+
{:empty, queue} -> {Enum.reverse(acc), queue}
165+
end
136166
end
137167
end

lib/ex_unit/test/ex_unit/assertions_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ defmodule ExUnit.AssertionsTest do
199199
""")
200200
end) =~ "variable \"var\" is unused"
201201
after
202-
:code.delete(ExSample)
203202
:code.purge(ExSample)
203+
:code.delete(ExSample)
204204
end
205205

206206
test "assert match with quote on left-side" do

lib/ex_unit/test/ex_unit_test.exs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,45 @@ defmodule ExUnitTest do
10541054
assert output =~ "2 tests, 0 failures, 2 excluded\n"
10551055
end
10561056

1057+
test "tests are run in compile order (FIFO)" do
1058+
defmodule FirstTestFIFO do
1059+
use ExUnit.Case
1060+
1061+
test "first test" do
1062+
assert true
1063+
end
1064+
end
1065+
1066+
defmodule SecondTestFIFO do
1067+
use ExUnit.Case
1068+
1069+
test "second test" do
1070+
assert true
1071+
end
1072+
end
1073+
1074+
defmodule ThirdTestFIFO do
1075+
use ExUnit.Case
1076+
1077+
test "third test" do
1078+
assert true
1079+
end
1080+
end
1081+
1082+
configure_and_reload_on_exit(trace: true)
1083+
1084+
output =
1085+
capture_io(fn ->
1086+
assert ExUnit.run() == %{total: 3, failures: 0, excluded: 0, skipped: 0}
1087+
end)
1088+
1089+
[_, first, second, third | _] = String.split(output, "\n\n")
1090+
1091+
assert first =~ "FirstTestFIFO"
1092+
assert second =~ "SecondTestFIFO"
1093+
assert third =~ "ThirdTestFIFO"
1094+
end
1095+
10571096
## Helpers
10581097

10591098
defp run_with_filter(filters, cases) do

0 commit comments

Comments
 (0)