Fault-injecting simulation of the replication protocol (§4.5, §5 of
the design doc), run against the
real dgen_registry code in a single BEAM.
formal/ proves an abstraction of the protocol
correct by checking every interleaving. This harness checks that the
implementation matches the protocol, by sampling interleavings of the real
code. The properties in DGen.Sim.Invariants have the same names as the
invariants in DgenRegistryReplication.tla. A violation found here should be
reproducible in the model, and the other way round. If it isn't, one of the two
is wrong about the system.
Running
mix dst # every :simulation-tagged sweep, on the dgen_mem backend
mix dst --seed 0 # extra arguments pass through to mix test
There are two suites:
test/dgen_registry_eta_test.exsis fully deterministic. It runs the cluster undereta_runondgen_mem, and one seed controls the scheduler, the clock and the network. Use it when a failure has to replay exactly. It checks that it is deterministic: each seed runs three times and must produce one trace, and everyeta_run:audit/1check is fatal. The sweeps are the lossy replication stream (drop_p) and node faults (fault_p).test/dgen_registry_sim_test.exsruns in real time on the real scheduler. The fault schedule is seeded, but process scheduling and timers are not, so a failure can be reproduced at the fault level but not bit-for-bit.
mix dst runs only the :simulation sweeps. The other tests in both suites run
under an ordinary mix test. The deterministic ones need
DGEN_BACKEND=dgen_mem, and test_helper.exs lists what each backend excludes.
A failure message includes its seed. Re-run that seed to reproduce it. A
recorded eta trace replays with DGen.Sim.RegistryHarness.replay_fixture/2.
test/fixtures/partial_batch.eta is the pinned example.
Mutation suites. Two defects can be compiled in on purpose to show the suite catches them. Both are opt-in, and the build is test-only:
DGEN_MUTATION=partial_batch DGEN_BACKEND=dgen_mem mix test --only mutation
DGEN_MUTATION=quiet_resync mix test --only mutation_quiet
Invariants
From DGen.Sim.Invariants. Each one returns :ok or {:violation, details}
and must not raise on a cluster that is partly torn down.
| When | Property |
|---|---|
| always | leader_epoch_unique/1 |
| at quiescence | same_version_same_replica/1 (check_quiescent/1) |
| after convergence | unique_binding/2, acked_bindings_present/2 |
end of an eta_run | RegistryHarness.check_final/2: acked_bindings_present/2 plus a UniqueBinding check over the history of yes acks (the spec's acked set) |
same_version_same_replica is checked only once the cluster is converged.
Members write their replica optimistically: route_unregister/3 deletes a row
straight away, and a follower inserts a row when the leader's yes arrives. In
both cases applied_version doesn't advance. So two members at the same version
can legitimately differ until the batch arrives. A difference that is still
there after healing and quiescence is a bug.
How it works
keyspace(dgen_registry:start_link/3) separates a member's identity from its durable keyspace. That lets N members with different names share one registry's elector queue, leader key and version key in one VM. Each member is a complete, real supervision tree.eta_net: every message between members goes throughdgen_registry_member:cast_to_member/2.eta_transformrewrites thegen_server:cast/2under it toeta_net:cast/2, soeta_netcontrols delivery. A module needs-include("dgen_eta.hrl")if it runs inside a scheduled process, even if it doesn't own one. The API modulesdgenanddgen_registryare examples.dgen_registry:status/1reports each member's own view of leader, epoch, sync state and applied version. The invariants compare these across members.DGen.Sim.Clusterstarts, faults and heals the cluster.DGen.Sim.RegistryHarnessadapts it toeta_harness: it starts the cluster, picks which processes to schedule, runs the workload and checks the result. Its moduledoc lists the real-time dependencies that had to be removed before runs became deterministic.
Fault model
Only faults that Erlang distribution can actually produce are injected. For each
ordered {from, to} pair, the network can deliver, drop, delay or
cut a message. It never reorders messages within a pair, which matches the
spec's DropMsg. Injecting reordering would only produce false positives.
A lost message comes with a link signal. In real distribution, messages are
lost because a link failed, and the failure sends nodedown/nodeup to both
ends. The registry relies on those signals: the member's {nodeup, _} handler re-joins
and re-drives stashed unregisters. So Cluster.converge/2 heals through
eta_net:heal_partition/3 with a nodeup signal. A fault model that leaves out
these signals produces failures that look like product bugs but aren't.
Topology decides what is faultable. Cluster.place_members/1 puts each
member's tree on its own simulated node. eta_net faults a send only when both
ends are faultable and on different nodes. Anything a member spawns inherits its
node and whether it can be faulted. The elector and connector are attached:
they are on the node but never on the wire, because their messages stand in for
operations on the durable store. If they were faultable, the run would produce
failures the real system can't have. If they weren't on any node, they would
survive a node kill that took down their own member.
Node faults
partition/4, heal_partition/4, isolate/3 and kill_node/3 on
DGen.Sim.Cluster inject link-level failures, along with the events each one
causes:
| messages | {nodedown, Peer} | noconnection DOWNs | processes die | |
|---|---|---|---|---|
set_policy loss | dropped at random | — | — | — |
cut/2 | dropped on one channel | — | — | — |
partition/4 | both directions cut | both sides | across the cut | — |
kill_node/3 | in flight cancelled | survivors | across the node | the whole tree |
crash/2 | — | — | — | the whole tree |
Each side gets its own signal: A is told {nodedown, member_2} and B is told
{nodedown, member_1}. %{learns: :a} makes the signal one-sided. With
kill_node/3, peer monitors report noconnection. With crash/2, they report
killed. The supervisor is frozen before the kill so one_for_all can't
restart the node.
Peer monitors are simulated (and so can be severed) only if both ends are already
on different simulated nodes when the monitor is created. Cluster.start/3's
:simulate_peer_monitors holds each member with sys:suspend/1 until every
tree has been placed. Without that, which monitors were simulated would depend
on real-time luck. It stays off in the real-time suite, because a simulated
monitor never fires on a crash when no scheduler is running.
eta_net:stats/0 counts dropped, signalled and noconnection. The sweeps
assert on these counts so that a run that injected nothing can't count as a
pass. stray_timers is allowed on the node-fault sweep only: a killed node's
timers outlive it, and a call blocked behind a cut waits on a virtual timeout
that the run never reaches.
Not simulated: net_kernel. A simulated node is only a name. nodes()
doesn't list it, and dgen_utils:node_reachable/1 returns true for every
member. So dgen_registry_member's signal-driven reactions are covered, but
dgen_registry_connector's node-keyed backstops (the {nodedown, Node} reap
and the leader-liveness probe) are not. Those belong in
dgen_registry_cluster_test.exs, which uses real peers.
Findings
Defects this harness found. All are fixed. Tests and the spec refer to them by number.
- Partially delivered group commit. A batch used to go out as one message
per name, all with the same version. A member that received only some of them
reported the full version, and gap detection couldn't see the hole. The fix
ships a batch as a single
{names_batch, …}message, which also matches the spec's one broadcast per version. Regression:"dropping a batch leaves a detectable gap that resyncs, not a silent hole". - Gap detection needed traffic. A follower that lost the tail of the stream
stayed behind for as long as the cluster was idle. The fix is the leader's
periodic empty-batch heartbeat. Regression:
"a follower that loses the tail of the stream still converges when quiescent". - Unprotected send in the mesh nudge. A send inside
try ... ofwasn't covered by thecatch, so sending to a member that had exited crashed the helper. dgenanddgen_registryweren't built with the eta transform. Theirgen_server:calls escaped the scheduler. This was a simulation-only gap.is_telemetry_available/0calledcode_serverfrom inside a member. It is now resolved ininit/1.set_metadata/2andunregister_name/1ignoredregister_timeout.- One unregister freed a name twice. An optimistic delete plus an
unconditional remove let a parked register get
yeswhile an acked binding still stood, and then deleted it.plan_op/3now clears a name only if the current holder is the pid the unregister targeted. Found by the ack-history check incheck_final/2. Replica comparisons can't catch it, because every member agrees on the same wrong history.