Skip to content

Commit 29c12fb

Browse files
authored
Merge pull request #20 from phoenixframework/fix-jepsen-repair-coverage
Require receiver commits for Jepsen repair coverage
2 parents 02e7d28 + 1d19575 commit 29c12fb

15 files changed

Lines changed: 1089 additions & 44 deletions

‎test/jepsen/Dockerfile.node‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ ENV MIX_ENV=prod
1111
COPY mix.exs mix.lock ./
1212
COPY lib ./lib
1313
COPY test/jepsen/node.exs ./test/jepsen/node.exs
14+
COPY test/jepsen/repair_coverage.exs ./test/jepsen/repair_coverage.exs
1415
COPY test/support/test_tcp_transport.ex ./test/support/test_tcp_transport.ex
1516

1617
RUN mix local.hex --force \

‎test/jepsen/README.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,65 @@ cannot masquerade as a current owner. Every history explicitly restarts one
5656
node after the deterministic conflict prelude, proving the checker does not
5757
mistake restart-sensitive instrumentation for missing protocol coverage.
5858

59+
Registry-conflict exits are obligations, not trusted coverage counters. Before
60+
calling Group, each owner journals its registration attempt independently of
61+
Group's tables; it journals successful or rejected replies, unregisters, and
62+
cluster-intent removal as well. Drivers retain the victim token, key, and
63+
winner metadata from every conflict death. The checker reconstructs the
64+
victim's registrations, requires the reported winner to rank strictly higher
65+
by `{revision, token}`, and requires a matching historical winning claim in
66+
the same cluster and key. Only validated deaths count toward coverage.
67+
68+
Registration calls interrupted by death or an indeterminate reply remain
69+
possible claims: Group may have installed them before the owner could record
70+
success. A definitive `:taken` reply excludes an attempt. Winning evidence is
71+
retained after unregister, death, and BEAM restart because delayed replicas
72+
can legitimately act on an older claim. Local journal order excludes winners
73+
first attempted after a death; the oracle does not invent a global clock or
74+
infer remote deletion delivery from wall time. This establishes independently
75+
witnessed possible winners, not the exact instant a replica learned a claim.
76+
77+
The append-only conflict journal retains small operation records for the
78+
bounded campaign, not production ETS rows. Its path defaults to
79+
`/tmp/group-jepsen-conflict-evidence` inside each container and can be overridden
80+
with the driver's `:conflict_evidence_path` option. Corrupt or unreadable
81+
journals fail closed. At permanent retirement, the stopped-container collector
82+
archives and decodes the journal alongside unexpected deaths. The checker
83+
replays retired and surviving nodes' evidence together, without treating retired
84+
owners as live. Conflict archives are bounded to 64 MiB with ten-second command
85+
deadlines; missing or malformed archives fail the history.
86+
87+
Each new history resets the running recorder through the harness socket after
88+
DB restart and before workload mutations. This clears disk and in-memory
89+
evidence together and initializes an empty journal, so repeated histories
90+
cannot inherit earlier conflict coverage.
91+
92+
Checker qualification also loads the real Elixir
93+
Owner/Driver harness, injects valid and forged death reasons, exercises a
94+
register interrupted before its reply, and checks the emitted EDN with the
95+
Clojure lifecycle oracle.
96+
97+
Repair coverage is receiver evidence, not transport admission. Before starting
98+
Group, the Jepsen VM compiles test-only wrappers around the existing delta-run
99+
and terminal snapshot-install functions. Each wrapper samples the same stream's
100+
cursor before and after its original body in one shard turn. Only positive
101+
committed advancement emits `applied-delta-run-records-peak`; a completed snapshot
102+
with a multi-chunk manifest emits `multi-chunk-snapshot-committed`. Duplicate
103+
prefixes do not contribute to the delta peak. Provisional chunks, missing
104+
terminal frames, rejected authority, and logical drops contribute neither.
105+
The wrapper installation fails if either expected boundary disappears.
106+
107+
The receiver evidence owner persists increasing maxima outside the shard and
108+
reloads them after VM restart. A crash before persistence can lose evidence
109+
(failing coverage conservatively), but cannot create it. This instrumentation
110+
is shared by distribution, TCP, and chaos and changes no production BEAM or API.
111+
Sender `attempted-*` peaks remain diagnostics only. The pure checker reads the
112+
same EDN fixture that executable receiver regressions compare to live output.
113+
59114
## Requirements
60115

61116
- Docker with Compose v2
117+
- Elixir/Mix with the repository dependencies installed (checker qualification)
62118
- Java 21 or newer
63119
- `curl`
64120

‎test/jepsen/conflict_probe.exs‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Invoked by the Clojure checker qualification. Load the actual harness without
2+
# starting its TCP server; no copied Owner/Driver logic lives in this probe.
3+
System.put_env("GROUP_JEPSEN_LIBRARY_ONLY", "1")
4+
Code.require_file("node.exs", __DIR__)
5+
Application.ensure_all_started(:group)
6+
Logger.configure(level: :emergency)
7+
8+
defmodule Group.Jepsen.ConflictProbe do
9+
alias Group.Jepsen.{ConflictEvidence, Driver, EDN}
10+
11+
def run do
12+
directory = Path.join(__DIR__, ".cache/conflict-probe-#{System.unique_integer([:positive])}")
13+
File.mkdir_p!(directory)
14+
15+
try do
16+
{winner, rejected, winner_events, archive} = winner(directory)
17+
18+
cases = [
19+
{"historical winner subsequently unregistered and died", true, 1, "jepsen/registry/0",
20+
winner, false},
21+
{"victim killed during register", true, 1, "jepsen/registry/0", winner, true},
22+
{"forged key and rank", false, 99, "nonexistent", %{token: "invented", revision: -100},
23+
false},
24+
{"rightful winner killed", false, 99, "jepsen/registry/0", winner, false},
25+
{"nonexistent winning claim", false, 1, "jepsen/registry/0",
26+
%{token: "invented", revision: 100}, false},
27+
{"definitively rejected winning claim", false, 1, "jepsen/registry/0", rejected, false}
28+
]
29+
30+
scenarios =
31+
Enum.with_index(cases, fn {label, valid, revision, key, meta, pending}, index ->
32+
{events, reset_events} = victim(directory, index, revision, key, meta, pending)
33+
34+
%{
35+
label: label,
36+
valid: valid,
37+
archive: archive,
38+
reset_evidence: reset_events,
39+
snapshots: %{
40+
"n1" => %{conflict_evidence: events},
41+
"n2" => %{conflict_evidence: winner_events}
42+
}
43+
}
44+
end)
45+
46+
IO.puts("CONFLICT-PROBE " <> EDN.encode(scenarios))
47+
after
48+
File.rm_rf!(directory)
49+
end
50+
end
51+
52+
defp start(directory, label, node_id) do
53+
{:ok, group} = Group.start_link(name: :jepsen_group, shards: 1, log: false)
54+
path = Path.join(directory, label)
55+
{:ok, evidence} = ConflictEvidence.start_link(conflict_evidence_path: path)
56+
{:ok, driver} = Driver.start_link(index: 0, node_id: node_id, boot_id: label)
57+
{group, evidence, driver, path}
58+
end
59+
60+
defp mutate(driver, operation, revision) do
61+
GenServer.call(driver, {:mutate, operation, "owner", nil, 0, revision})
62+
end
63+
64+
defp winner(directory) do
65+
{group, evidence, driver, path} = start(directory, "winner", "n2")
66+
%{status: :ok, owner: %{token: token}} = mutate(driver, :register, 10)
67+
68+
%{status: :fail, owner: %{token: rejected}} =
69+
GenServer.call(driver, {:mutate, :register, "rejected", nil, 0, 100})
70+
71+
%{status: :ok} = mutate(driver, :unregister, 0)
72+
%{status: :ok} = GenServer.call(driver, {:kill, "owner"})
73+
%{status: :ok} = GenServer.call(driver, {:kill, "rejected"})
74+
events = ConflictEvidence.snapshot()
75+
archive = File.read!(path)
76+
GenServer.stop(driver)
77+
GenServer.stop(evidence)
78+
Supervisor.stop(group)
79+
{%{token: token, revision: 10}, %{token: rejected, revision: 100}, events, archive}
80+
end
81+
82+
defp victim(directory, index, revision, key, winner, pending) do
83+
{group, evidence, driver, path} = start(directory, "victim-#{index}", "n1")
84+
%{status: :ok} = mutate(driver, :join, 0)
85+
{pid, _token, _monitor, _cached} = :sys.get_state(driver).owners["owner"]
86+
shard = Group.Replica.shard_for(:jepsen_group, nil, "jepsen/registry/0")
87+
88+
task =
89+
if pending do
90+
:ok = :sys.suspend(shard)
91+
task = Task.async(fn -> mutate(driver, :register, revision) end)
92+
wait(fn -> Enum.any?(ConflictEvidence.snapshot(), &(&1.kind == :register)) end)
93+
task
94+
else
95+
%{status: :ok} = mutate(driver, :register, revision)
96+
nil
97+
end
98+
99+
Process.exit(pid, {:group_registry_conflict, key, winner})
100+
if task, do: Task.await(task)
101+
wait(fn -> Enum.any?(ConflictEvidence.snapshot(), &(&1.kind == :death)) end)
102+
if pending, do: :sys.resume(shard)
103+
events = ConflictEvidence.snapshot()
104+
[] = GenServer.call(driver, :unexpected_deaths)
105+
{:ok, []} = GenServer.call(driver, :owner_snapshots)
106+
GenServer.stop(driver)
107+
GenServer.stop(evidence)
108+
109+
# The exact registration/death obligations must survive a recorder restart.
110+
{:ok, evidence} = ConflictEvidence.start_link(conflict_evidence_path: path)
111+
^events = ConflictEvidence.snapshot()
112+
:ok = ConflictEvidence.reset()
113+
[] = ConflictEvidence.snapshot()
114+
"" = File.read!(path)
115+
GenServer.stop(evidence)
116+
{:ok, evidence} = ConflictEvidence.start_link(conflict_evidence_path: path)
117+
[] = ConflictEvidence.snapshot()
118+
119+
1 =
120+
ConflictEvidence.record(%{
121+
kind: :register,
122+
token: "next-history",
123+
key: 0,
124+
cluster: nil,
125+
revision: 1
126+
})
127+
128+
:ok = ConflictEvidence.reset()
129+
reset_events = ConflictEvidence.snapshot()
130+
GenServer.stop(evidence)
131+
Supervisor.stop(group)
132+
{events, reset_events}
133+
end
134+
135+
defp wait(fun, remaining \\ 200)
136+
defp wait(_fun, 0), do: raise("probe timed out")
137+
138+
defp wait(fun, remaining) do
139+
unless fun.() do
140+
Process.sleep(5)
141+
wait(fun, remaining - 1)
142+
end
143+
end
144+
end
145+
146+
Group.Jepsen.ConflictProbe.run()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
System.put_env("GROUP_JEPSEN_LIBRARY_ONLY", "1")
2+
Code.require_file("node.exs", __DIR__)
3+
4+
[path] = System.argv()
5+
events = path |> File.read!() |> Group.Jepsen.ConflictEvidence.decode()
6+
IO.puts("CONFLICT-EVIDENCE " <> Group.Jepsen.EDN.encode(events))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{:applied-delta-run-records-peak 2 :multi-chunk-snapshot-committed 1}

‎test/jepsen/metadata_capture.exs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
System.put_env("GROUP_JEPSEN_LIBRARY", "1")
2+
Code.require_file("repair_coverage.exs", __DIR__)
23
Code.require_file("node.exs", __DIR__)
34

45
alias Group.Jepsen.{Driver, EDN, Snapshot}
56
alias Group.Replica.Data
67

8+
repair_path =
9+
Path.join(
10+
System.tmp_dir!(),
11+
"group-jepsen-metadata-repair-#{System.unique_integer([:positive])}"
12+
)
13+
14+
{:ok, _} = Group.Jepsen.RepairCoverage.start_link(path: repair_path)
715
{:ok, _} = Group.Jepsen.Transport.Stats.start_link([])
816

917
{:ok, _} =
@@ -74,3 +82,4 @@ Enum.each(originals, fn {table, rows} -> :ets.insert(table, rows) end)
7482
false = reincarnated.token == owner.token
7583

7684
File.write!(hd(System.argv()), EDN.encode(%{healthy: healthy, corruptions: corruptions}))
85+
File.rm(repair_path)

0 commit comments

Comments
 (0)