State Machine Replication and Deterministic Apply

LESSON

Consensus and Coordination

017 30 min intermediate

State Machine Replication and Deterministic Apply

By the end of this lesson, you will be able to...

  • Trace a committed command from a replicated log into the same service state on several replicas.

  • Identify nondeterministic inputs that would make replicas diverge during application.

  • Place acknowledgement, recovery, and external side effects on the correct side of the apply boundary.

Idea in one sentence: Consensus chooses a durable order of commands; deterministic application turns that order into one shared service state.

Core Insight

Atlas has three replicas for a small metadata service. An operator asks to add node n4. The cluster can agree that add_member(n4) belongs at log index 57. That agreement matters, but it is not yet the visible configuration. Each replica must turn entry 57 into the same local state change.

The tempting model is: “matching logs mean matching service state.” That works only when every replica applies the same committed entries in the same order and computes the same result. A replica that applies a proposal too early, consults its local clock, or sends an email while replaying can create a different service even though the log looks correct.

State machine replication makes the missing contract explicit:

same starting state
+ same committed command sequence
+ deterministic apply
= same resulting state

The replicated log is the authoritative history. The state machine gives that history meaning: membership changes, keys update, leases advance, or a deployment generation becomes current. This lesson uses a simplified service model. Exact commit rules differ across protocols, but the boundary between proposed, committed, and applied state must remain visible.

The Situation

Atlas stores the set of voting members in a replicated state machine. Before the request, all replicas have applied through index 56:

applied_index = 56
members = [n1, n2, n3]

The operator submits add_member(n4). The operator wants a clear promise: once the service reports success, a recovering replica will rebuild a configuration that includes n4; a lagging replica must not invent a different membership change at index 57.

There are four moving parts:

Part What it holds or does What it must not claim early
Leader Receives a command and helps replicate the log That a merely received proposal is committed
Replicated log Ordered entries and their commit boundary That every entry has already affected service state
State machine Applies the committed prefix to local state That local time or a network call belongs in replay
Client/result path Returns a result tied to a stable command That a timeout proves failure or success

Keeping these jobs separate makes failures easier to reason about. A log position answers “where does this command belong?” An applied result answers “what does the service state now say?”

The Initial Model: Apply When the Leader Sees a Request

The smallest implementation is attractive:

leader receives add_member(n4)
leader updates its local members list
leader sends success to the operator
leader replicates the entry later

It has low latency in the happy path. It is also unsafe. Suppose the leader updates its own state at index 57, replies success, and then crashes before enough replicas preserve the entry. A replacement leader can legitimately continue from the committed prefix ending at 56. The new leader does not have evidence that n4 belongs in the shared history.

The operator has now seen a service result that recovery can erase. A follower can also diverge if it applies an uncommitted entry that the eventual leader does not retain.

The correction is not “wait for every replica.” The exact protocol may allow progress while some replicas are down. The needed rule is narrower: apply only the prefix that the protocol has made committed, and acknowledge only after the service can give a stable result according to its stated guarantee.

The Better Model: A Deterministic Transition Function

In plain English, every replica runs the same small program over the same approved commands.

In Atlas, the program takes the previous member set and add_member(n4), then produces the new member set.

The technical name is a replicated state machine. Its apply function can be described as:

next_state, result = apply(previous_state, committed_command)

For this model to hold, apply must depend only on the command and replicated state. It may not quietly inspect a replica-local input that another replica can see differently.

Unsafe input during apply Why replicas can disagree Safer command or state
Local wall-clock time Each machine may read a different instant Include a chosen expiry time in the command
Random identifier Each machine can generate a different value Allocate the identifier before agreement and replicate it
Remote pricing or health API Responses can differ or arrive only at one replica Record the chosen input as command data, or perform a separate workflow
Local cache Caches can be stale or incomplete Read only replicated state for the transition

Deterministic does not mean simultaneous. Replica n3 may be offline and apply later. It means that whenever n3 reaches the same committed prefix, n3 produces the same state and result as n1 and n2.

The Mechanism Step by Step

The following trace uses illustrative indices and assumes Atlas has already established its protocol-specific commit evidence for index 57.

Step Log and commit state State-machine state What the operator can conclude
1 Leader receives add_member(n4) All replicas remain applied through 56 The request is only proposed
2 Entry is replicated at index 57 No replica should expose n4 merely from replication The entry has a position, not yet a service result
3 The protocol marks index 57 committed Each replica may advance its apply loop to 57 The committed history includes the command
4 apply([n1,n2,n3], add_member(n4)) Each available replica stores [n1,n2,n3,n4] and applied_index=57 The state transition is reproducible
5 The leader records the result for the request identity A response can now return the member list or revision 57 Success refers to a stable service boundary
6 n3 returns after a crash It installs a snapshot through 56, then applies committed tail 57 Recovery reaches the same configuration

The important intermediate state is step 3. “Committed” says the command belongs to the history that a valid future leader must preserve. “Applied” says a particular state machine has executed it. A healthy leader may have a committed index ahead of a follower's applied index for a short time. That gap is normal only if the service is careful about which replica answers which request.

Question: Why not return success immediately at step 3?

Answer: Some services can safely acknowledge after commit and apply asynchronously if the result is defined at the commit boundary. Others need the actual transition result, such as a compare-and-set outcome, so they wait until the leader has applied the entry. The design must state its boundary. It must not silently treat receipt, replication, commit, and apply as the same event.

So far, the log has done one job: preserve an order. Deterministic apply does the next job: make that order produce one inspectable service state.

A Second Trace: Why Local Time Breaks Replay

Now consider create_session(user=U). An early implementation hides the expiry calculation inside apply:

apply(create_session(U)):
    expiry = local_now() + 30 minutes
    store session(U, expiry)

At index 58, replica n1 reads 10:00:00; replica n2 reads 10:00:03. Both have the same command. They now store different expiry values. A snapshot from n1 and a replay on n2 no longer describe one service state.

Move the choice before agreement instead:

command = create_session(user=U, session_id=S17, expires_at=10:30:00Z)
apply(command):
    store session(U, S17, 10:30:00Z)

The timestamp and identifier are illustrative command fields, not measurements from a real Atlas system. The command now contains the information every replica needs. Clock quality can still matter when the leader chooses the value, but clock disagreement no longer makes replay compute different state.

Where It Breaks: External Effects and Duplicate Work

Deterministic internal state does not make an external effect deterministic. This apply function is dangerous:

apply(invoice_ready(I)):
    send invoice email for I
    mark I as sent

If a replica crashes after sending the email but before recording sent, recovery may replay the command and send a second email. If several replicas apply the same committed command, sending from each apply loop is worse.

Use two boundaries instead:

apply(invoice_ready(I)) -> durable state says "I is ready"
worker observes ready(I) -> calls email provider with idempotency key I
worker records a durable outcome or an explicit unknown/in-progress state

The state machine decides that an invoice is ready. A separate worker performs the side effect. The worker needs its own retry contract because a lost provider response can still leave the outcome unknown. This is the same boundary introduced in the previous capstone: agreement about a decision does not automatically make an outside system execute exactly once.

Cost, Limits, and Signals

State machine replication buys a clear recovery story and a strong basis for one service state. The trade-off is discipline and latency at explicit boundaries: commands must include relevant nondeterministic choices, apply must wait for commit, snapshots must match an applied prefix, and external work needs a separate idempotency boundary.

It does not solve every consistency question. A read from a lagging follower may observe an older applied prefix. A deterministic apply function cannot repair a wrong command chosen by the client. Membership changes require additional protocol rules; this lesson uses add_member only as a simple state transition, not as a complete reconfiguration procedure.

Useful operational signals are concrete:

These signals do not replace a proof or a protocol-specific checker. They tell an operator where the state-machine contract may be breaking in a running service.

Trace It Yourself

Check: A follower has applied through index 70. The leader has committed through 72, but has applied only through 71. May the leader claim that the result of command 72 is available?

Think first, then reveal.

Answer: Not if the service defines that result at the applied boundary. Index 72 is part of committed history, so followers should eventually preserve it. But no state machine in this trace has executed it yet. The leader may describe it as committed only if its API explicitly exposes that distinction; it should not fabricate the transition result.

Practice: Review a Deterministic Apply Function

A lock service proposes this command:

apply(acquire(resource=R, client=C)):
    token = random()
    if local_clock() < lease_deadline(R): reject
    else store owner=C, token=token
    notify an external webhook

Rewrite the design at the level of boundaries, not implementation syntax.

A good answer should mention:

Connections

The previous capstone established why a control plane keeps its authoritative decisions small and makes external work fenced and idempotent. This lesson explains how the authoritative command history becomes shared internal state.

The next lesson explains the evidence that makes a committed history survive later leaders: quorum intersection, ballots, and commit evidence.

Resources

Key Takeaways

PREVIOUS Control Plane Consensus Boundary Design Review NEXT Quorum Intersection, Ballots, and Commit Evidence