← Back to roadmap
Phase 2Week 20~120 min read
0%·0/4

Failover & Hot Standby

Primary, backup, and never two leaders.

Production · Gateway, Protocols, Risk & Resilience

Why this week

Week 18 made a restart exact. It didn't make it instant, and it can't help if the machine itself is gone: a dead power supply, a kernel panic, a NIC that stops talking. A trading venue answers that with a hot standby: a second engine on a second machine that receives the same sequenced input stream and applies it in lockstep, so that failing over means start accepting orders rather than start recovering.

Replication is the easy half. The hard half is a failure mode that doesn't exist with one machine: two leaders. If the standby decides the primary is dead while the primary is merely slow or unreachable, both may accept and match orders. Two different sets of trades, two different books, two market-data feeds disagreeing about the last price. A halted market is an embarrassment; a market that matched twice is a forensic and legal disaster. This week's rule is therefore blunt: when in doubt, stop. Safety beats availability every time.

ℹ️
Note

The same machinery shows up in every serious exchange and in many trading firms' order-management systems: deterministic state machine, sequenced input, a replica applying the same sequence, leases or consensus deciding who leads, and fencing so a deposed leader can't act. You are building the minimal version: one primary, one standby, one arbiter.

Day 1 — State machine replication

Day 01

Replicate inputs, not state

Deterministic engines, sequenced streams and gated outputs

The idea is old (Schneider's 1990 tutorial is the classic statement) and your engine already satisfies its precondition. A deterministic state machine fed the same commands in the same order ends in the same state. You made the engine deterministic in week 9 and proved it with state hashes. So the primary doesn't need to ship its book to the standby. It ships the sequenced input stream, the same frames it journals, and the standby feeds them through its own copy of the engine.

Why inputs and not the alternatives?

  • Not state. The book changes on every command; shipping diffs of it is more data, more code, and a second serialization of state that can drift from the first.
  • Not outputs. Executions and market data are derived; replaying them doesn't rebuild the engine's internal state (queue positions, id generators, risk exposure).
  • Inputs are small (tens of bytes per order), already sequenced and checksummed, and already journaled. The standby re-derives every output itself. Periodically comparing the two sides' state hashes at the same sequence number is a free detector for non-determinism bugs, the kind that would otherwise surface only after a failover.

The data path: the primary's sequencer assigns seq, appends to its journal, and sends the frame to the standby. The standby applies it and acknowledges the highest contiguous sequence number it holds. Nothing waits per command: the primary keeps matching while frames stream.

The subtle part is what leaves the building. Suppose the primary matches order N, publishes the trade on market data and acks the client, then dies before the standby received N. The standby takes over without N. The public saw a trade that, as far as the new primary is concerned, never happened, and a client holds an execution report for a fill that doesn't exist. The fix is output gating: every externally visible effect of command N (acks, execution reports, market data) is held until the standby has acknowledged N (plus whatever your week 18 fsync policy requires). The primary computes ahead, but the outside world only ever sees what both machines have.

/// The highest sequence number whose outputs may be released to clients
/// and to market data.
fn released_seq(&self) -> Seq {
    self.applied_seq                // the primary has computed the outputs
        .min(self.standby_ack)      // the standby has the input
        .min(self.durable_seq)      // and the week 18 policy is satisfied
}

The cost is one network round trip on the ack path, roughly 10–50 µs over a kernel TCP stack within a rack and a few microseconds with kernel bypass (week 33). Pipelining hides it from throughput, but it is in every order's latency, and it is why exchanges put the standby a rack, not a city, away. (Disaster recovery to another data center is usually asynchronous and accepts a small loss window. That is a different product decision.)

Recall#replicate-inputs

Why does the primary replicate sequenced input commands to the standby, rather than its book state or its output events?

Recall#gate-outputs

The primary matched order N and published the trade on market data, then crashed before the standby received N. What has gone wrong, and what rule prevents it?

Paperby Fred B. Schneider, ACM Computing Surveys (1990)
Implementing Fault-Tolerant Services Using the State Machine Approach: A Tutorial

The canonical statement of state machine replication. Read the requirements on replicas (agreement and order) and map each one onto your sequencer and your deterministic engine.

Paperby Martin Fowler
The LMAX Architecture

An exchange built on exactly this idea: a single-threaded, in-memory business logic processor fed by an input ring that is journaled and replicated before the business logic processes it. Note that journaling and replication run in parallel with each other rather than in series, and compare gating the input (LMAX) with gating the outputs (this week).

Day 2 — Leases and fencing

Day 02

The right to lead, for a while

Leases, monotonic time, process pauses and fencing tokens

Heartbeats tell you that a node has gone silent. They can't tell you that it is dead. Silence looks the same whether the node crashed, the network dropped, or the process is stuck in a 3-second page-fault storm. So leadership must not rest on "I haven't heard from the primary, so I'll take over". It rests on a lease: a time-limited right to lead, granted by an arbiter that only ever has one live grant outstanding.

Three details make leases correct:

  1. Time the lease from when you sent the request. The arbiter starts the lease clock no earlier than when it received the request, which is after you sent it. Measuring from your send time therefore makes your view of the deadline earlier than the arbiter's, which is the safe direction. Measuring from when the reply arrived would make it later.
  2. Subtract a drift margin. Your clock and the arbiter's run at slightly different rates. Ordinary crystal oscillators are specified to tens of parts per million; 100 ppm over a 1-second lease is 100 µs. Stop leading at sent_at + duration − margin.
  3. Monotonic clocks only. Leases are durations. A wall clock can be stepped by NTP or an operator by seconds in either direction (week 21); a lease computed from SystemTime can silently last forever or not at all.

Even a perfect lease check isn't sufficient, because checking and acting are not atomic. A leader checks its lease (valid), then the process stalls: a long page fault, a SIGSTOP, a VM live migration, a stuck fsync. By the time it resumes and writes, the lease has expired, a new leader exists, and the old one has no idea. The defense is a fencing token: every grant carries an epoch that increases with each new holder, every write carries the writer's epoch, and the protected resource remembers the highest epoch it has seen and refuses anything older. The stale leader's write is rejected by the resource, not by the leader's own (stale) judgement.

Drill

Lease-based leadership check

Target: 6/6 casesNot yet run
// Leadership = lease + fencing token. All times are nanoseconds on a
// MONOTONIC clock, passed in by the caller; nothing here reads a clock.

pub type NodeId = u32;

/// The arbiter (a witness process or a quorum) grants time-bounded leases.
/// A lease is live while `now < expires_at`.
pub struct LeaseArbiter {
    duration: u64,
    holder: Option<NodeId>,
    /// Fencing token of the latest grant. Starts at 0; the first grant is 1.
    epoch: u64,
    expires_at: u64,
}

impl LeaseArbiter {
    pub fn new(duration: u64) -> Self {
        LeaseArbiter { duration, holder: None, epoch: 0, expires_at: 0 }
    }

    /// If nobody holds a live lease: grant it to `node` with epoch + 1.
    /// If `node` already holds the live lease: extend it, same epoch.
    /// If another node holds a live lease: None.
    pub fn acquire(&mut self, node: NodeId, now: u64) -> Option<u64> {
        let _ = (node, now);
        todo!()
    }

    /// Extend the live lease to now + duration. Only the current holder,
    /// presenting the current epoch, strictly before expiry.
    pub fn renew(&mut self, node: NodeId, epoch: u64, now: u64) -> bool {
        let _ = (node, epoch, now);
        todo!()
    }
}

/// The holder's own view of its lease. `sent_at` is the holder's local
/// monotonic time when it SENT the request that produced this grant.
pub struct LeaseView {
    pub epoch: u64,
    pub sent_at: u64,
    pub duration: u64,
}

/// May the holder act as leader at `local_now`? Only strictly before
/// sent_at + duration - margin (`margin` covers clock-rate drift between
/// holder and arbiter). Must not underflow if margin > duration.
pub fn may_lead(view: &LeaseView, local_now: u64, margin: u64) -> bool {
    let _ = (view, local_now, margin);
    todo!()
}

#[derive(Debug, PartialEq, Eq)]
pub struct Stale {
    pub epoch: u64,
    pub highest: u64,
}

/// The protected resource (sequencer / journal). It remembers the highest
/// epoch it has ever accepted and refuses writes from older epochs.
pub struct FencedJournal {
    highest_epoch: u64,
    pub entries: Vec<(u64, u64)>, // (epoch, command)
}

impl FencedJournal {
    pub fn new() -> Self {
        FencedJournal { highest_epoch: 0, entries: Vec::new() }
    }

    pub fn append(&mut self, epoch: u64, cmd: u64) -> Result<(), Stale> {
        let _ = (epoch, cmd);
        todo!()
    }
}
Rust

In this exchange, what exactly is the protected resource? The standby. Every replication frame carries the primary's epoch, and on promotion the standby takes a new, higher epoch and refuses frames from older ones. Now combine that with Day 1's output gating. A deposed primary that is still running can match orders against its stale book, but it can never release their outputs, because releasing requires an ack from a standby that no longer accepts its frames. Fencing at the standby plus gated outputs means split brain can happen inside the old primary's memory without ever becoming visible to the outside world. (The repo's in-process version gets the fence without epochs: promoting consumes the standby and drops its end of the link, so a deposed primary's late frames reach nothing. The capstone has the details.)

Pause the primary for longer than its lease and step through the failover; then switch off fencing to see the deposed primary's stale frame reach clients, and switch off output gating and crash it to see an acknowledged order vanish.

Leases, fencing and output gating: crash, partition or pause the primaryOpen full screen ↗
Recall#lease-from-send

Why must a lease holder measure its lease from the moment it sent the acquire/renew request, not from when the grant arrived?

Recall#fencing-token

A leader checked its lease, then was paused for longer than the lease duration, then wrote to the journal. The lease check passed. What stops the write from corrupting the system?

Paperby Martin Kleppmann
How to do distributed locking

The clearest explanation of why leases need fencing tokens, with the process-pause diagram you just reproduced in the drill's last case.

Paperby Cary G. Gray and David R. Cheriton, SOSP 1989
Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency

Where leases come from. Note their treatment of clock-rate assumptions: leases need bounded drift, not synchronized clocks.

Day 3 — Split brain

Day 03

Two leaders is the only unacceptable outcome

Partitions, witnesses, quorums, and choosing to halt

With exactly two machines, a partition between them is undecidable. The primary sees a silent standby, the standby sees a silent primary, and neither can tell "the other one died" from "the link between us died". Asymmetric partitions are worse: the primary still reaches its clients but not the standby. Any rule that lets either side act unilaterally on silence will, eventually, produce two leaders.

There are three standard ways out:

  • A third party decides. A lightweight arbiter (witness) on a third machine grants the lease. Whoever can reach the arbiter may lead, and the arbiter grants only one lease at a time. This is the design the drill models. The repo's in-process capstone uses a two-node variant: the standby itself grants the primary's lease and promotes only after it has expired. That never yields two leaders, but a dead standby also stops the primary, which is what a witness avoids.
  • A quorum decides. Three or five full replicas run a consensus protocol (Raft, Paxos). A leader needs a majority, and two majorities always overlap. Raft's terms are exactly your epochs. Real Logic's Aeron Cluster packages Raft for low-latency state machines of precisely this shape.
  • The loser is forcibly stopped. Classic HA clusters power-cycle the other node before taking over (STONITH: "shoot the other node in the head") through a management controller. It is crude and effective, and it still needs a tie-breaker to decide who shoots.

Whichever you choose, write down the policy for ambiguity: if the standby can't reach the arbiter, or the arbiter is down, nobody leads and the market halts. Exchanges do halt for technical reasons (the NYSE suspended trading for several hours in July 2015 after a problem with a software release). It is survivable, and it is the correct outcome when the alternative is guessing.

Paperby Diego Ongaro and John Ousterhout, USENIX ATC 2014
In Search of an Understandable Consensus Algorithm (Raft)

Read sections 5.1–5.4: terms as epochs, leader election, log matching, and why a leader may only commit entries from its own term. Map each piece onto your primary, standby and arbiter.

Design note#two-vs-three

Compare primary + standby + witness against a three-node Raft group for the matching engine. For each: how many machines must be up to trade, what happens when the witness (or one Raft node) dies, and what the extra round trips cost on the order path.

0 chars

Day 4 — Idempotent apply

Day 04

Exactly once, in order, whatever the network does

Duplicates, gaps, retransmits and the bounded buffer

TCP gives you ordered, deduplicated bytes within one connection. Replication outlives connections: after a network blip the primary reconnects and resends from the last sequence number it knows the standby acknowledged, and some of those frames the standby already has. If you replicate over UDP or multicast (Aeron, or a multicast bus shared with other consumers), you also get reordering and loss. In every case the standby must apply by sequence number, not by arrival:

  • seq ≤ applied is a duplicate. Ignore it.
  • seq == applied + 1 gets applied, then anything buffered that is now contiguous gets drained.
  • seq > applied + 1 is ahead of a gap. Buffer it and ask the primary to retransmit the missing range (a NAK), which it serves from its journal.
  • Too much buffered means the standby has fallen too far behind to catch up by retransmission. It rebuilds from a snapshot (week 18) and resumes the stream from the snapshot's sequence number.

Acknowledge the highest contiguous sequence number, never the highest received. The primary uses that ack to release client-visible outputs, so it must mean "everything up to here is safe on the standby".

Drill

Apply a replicated stream idempotently

Target: 6/6 casesNot yet run
use std::collections::BTreeMap;

/// The standby side of primary → standby replication. Messages carry the
/// primary's sequence number; the network may duplicate, reorder or drop
/// them. The standby must apply every seq exactly once, in order.
pub struct Standby {
    /// Highest contiguous seq applied so far (0 = nothing yet).
    applied: u64,
    /// Messages that arrived ahead of a gap: seq → payload.
    pending: BTreeMap<u64, u64>,
    max_pending: usize,
    /// Payloads in apply order (stands in for feeding the engine).
    pub log: Vec<u64>,
}

/// The standby fell too far behind to buffer: it must reload a snapshot
/// (week 18) and resume from the snapshot's sequence number.
#[derive(Debug, PartialEq, Eq)]
pub struct NeedSnapshot;

impl Standby {
    pub fn new(max_pending: usize) -> Self {
        Standby { applied: 0, pending: BTreeMap::new(), max_pending, log: Vec::new() }
    }

    fn apply(&mut self, seq: u64, payload: u64) {
        debug_assert_eq!(seq, self.applied + 1);
        self.log.push(payload);
        self.applied = seq;
    }

    /// Process one replicated message.
    ///   seq ≤ applied            → duplicate, ignore
    ///   seq == applied + 1       → apply it, then drain contiguous pending
    ///   seq > applied + 1        → buffer it (a duplicate of a buffered seq
    ///                              is ignored); if `pending` is already
    ///                              full, return Err(NeedSnapshot) and
    ///                              change nothing
    /// Afterwards, if anything is still pending, return the first gap as an
    /// inclusive retransmit request Some((applied + 1, first_pending - 1)).
    pub fn on_message(&mut self, seq: u64, payload: u64) -> Result<Option<(u64, u64)>, NeedSnapshot> {
        let _ = (seq, payload);
        todo!()
    }

    /// What the standby acknowledges to the primary: everything ≤ this seq
    /// is applied here and survives a primary crash.
    pub fn ack(&self) -> u64 {
        todo!()
    }
}
Rust
Recall#ack-contiguous

The standby has applied seq 1..=100 and has 102..=110 buffered. Why must it acknowledge 100 rather than 110?

Day 5 — Failover, step by step

Day 05

The failover timeline

Detection, promotion, and what clients see

A failover in your design runs in a fixed order, and each step has a duration you should be able to state:

  1. Detection. The primary stops renewing its lease. Detection time is about the lease duration. Shorter leases mean faster failover but more false ones: a brief network blip or a long stall on the primary then costs a failover. Venues land anywhere from sub-second automatic failover to deliberately operator-confirmed promotion. Pick a number and justify it.
  2. Expiry, then acquisition. The standby may acquire the lease only after the arbiter considers the old one expired. It receives epoch + 1.
  3. Drain. The standby applies everything contiguous it has received. Anything the old primary sequenced but never replicated is gone, and by output gating none of it was ever visible to clients.
  4. Promote. The new primary starts sequencing at applied + 1 under the new epoch, starts the gateway and the market-data publisher (continuing the market-data sequence, not restarting it), and starts rebuilding a new standby from a snapshot.
  5. Clients reconnect. FIX sessions resume with sequence-number recovery (week 14). Orders a client sent but never saw acknowledged are unknown to that client. It must either query their status or resend them with the same client order id, and the exchange must deduplicate on (session, ClOrdID). That deduplication table is engine state, so it must be replicated and snapshotted too.
  6. Market data consumers see a sequence gap or a session change and resynchronize from a snapshot (week 10).

Then practice. A failover path that only runs during real incidents will have rotted by the time you need it. Run failovers in your test environment on every build, and on a schedule in production outside trading hours.

Recall#inflight-order

A client sent an order; the primary sequenced and matched it, but died before the standby received it. What does the client see after failover, and what should it do?

Design note#failover-runbook

Write the failover runbook for your exchange as numbered steps with an expected duration for each (detection, expiry, drain, promotion, client reconnection). Which step dominates? What would you monitor to know a failover is going wrong while it is happening?

0 chars

Day 6 — Build and reflect

Day 06

Ship the capstone

Then kill the primary a thousand times

Build the replication layer below, then run the in-process failover test in a loop with different seeds and crash points. A failover bug that shows up once in a thousand runs in CI shows up eventually in production.

Design note#week-reflection

What does an acknowledged order now survive: process crash, machine loss, a partition between primary and standby? What is the remaining window in which an acknowledged order could be lost, and what would close it?

0 chars

Capstone

Extend crates/journal from week 18 with replication (replication.rs), then fail an engine over in crates/engine. The repo's version is deliberately smaller than the design above: in-process, two nodes, no separate arbiter.

  • Transport and messages. link() returns a connected (PrimaryLink, StandbyLink) over two std::sync::mpsc channels; a network transport would carry the same messages.
    • Primary to standby: ToStandby::Frame(FrameBuf) (one journal frame held by value: Copy, 51 bytes, no allocation) or ToStandby::Heartbeat { next_seq }.
    • Standby to primary: ToPrimary::Ack { applied, granted_at } or ToPrimary::Resend { from }.
  • Standby side.
    • Journal::append_frame(&[u8]) -> Result<Seq, FrameError> accepts a frame only if it is exactly one well-formed frame whose CRC matches, whose payload decodes and whose sequence number is next_seq. Otherwise it returns Corrupt, TrailingBytes, Duplicate, Gap or Decode and changes nothing.
    • Standby::handle applies accepted frames to its own state machine (for the engine, apply discards the events) and ignores duplicates. It answers a damaged or early frame by asking for a resend from next_seq, once per gap, and drops frames until the resend arrives. If that request itself is lost, it asks again once a quarter of the lease has passed (week 48's chaos tests found the stall this prevents). It does not buffer frames ahead of a gap as the drill does: over an in-order channel a resend is rare, and going back to next_seq keeps the standby simple.
    • A heartbeat's next_seq exposes a lost last frame.
    • After every message, the standby acknowledges its highest contiguous applied sequence number.
  • Primary side. Primary::append journals the command and sends its frame without waiting; resend requests are served from its journal. There is no released_seq(): a client may be told its command is done only after Primary::wait_acked(seq, timeout) (or poll() then acked_seq()). The primary keeps its journal in memory only, so combining the standby's ack with week 18's durable_seq() is left to you.
  • Leadership.
    • The standby grants the lease. Each Ack carries the standby's clock reading granted_at and is a promise not to take over before granted_at + lease.
    • Primary::append refuses with ReplicationError::NotLeader once that promise has run out. A primary starts with no lease until the standby's first grant.
    • Standby::can_promote() turns true only when its newest promise has expired, and promote(self) hands back the journal and the state machine to carry on sequencing.
    • Both sides compare against the same promise, so their leadership intervals never overlap. Time comes from an injected journal::Clock (a ManualClock in tests); across machines the primary would subtract a drift margin.
    • There are no epochs: promote consumes the standby and drops its end of the link, so a deposed primary's late frames reach nothing.
    • After promotion, Pipeline::with_journal(journal, engine) starts a new feed: a fresh market-data publisher primed from the restored book, whose sequence numbers do not continue the old feed's. Unlike Day 5's advice, clients must resynchronize from a snapshot (publisher().snapshot()).
  • Tests named w20_*.
    • append_frame catches CRC damage (any single flipped bit), gaps, duplicates, trailing bytes and payloads that do not decode.
    • The standby applies, acknowledges and matches the primary byte for byte.
    • A network that drops, duplicates and damages frames is repaired through resends and heartbeats.
    • The lease decides who leads, on a manual clock.
    • A threaded failover (engine/tests/w20_failover.rs) kills the primary mid-stream. The standby must not promote while the lease stands; no acknowledged command may be lost; the state hashes must match; and the promoted standby must finish the stream with the correct final hash.

On the benchmark: w20.replication_lag measures, in-process, the time from the start of Primary::append to the standby having validated, journaled and applied that frame to its own engine. The primary sends the week 12 order flow at 100k commands per second, open loop, and the standby thread spins on its channel. The ingredients are the primary's journal append and send, the channel hand-off, the standby's frame check, journal append and engine apply, and the ack it sends after every message (the bench stamps the time after the standby's poll_one returns, so the ack is inside the measurement). Acknowledging once per batch of frames, or whenever the channel drains, is the usual lever. Be careful that batching doesn't hold the acknowledged watermark back long enough to hurt end-to-end latency in week 24.

Capstone · in your exchange · crates/journal

Hot standby with fenced failover

Replicate the sequenced journal to a standby, release client-visible outputs only once the standby has them, and fail over in-process without losing or duplicating an acknowledged order.

cargo xtask test --week 20      # tests named w20_*
cargo xtask bench --week 20     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 20 tests pass w20_*
  • Primary→standby apply lag (in-process) w20.replication_lagtarget p99 ≤ 10 µs

Feeds into

  • Week 48 attacks this design on purpose: partitions between primary and standby, a primary paused mid-write, dropped and reordered replication frames, a crashed arbiter. The failover drills you wrote today become automated chaos scenarios with assertions.