Skip to content

Commit b48d4a6

Browse files
committed
Add Elixir and OTP CI matrix and stabilize distributed tests
1 parent 53845ac commit b48d4a6

6 files changed

Lines changed: 159 additions & 27 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
test:
16+
name: ${{ matrix.latest && 'Latest stable Elixir / OTP' || format('Elixir {0} / OTP {1}', matrix.elixir, matrix.otp) }}
17+
runs-on: ubuntu-24.04
18+
timeout-minutes: 15
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
include:
23+
- elixir: "1.19"
24+
otp: "26"
25+
- elixir: "1.19"
26+
otp: "27"
27+
- elixir: "1.19"
28+
otp: "28"
29+
- elixir: "1.20"
30+
otp: "27"
31+
- elixir: "1.20"
32+
otp: "28"
33+
# Unlike "latest", these ranges exclude prereleases.
34+
- elixir: "> 0"
35+
otp: "> 0"
36+
latest: true
37+
env:
38+
MIX_ENV: test
39+
# Inherited by :peer nodes; bound scheduler usage for distributed tests.
40+
ERL_FLAGS: "+S 4:4"
41+
steps:
42+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
43+
with:
44+
persist-credentials: false
45+
46+
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
47+
with:
48+
elixir-version: ${{ matrix.elixir }}
49+
otp-version: ${{ matrix.otp }}
50+
51+
- name: Install dependencies
52+
run: mix deps.get
53+
54+
- name: Check formatting
55+
if: matrix.latest
56+
run: mix format --check-formatted
57+
58+
- name: Compile with warnings as errors
59+
run: mix compile --warnings-as-errors
60+
61+
- name: Run local and distributed tests
62+
run: mix test --warnings-as-errors

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,24 @@ mix test
400400
See [`test/README.md`](test/README.md) for details on the distributed test
401401
infrastructure.
402402

403+
GitHub Actions runs the full local and distributed suite on pushes and pull
404+
requests using Elixir 1.19 / OTP 26–28, Elixir 1.20 / OTP 27–28, and the latest
405+
stable Elixir / OTP pair. Version ranges pick up new patch releases automatically;
406+
the latest-stable job also picks up new minor and major releases, excluding
407+
prereleases. Every job treats compilation and test warnings as errors, and the
408+
latest-stable job checks formatting.
409+
410+
To run the same checks locally:
411+
412+
```bash
413+
export MIX_ENV=test
414+
export ERL_FLAGS="+S 4:4"
415+
mix deps.get
416+
mix format --check-formatted
417+
mix compile --warnings-as-errors
418+
mix test --warnings-as-errors
419+
```
420+
403421
## Benchmarks
404422

405423
```bash

‎test/README.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,10 @@ TestCluster.spawn_register_update_unregister(node_a, :test, "user/1", %{v: 1}, %
104104

105105
`spawn_register` accepts `flush_shards: num_shards` which calls
106106
`:sys.get_state` on the target shard's GenServer after registration. This
107-
blocks until all pending messages (nodedown, replicate, etc.) are processed
108-
on that shard — useful in partition tests where you need to guarantee ordering.
107+
synchronizes with that shard after the write; it is not a pre-write barrier
108+
and does not flush buffered replication. Use `TestCluster.flush_shards/2` to
109+
flush replication and `TestCluster.assert_group_nodes/3` to wait for peer
110+
discovery or nodedown cleanup on every shard.
109111

110112
```elixir
111113
TestCluster.spawn_register(node_a, :test, "key", %{}, flush_shards: 4)
@@ -136,6 +138,9 @@ TestCluster.monitor_nodes_on(node_a, self())
136138
assert_receive {:nodedown_on_remote, ^node_b}, 5000
137139
```
138140

141+
`monitor_nodes_on/2` returns only after the remote monitor is installed.
142+
Receiving its notification does not mean Group's shards have processed nodedown.
143+
139144
#### Network partitions
140145

141146
```elixir
@@ -148,6 +153,24 @@ partitions don't work reliably because the test node bridges them — Erlang
148153
distribution is fully meshed, so if the test node can reach both peers, they
149154
can reach each other through it.
150155

156+
Before disconnecting, wait for discovery on **every shard**, not just
157+
`Node.list/0` connectivity. In-flight discovery sends can otherwise trigger
158+
reconnect retries. After disconnecting, wait for every shard's peer state
159+
before writing partition-local data:
160+
161+
```elixir
162+
TestCluster.assert_group_nodes(node_a, :test, [node_b, node_c])
163+
TestCluster.assert_group_nodes(node_b, :test, [node_a, node_c])
164+
TestCluster.assert_group_nodes(node_c, :test, [node_a, node_b])
165+
166+
TestCluster.disconnect_nodes(node_c, node_a)
167+
TestCluster.disconnect_nodes(node_c, node_b)
168+
169+
TestCluster.assert_group_nodes(node_a, :test, [node_b])
170+
TestCluster.assert_group_nodes(node_b, :test, [node_a])
171+
TestCluster.assert_group_nodes(node_c, :test, [])
172+
```
173+
151174
#### Polling for eventual consistency
152175

153176
```elixir

‎test/distributed_test.exs‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -574,14 +574,11 @@ defmodule Group.DistributedTest do
574574

575575
start_group_on_peers(peers, opts)
576576

577-
# Wait for Erlang-level connectivity so disconnect_nodes actually works
578-
TestCluster.assert_eventually(
579-
fn ->
580-
c_nodes = TestCluster.rpc!(node_c, Node, :list, [])
581-
node_a in c_nodes and node_b in c_nodes
582-
end,
583-
timeout: 5000
584-
)
577+
# Finish discovery on every shard before cutting links. Erlang connectivity
578+
# alone can leave handshake sends in flight that trigger reconnect retries.
579+
TestCluster.assert_group_nodes(node_a, name, [node_b, node_c])
580+
TestCluster.assert_group_nodes(node_b, name, [node_a, node_c])
581+
TestCluster.assert_group_nodes(node_c, name, [node_a, node_b])
585582

586583
# Set up nodedown monitor on A
587584
TestCluster.monitor_nodes_on(node_a, self())
@@ -593,8 +590,12 @@ defmodule Group.DistributedTest do
593590
# Wait for A to confirm it saw C go down
594591
assert_receive {:nodedown_on_remote, ^node_c}, 5000
595592

593+
# A separate node monitor does not establish that every shard handled DOWN.
594+
TestCluster.assert_group_nodes(node_a, name, [node_b])
595+
TestCluster.assert_group_nodes(node_b, name, [node_a])
596+
TestCluster.assert_group_nodes(node_c, name, [])
597+
596598
# While partitioned: register keys on A, join groups on C
597-
# flush_shards ensures nodedown is processed before registering
598599
TestCluster.spawn_register(node_a, name, "user/from_a", %{origin: :a}, flush_shards: 2)
599600
TestCluster.spawn_join(node_c, name, "room/from_c", %{origin: :c})
600601

@@ -1970,14 +1971,10 @@ defmodule Group.DistributedTest do
19701971
length(nodes) >= 1
19711972
end)
19721973

1973-
# Wait for Erlang-level connectivity so disconnect_nodes actually works
1974-
TestCluster.assert_eventually(
1975-
fn ->
1976-
c_nodes = TestCluster.rpc!(node_c, Node, :list, [])
1977-
node_a in c_nodes and node_b in c_nodes
1978-
end,
1979-
timeout: 5000
1980-
)
1974+
# Wait for Group discovery, not just Erlang connectivity, before partitioning.
1975+
TestCluster.assert_group_nodes(node_a, name, [node_b, node_c])
1976+
TestCluster.assert_group_nodes(node_b, name, [node_a, node_c])
1977+
TestCluster.assert_group_nodes(node_c, name, [node_a, node_b])
19811978

19821979
# Set up nodedown monitors on A before partitioning
19831980
TestCluster.monitor_nodes_on(node_a, self())
@@ -1990,9 +1987,12 @@ defmodule Group.DistributedTest do
19901987
assert_receive {:nodedown_on_remote, ^node_c}, 5000
19911988

19921989
# Wait for Group's own peer tables to reflect the partition before writing.
1990+
TestCluster.assert_group_nodes(node_a, name, [node_b])
1991+
TestCluster.assert_group_nodes(node_b, name, [node_a])
1992+
TestCluster.assert_group_nodes(node_c, name, [])
1993+
19931994
TestCluster.assert_eventually(fn ->
1994-
node_c not in TestCluster.rpc!(node_a, Group, :nodes, [name]) and
1995-
node_c not in TestCluster.rpc!(node_a, Group, :nodes, [name, "game"])
1995+
node_c not in TestCluster.rpc!(node_a, Group, :nodes, [name, "game"])
19961996
end)
19971997

19981998
# Register data during partition

‎test/support/test_cluster.ex‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule Group.TestCluster do
1111
Enum.flat_map(code_paths, fn p -> [~c"-pa", p] end)
1212

1313
for _i <- 1..count do
14-
name = :"peer#{System.unique_integer([:positive])}"
14+
name = :"peer_#{System.pid()}_#{System.unique_integer([:positive])}"
1515

1616
{:ok, pid, node} =
1717
:peer.start(%{name: name, host: ~c"127.0.0.1", longnames: true, args: args})
@@ -302,10 +302,20 @@ defmodule Group.TestCluster do
302302
@doc "Monitor nodedown events from a remote node, forwarding to caller"
303303
def monitor_nodes_on(node, target_pid) do
304304
:erpc.call(node, fn ->
305-
spawn(fn ->
306-
:net_kernel.monitor_nodes(true)
307-
forward_nodedown(target_pid)
308-
end)
305+
parent = self()
306+
307+
pid =
308+
spawn(fn ->
309+
:net_kernel.monitor_nodes(true)
310+
send(parent, {:monitor_ready, self()})
311+
forward_nodedown(target_pid)
312+
end)
313+
314+
receive do
315+
{:monitor_ready, ^pid} -> pid
316+
after
317+
5000 -> raise "monitor_nodes_on timed out"
318+
end
309319
end)
310320
end
311321

@@ -397,6 +407,23 @@ defmodule Group.TestCluster do
397407
end)
398408
end
399409

410+
@doc "Wait for every shard and the shared peer table to agree on the expected Group peers."
411+
def assert_group_nodes(node, name, expected_nodes) do
412+
expected_nodes = Enum.sort(expected_nodes)
413+
414+
assert_eventually(fn ->
415+
:erpc.call(node, fn ->
416+
num_shards = Group.get_config(name).num_shards
417+
418+
Enum.sort(Group.nodes(name)) == expected_nodes and
419+
Enum.all?(0..(num_shards - 1), fn shard ->
420+
state = :sys.get_state(:"#{name}_replica_#{shard}")
421+
Enum.sort(Map.keys(state.remote_shards)) == expected_nodes
422+
end)
423+
end)
424+
end)
425+
end
426+
400427
@doc "Returns the current message_queue_len for a shard on a remote node."
401428
def shard_message_queue_len(node, name, shard) do
402429
:erpc.call(node, __MODULE__, :do_shard_message_queue_len, [name, shard])

‎test/test_helper.exs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ unless epmd_running?.() do
1414
end
1515

1616
unless Node.alive?() do
17-
{:ok, _} = Node.start(:"test_#{System.unique_integer([:positive])}@127.0.0.1", :longnames)
17+
# unique_integer is VM-local; include the OS pid to allow concurrent test runs.
18+
name = :"test_#{System.pid()}_#{System.unique_integer([:positive])}@127.0.0.1"
19+
{:ok, _} = Node.start(name, :longnames)
1820
Node.set_cookie(:group_test)
1921
end
2022

0 commit comments

Comments
 (0)