A worked example: two-phase commit

Copy Markdown

eta ships with a small two-phase commit implementation as an example system under test. It lives in test/support/eta_2pc*.erl: about 170 lines of protocol plus 150 of harness, with a bug planted in it that only an unlucky interleaving reveals.

This page reads through that example. If you'd rather build something from scratch, A Journey Through DST does that instead.

AI disclosure

This document is pending a human rewrite. We still expect the content to be mostly correct.

The protocol

A coordinator wants a group of participants to agree on a transaction. It asks each one to prepare, and each votes yes or no. If every vote is yes the coordinator decides commit, otherwise abort, and it tells everyone. The property that has to hold is atomicity: no 2 participants may reach opposite conclusions about the same transaction.

3 processes vote, 1 decides, and a client waits for the answer.

The participants

A participant is an ordinary gen_server with no timers, so it needs no parse transform at all:

handle_cast({prepare, TxId, Coordinator}, St = #{tab := Tab, index := Index}) ->
    case plan(Tab, TxId, Index) of
        stall ->
            {noreply, St};
        Vote ->
            record(Tab, TxId, Index, {prepared, Vote}),
            gen_server:cast(Coordinator, {vote, TxId, Index, Vote}),
            {noreply, St}
    end;

A participant's plan, meaning what it will vote for a given transaction, isn't decided by the participant. The driver writes it into a shared ETS table before the transaction starts and the participant looks it up. This makes the workload part of the seeded, replayable state rather than something each process invents for itself.

stall means the participant never votes. That case exists to put the virtual clock on the critical path: with one participant silent, the only thing that can resolve the transaction is the coordinator's prepare timeout.

Everything the participant decides also goes into that table rather than into its gen_server state, because the atomicity invariant has to read it while every one of these processes is suspended. eta_observe could also make that state readable, and the choice between the 2 comes up on every system. Our rule of thumb: if the system already keeps the state somewhere readable, read it there. If the state exists only inside a process, publish it. The table wins here because the driver has to write plans into it anyway, and because an accumulating history is the wrong shape for eta_observe, which publishes a snapshot of current fields.

The coordinator, and the timer

The coordinator is where the header is needed, for exactly one line:

-include_lib("eta/include/eta.hrl").

-define(VOTE_TIMEOUT, 30000).

handle_call({run_tx, TxId}, From, St) ->
    #{participants := Participants, pending := Pending} = St,
    Self = self(),
    [gen_server:cast(P, {prepare, TxId, Self}) || {_Index, P} <- Participants],
    Timer = erlang:send_after(?VOTE_TIMEOUT, self(), {vote_timeout, TxId}),
    ...

Under a simulation build the header brings the parse transform, so that erlang:send_after/3 becomes eta_time:send_after/3. Under a release build it brings nothing and the line ships as written.

This module lives inside eta's own test suite, so it includes eta.hrl directly. In your project, include a wrapper header of your own instead, so that builds without DST don't need eta on the path at all. Setting up a project has it.

30 seconds is long enough that the timeout never fires by accident and short enough to be interesting, and it costs nothing because it's virtual. When every participant has stalled and no process is runnable, the driver advances the clock straight to that deadline and the run continues.

The planted bug

The coordinator has a second mode, selectable by config, in which it decides on the first vote to arrive rather than waiting for all of them:

on_vote(TxId, _Index, Vote, _Tx, St = #{mode := first_vote_wins}) ->
    Decision =
        case Vote of
            yes -> commit;
            no  -> abort
        end,
    {noreply, decide(TxId, Decision, St)};

Most of the time this behaves correctly. If a no arrives first the transaction aborts. If every vote is yes it commits. Either way everybody agrees. The defect is visible only when a yes wins the race and a no arrives afterwards, and even then only because a participant that voted no refuses to commit regardless of what it's told:

Final =
    case {Decision, plan(Tab, TxId, Index)} of
        {commit, no} -> aborted;
        {commit, _}  -> committed;
        {abort, _}   -> aborted
    end,

That refusal converts a wrong decision into a visible atomicity violation. Without it the coordinator would be quietly wrong and every participant would agree with it, which is a nastier class of bug and one this example doesn't try to model.

So reaching the defect needs at least one no vote in a transaction, plus a schedule in which a yes-voting participant is stepped before the no-voting one. The generator draws no about a fifth of the time per participant. The rest is the scheduler's job.

The harness

6 required callbacks, init/2 through terminate/1, and 2 optional. This example uses one of the optional ones, labels/1. The other, check_final/2, is for invariants that only hold once the system has stopped being driven; two-phase commit doesn't need it, and page 4 covers it.

init/2

init(Seed, Config) ->
    Tab = ets:new(eta_2pc, [public, set]),
    N = maps:get(participants, Config, 3),
    Mode = maps:get(mode, Config, correct),

    Participants = [...],
    {ok, Coordinator} = eta_2pc_coordinator:start_link(Tab, Participants, Mode),

    {ok, #{tab => Tab, coordinator => Coordinator, participants => Participants,
           clients => [], next_tx => 1, plan => maps:get(plan, Config, random)}}.

The table is public because 3 different kinds of process touch it: the driver writes plans, the participants write their states, and the invariant reads all of it.

init/2 has to return with the system quiescent. Not merely started, and not merely ready, but idle. Anything still in flight when the driver takes over ran on the real scheduler, outside the schedule. For two-phase commit that's free, since start_link returns once init/1 has run. For a real cluster it isn't, and page 4 covers what it takes.

processes/1

processes(#{coordinator := C, participants := Ps, clients := Clients}) ->
    [C | [P || {_Index, P} <- Ps]] ++ Clients.

The driver consults this after every operation, so processes an operation creates get picked up. Pids it already knows are ignored, so returning the whole set every time is both correct and expected.

The order is part of the contract. eta_sched assigns ids in registration order and the trace records ids, so a list that comes out differently between runs makes the same seed pick different processes. The resulting trace difference looks exactly like a scheduling bug.

generate/2

generate(#{participants := Participants, next_tx := TxId}, Rand0) ->
    {Plan, Rand} = lists:foldl(
        fun({Index, _Pid}, {Acc, R0}) ->
            {Roll, R1} = rand:uniform_s(R0),
            {[{Index, vote_for(Roll)} | Acc], R1}
        end,
        {[], Rand0},
        Participants
    ),
    {{run_tx, TxId, lists:reverse(Plan)}, Rand}.

vote_for(Roll) when Roll < 0.70 -> yes;
vote_for(Roll) when Roll < 0.90 -> no;
vote_for(_Roll) -> stall.

Draw from the rand state you're handed and return the advanced state. Drawing entropy from anywhere else, like rand:uniform/1, erlang:unique_integer/0, or the clock, breaks replay silently.

Votes are mostly yes so that transactions usually commit and the interesting cases stay rare enough that the scheduler has to go looking for them.

A plan config option pins the votes to a fixed list, which is how a test isolates the schedule as the only variable. Same votes, different seed, and any change in outcome is the interleaving.

execute/2

execute({run_tx, TxId, Plan}, Sut = #{tab := Tab, coordinator := Coordinator}) ->
    [ets:insert(Tab, {{plan, TxId, Index}, Vote}) || {Index, Vote} <- Plan],

    Client = eta_run:spawn_op(fun() ->
        Result = gen_server:call(Coordinator, {run_tx, TxId}, infinity),
        ets:insert(Tab, {{client, TxId}, Result})
    end),

    Sut#{clients := [Client | maps:get(clients, Sut)], next_tx := TxId + 1}.

execute/2 must not block. Every process the scheduler owns is suspended, so a synchronous call from the driver into one of them is never answered. Operations are issued by spawning a process to perform them, which is the right model anyway: the client is part of the concurrent system, and the next processes/1 call picks it up and interleaves it like everything else.

The spawn goes through eta_run:spawn_op/1 rather than spawn/1. The driver isn't traced, so a process it creates isn't adopted until the driver registers it, and in that window the new process runs free. 2 clients created by operations injected close together race each other to the coordinator's mailbox, and the order they arrive in is decided by wall clock. spawn_op/1 parks the process on a handshake until registration is done.

Note the infinity on the call. A client under simulation should never be waiting on the real clock.

check/1

check(#{tab := Tab, next_tx := Next}) ->
    first_violation(lists:seq(1, Next - 1), Tab).

first_violation([TxId | Rest], Tab) ->
    case lists:usort(eta_2pc_participant:decisions(Tab, TxId)) of
        Mixed when length(Mixed) > 1 ->
            {violation, #{property => atomicity,
                          detail => <<"participants disagreed about a transaction">>,
                          tx => TxId,
                          decision => coordinator_decision(Tab, TxId),
                          participants => [...]}};
        _ ->
            first_violation(Rest, Tab)
    end.

Nothing in there sends a message or waits for anything. It's an ets:tab2list and some list operations. An invariant that calls into a suspended process either hangs or, worse, gets a plausible answer from an API that swallowed its own timeout, and then passes while checking nothing.

Give the violation a property key. eta_shrink uses it to tell "the same failure" from "a different failure that also happens to be a failure". Without it, a shrinker with more than one invariant to choose from can reduce one bug into another and report the result as progress.

labels/1, an optional one

labels(#{coordinator := C, participants := Ps, clients := Clients}) ->
    maps:from_list(
        [{C, coordinator}] ++
            [{Pid, {participant, Index}} || {Index, Pid} <- Ps] ++
            [{Pid, {client, N}} || {N, Pid} <- lists:enumerate(lists:reverse(Clients))]
    ).

Without a name a step reads p3; with one, participant-2. Names are ordinary terms; eta_log renders {participant, 2} as participant-2.

The coordinator and the participants already call ?ETA_LABEL in their own init/1, and a self-reported label wins, so this callback isn't what names them. The clients are the reason it exists. They're anonymous funs handed to eta_run:spawn_op/1, with no module to put a ?ETA_LABEL in, so the harness is the only thing that knows one of them is transaction 3. That's the general shape: reach for labels/1 when the process can't name itself, whether it's a fun like this, something from a library you don't own, or a module you deliberately kept the header out of.

It's called once, after the run, which is why it takes the whole state rather than one pid at a time. Ids are handed out as processes register, so the mapping can't exist before the run is over.

terminate/1

terminate(Sut = #{coordinator := C, participants := Ps, tab := Tab}) ->
    [exit(Client, kill) || Client <- maps:get(clients, Sut)],
    [stop(P) || {_Index, P} <- Ps],
    stop(C),
    catch ets:delete(Tab),
    ok.

Clients first, and by killing rather than asking. By the time terminate/1 runs the driver has released the scheduler, so anything still in flight is running again, and a client that reaches its ets:insert after the table has been deleted crashes with a badarg that has nothing to do with the run.

Running it

In correct mode, over 40 seeds:

[eta_run:run(eta_2pc, #{seed => S, max_ops => 25, max_steps => 20000})
 || S <- lists:seq(1, 40)].

Every one comes back #{outcome := ok}.

Turning the defect on:

eta_run:run(eta_2pc, #{seed => 1, max_ops => 25, max_steps => 20000,
                       config => #{mode => first_vote_wins}}).

Seed 1 fails after 19 steps:

#{outcome := {violation, #{property := atomicity,
                           tx := 5,
                           decision := commit,
                           detail := <<"participants disagreed about a transaction">>,
                           participants := [{1, aborted}, {2, {prepared, yes}}, {3, committed}]}},
  steps := 19,
  ops := 7}

Participant 1 voted no and refused to commit. Participant 3 committed. The coordinator had already decided commit on participant 2's yes, which is why participant 2 is still at {prepared, yes}; the decision hasn't reached it yet. The run stopped after 7 of its 25 operations, because eta_run ends on the first violation.

Making the failure readable

The trace is everything the driver did, in order: {step, Id}, {op, Op}, and {clock, Ms} entries. For this failure it's 26 of them, which is small only because the failure happened early. On a larger system a first trace runs to 1000s.

#{outcome := {violation, _}, trace := Trace} =
    eta_run:run(eta_2pc, Opts),

#{trace := Minimal, original := 26, shrunk := 7, verified := true} =
    eta_shrink:shrink(eta_2pc, Trace, Opts).

26 entries down to 7, in 74 candidate replays:

[{op,   {run_tx, 5, [{1, no}, {2, yes}, {3, stall}]}, [8]},
 {step, 8},
 {step, 0},
 {step, 2},
 {step, 0},
 {step, 3},
 {step, 1}]

6 of the 7 operations are gone, including the 4 that ran before this one. The [8] on the operation is the id of the client process it spawned. It's recorded so that deleting any other operation can't renumber it; see the shrinking hazards for what that used to cost.

If the rest means little to you, that's the right reaction. A trace is a scheduler artifact, not an explanation. {step, 3} records which process the scheduler chose and says nothing about what that process did. Turning one into a story means reconstructing every mailbox state by hand.

What shrinking gives you is a trace short enough to be worth narrating. eta_log is what narrates it.

Reading it

eta_run writes its own decisions into the same log your system writes to, so replaying the shrunk trace and printing gives you one timeline:

eta_run:replay(eta_2pc, Minimal, Opts),
eta_log:analyze().
    1  $eta           {op,{run_tx,5,[{1,no},{2,yes},{3,stall}]}}
    2  client-1       {step,8}
    3  coordinator    {step,0}
    4  coordinator    {prepare_sent,5}
    5  participant-2  {step,2}
    6  participant-2  {voted,5,yes}
    7  coordinator    {step,0}
    8  coordinator    {decided,5,commit}
    9  participant-3  {step,3}
   10  participant-3  {stalling,5}
   11  participant-3  {decided,5,commit,committed}
   12  participant-1  {step,1}
   13  participant-1  {voted,5,no}
   14  participant-1  {decided,5,commit,aborted}

Line 8 is the bug: the coordinator decided commit having heard one vote, on line 6. Line 13 is participant 1's no arriving after the decision, and line 14 is it refusing to commit, which is what makes the disagreement visible.

The events come from ?ETA_LOG calls in the participant and coordinator, about one per protocol decision. eta_log:analyze(#{until => N}) stops the output where the story does, which matters because everything after the run proper is the scheduler releasing the system during teardown.

What the shrinker can't remove

It deletes whole entries and never simplifies one. This transaction still runs against 3 participants because that's the operation the generator produced; a minimal workload is a separate axis, and the shrinker doesn't search it. It also can't go below what the system needs to reach the violation, which is the right answer and the reason the surviving operation survives.

2 things about how the shrinker works matter when you read a result.

Replaying a candidate has 3 possible results, not 2. It gives the same violation, a clean run, or a divergence, meaning the candidate isn't a valid schedule and is evidence of nothing. A shrinker that treated divergence as "did not fail" would appear to work while quietly reverting safe removals.

The result is re-recorded before it's verified. Candidates replay leniently, skipping entries, so a surviving candidate is a recipe rather than an artifact. shrink/3 finishes by replaying what the run actually executed, under strict rules. If that doesn't reproduce you get verified => false and the original trace back.

What this example leaves out

No faults are injected. No message loss, no crashes, no partitions. Two-phase commit here is purely a scheduling exercise. Fault injection is a separate axis with a reproducibility trap of its own, described on page 5.

Next

Writing a system under test generalizes from this example to the callbacks and practices a real system needs.