← Back to roadmap
Phase 4Week 48~110 min read
0%·0/3

Chaos Engineering

Break it on purpose before the market does.

Mastery · Markets, Strategies & Getting Hired

Why this week

By now your exchange has a recovery story for everything: gap recovery (week 10), slow-consumer policies (week 16), crash recovery from snapshot plus journal (week 18), hot-standby failover (week 20) and a kill switch (week 15). Each one was tested once, against the failure you had in mind while writing it. Real failures don't arrive one at a time or in the order you imagined. A standby that lags by 40 ms is fine until the primary dies during a packet-loss burst, while a slow WebSocket client is holding the publisher's oldest buffer.

Chaos engineering is the discipline of finding out before the market does. You form a hypothesis about how the system behaves under a fault, inject the fault deliberately with a bounded blast radius, and check the hypothesis against invariants. For an exchange that mostly means deterministic simulation: every component in one process, a simulated network and clock, faults from a seeded plan, and a failing seed that replays the same bug every time.

⚠️
Warning

Netflix popularised breaking things in production. An exchange doesn't do that during trading hours: one bad experiment is a market-wide incident. The same principles apply. They run in simulation, in member test environments, and in scheduled failover and disaster-recovery tests outside the session.

Day 1 — Principles, translated for trading

Day 01

Hypotheses, not hammers

A chaos experiment is a scientific experiment

The Principles of Chaos Engineering define an experiment in four steps: describe the steady state with measurable outputs, hypothesise that it holds in both the control and the experiment, introduce real-world events (crashes, loss, latency), and look for a difference. The part that gets skipped is the first one. "The system recovers" isn't a steady state. "Every acknowledged order is in the standby's journal, order-to-ack p99 stays under 50 µs, and no market-data consumer sees a Live book that differs from the publisher's" is one.

For an exchange, the steady-state metrics are mostly invariants rather than rates:

  • Safety: no acknowledged order lost, no execution duplicated, never two leaders, never a crossed book, quantity conserved, no consumer shown a stale book as live.
  • Liveness: after the fault clears, the system returns to Live within a bounded time, measured in simulated time.
  • Performance: tail latency during and after the fault, because a failover that takes 3 seconds may be correct and still unacceptable.
Docs
Principles of Chaos Engineering

One page. Note 'minimize blast radius' and 'automate experiments to run continuously', and ask how each applies when the production system is an exchange during trading hours.

Paperby Ali Basiri, Niosha Behnam, Ruud de Rooij, Lorin Hochstein, Luke Kosewski, Justin Reynolds, Casey Rosenthal
Chaos Engineering (IEEE Software, 2016)

Netflix's account of moving from Chaos Monkey to hypothesis-driven experiments. Read for the steady-state definition (they used stream starts per second) and think about what your exchange's equivalent is.

Recall#no-prod-chaos

Why can't an exchange adopt 'run chaos experiments in production' the way a streaming service did, and where does it run them instead?

Day 2 — A fault catalogue and a replayable plan

Day 02

Name every fault, then make it reproducible

Injected faults must replay exactly, or they're just noise

Write down every fault the exchange can experience, next to the invariant it threatens:

  • Network: loss, duplication, reordering, delay and jitter, a full partition, an asymmetric partition (A hears B, B doesn't hear A), and one multicast line silent while the other is healthy.
  • Process: crash, hang (a stop-the-world pause: SIGSTOP, a major page fault storm, CPU throttling), or a slow restart while replaying the journal.
  • Storage: slow fsync, full disk, torn write (week 18).
  • Clock: a step adjustment, drift between primary and standby, or a lease computed on a clock that jumped.
  • Dependencies and people: stale reference data, a risk service that's unreachable, or a config typo in one host of eight.

Inject at the layer where the fault actually happens. UDP market data loses, duplicates and reorders individual packets. A TCP order session never does any of that to bytes in the stream: its failure modes are stalls, resets and reconnects, and a fault model that drops a message in the middle of a TCP stream is testing a failure that can't occur while missing the ones that can.

On real hosts, tc netem adds delay, loss, duplication, reordering and corruption to an interface. iptables rules make partitions, SIGSTOP/SIGCONT makes pauses, and TCP proxies like Toxiproxy inject resets and latency per connection. In your simulator, the fault plan is data: a seed plus a list of faults applied at a seam you own. That's what the drill builds.

Docsby Linux manual pages
tc-netem(8)

Delay with jitter and correlation, loss models, duplicate, reorder, corrupt and rate. Read the reordering section closely: netem reorders by sending some packets immediately while the rest are delayed, which is a different model from swapping neighbours.

Make the plan deterministic: a seeded RNG, a documented order in which faults compose, and exactly one random draw per packet that reaches the random stage. Then a failing run is a seed you can paste into a bug report.

The repo's FaultPlan goes one step further and has no shared generator at all. Whether fault i hits packet n on a link is draw(plan.stream(i), n) % 1000 < per_mille, where the stream is derived from the seed and the fault itself, not its window or rate. Each decision is a pure function of the seed, the fault and the packet number. With one shared generator, removing a fault shifts every later draw, so a smaller plan is a different run. With per-fault streams, removing a fault, narrowing its window or lowering its rate leaves the other decisions as they were, and a lower rate hits a subset of the packets the higher one did. That is what makes shrinking a failing plan work.

Drill

Design a fault-injection plan

Target: 6/6 cases · same seed, same runNot yet run
/// Deterministic PRNG: a chaos run must be replayable from its seed.
pub struct XorShift64(u64);

impl XorShift64 {
    pub fn new(seed: u64) -> Self {
        Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
    }
    pub fn next_u64(&mut self) -> u64 {
        let mut x = self.0;
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        self.0 = x;
        x
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fault {
    /// Drop every packet with from <= seq <= to.
    DropRange { from: u64, to: u64 },
    /// Drop each packet with probability per_mille / 1000.
    DropRandom { per_mille: u32 },
    /// Deliver this packet twice in a row.
    Duplicate { seq: u64 },
    /// Deliver this packet right after the next delivered packet.
    SwapNext { seq: u64 },
}

pub struct Plan {
    pub seed: u64,
    pub faults: Vec<Fault>,
}

/// Apply `plan` to `input` (sequence numbers in send order) and return what
/// the receiver sees. Use one XorShift64::new(plan.seed) per call. For each
/// input packet p, in order:
///   1. If any DropRange covers p → drop it (no random draw).
///   2. For each DropRandom in plan order: draw next_u64() % 1000; if the
///      draw < per_mille → drop p (and stop drawing for p).
///   3. If a SwapNext names p → if a packet is already held, emit the held
///      one now; then hold p.
///   4. Otherwise emit p; then, if a packet is held, emit the held one.
/// At the end of input, emit any held packet.
/// "Emit x" = push x, and push it a second time if a Duplicate names x.
pub fn inject(plan: &Plan, input: &[u64]) -> Vec<u64> {
    let _ = (plan, input);
    todo!()
}
Rust
Design note#fault-catalogue

Build your exchange's fault catalogue as a table: fault, layer, component, the invariant it threatens, how you inject it in simulation, and how you would inject it on real hosts. Mark the three you have never tested.

0 chars

Day 3 — The feed handler under loss

Day 03

Stale is a state, not a bug

Recover from snapshots, and never serve a book you can't vouch for

Week 10 taught gap detection. Under sustained random loss the hard part is everything around it:

  • Losses during recovery. You request a snapshot, buffer updates while you wait, and lose one of the buffered updates too. The snapshot then doesn't close the hole. You stay in recovery and ask again instead of declaring victory.
  • Duplicates and stragglers. An update older than what you've applied must never be re-applied. With absolute "set level to qty" updates, a late duplicate silently rolls a level back.
  • Tail loss. If the last packets before a quiet period are lost, no later packet reveals the gap. Feeds send heartbeats that carry the next sequence number (MoldUDP64 heartbeats do exactly this) so a quiet line still proves you're current.
  • Telling consumers. While recovering, the book isn't served as live. Strategies must see an explicit stale state and pull their quotes, not keep trading on a frozen book.
Recall#tail-loss

Your feed handler only checks sequence numbers on arriving updates. The last three updates before a 30-second lull are lost. What happens, and what fixes it?

The harness ends with a chaos run: 3,000 updates through a lossy, duplicating, reordering network and a recovery server that answers your snapshot requests. Every time your handler reports Live, its book is compared with the publisher's truth at that sequence number.

Drill

Feed handler under packet loss

Target: 6/6 cases · never Live with a wrong bookNot yet run
use std::collections::BTreeMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Msg {
    /// Set the quantity at `price` to `qty` (0 removes the level).
    Update { seq: u64, price: i64, qty: u64 },
    /// The full book after every update <= seq.
    Snapshot { seq: u64, levels: Vec<(i64, u64)> },
    /// The publisher's next sequence number (sent when the line is quiet).
    Heartbeat { next_seq: u64 },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
    RequestSnapshot,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
    Live,
    Recovering,
}

pub struct FeedHandler {
    book: BTreeMap<i64, u64>,
    next: u64,                           // next update seq to apply
    status: Status,
    buffered: BTreeMap<u64, (i64, u64)>, // updates received while recovering
}

impl FeedHandler {
    /// Starts Live with an empty book, expecting update 1.
    pub fn new() -> Self {
        Self { book: BTreeMap::new(), next: 1, status: Status::Live, buffered: BTreeMap::new() }
    }

    /// The book, or None while recovering: never serve a book you can't vouch for.
    pub fn book(&self) -> Option<&BTreeMap<i64, u64>> {
        match self.status {
            Status::Live => Some(&self.book),
            Status::Recovering => None,
        }
    }

    pub fn status(&self) -> Status { self.status }
    pub fn next_expected(&self) -> u64 { self.next }

    /// Rules:
    ///  any state: Update with seq < next → already applied, ignore.
    ///  Live:
    ///    Update seq == next → apply, next += 1.
    ///    Update seq > next  → gap: buffer it, go Recovering, return [RequestSnapshot].
    ///    Heartbeat next_seq > next → tail loss: go Recovering, return [RequestSnapshot].
    ///    Snapshot / other Heartbeats → ignore.
    ///  Recovering:
    ///    Update → buffer it (no new request). Heartbeat → ignore.
    ///    Snapshot { seq, levels } → book = levels, next = seq + 1, discard
    ///      buffered <= seq, apply buffered updates while they continue the
    ///      sequence. If nothing is left buffered → Live, return [].
    ///      Otherwise a hole remains → stay Recovering, return [RequestSnapshot].
    pub fn on_msg(&mut self, msg: &Msg) -> Vec<Action> {
        let _ = msg;
        todo!()
    }
}
Rust

Day 4 — Deterministic simulation testing

Day 04

One process, one clock, every fault

Turn 'flaky in staging' into 'fails on seed 81723'

FoundationDB's team famously built the simulator before the database. The whole cluster ran in one thread with simulated networks, disks and clocks, and the simulator injected failures far more often than reality would. Because everything was deterministic, any failure could be replayed exactly from its seed. TigerBeetle took the same approach. Your week 23 runner already has the shape: seeded agents take turns in a fixed order against risk → engine → market data in one thread, and nothing reads a clock. The week 20 primary and standby fit in too, because they take their clock and their link as parameters: on a ManualClock and channels the simulator pumps, they become state machines it steps.

To turn it into a chaos tool:

  1. Own every source of nondeterminism. Time comes from a simulated clock, I/O goes through traits with simulated implementations, randomness comes from the run's seed, and threads are replaced by a deterministic scheduler, or each component is a state machine stepped by the simulator.
  2. Put fault seams at real boundaries. The simulated UDP link applies today's fault plan. The simulated TCP link can stall, reset and reconnect. The simulated disk can be slow, fill up or tear a write. The process wrapper can crash a component at any step and restart it from its persisted state.
  3. Check invariants continuously. After every simulated event, not just at the end, because transient violations (two leaders for 3 ms) are the dangerous ones.
  4. Run many seeds. A few hundred on every commit, thousands nightly, and print the seed with every failure.

Black-box tools complement this. Jepsen runs real processes on real networks, records a history of client operations and checks it against a consistency model. It finds bugs your simulator's models can't, because the simulation is only as faithful as the models you wrote.

Videoby Will Wilson (Strange Loop 2014) · ~40m
Testing Distributed Systems w/ Deterministic Simulation

How FoundationDB simulated networks, disks and whole-cluster failures in one deterministic process, and the 'buggify' technique of making rare code paths happen often in simulation. Steal both ideas for your sim crate.

Docsby Kyle Kingsbury
Jepsen

Read one analysis end to end. Notice the shape: a workload, a fault schedule (partitions, pauses, clock skew), a recorded history and a checker. Your failover test should produce the same kind of evidence.

Recall#sim-vs-staging

Why does deterministic simulation find failover bugs that weeks of running a staging environment miss?

Day 5 — Failover drills and game days

Day 05

Kill the leader, mid-sentence

What must be true after the primary dies at the worst moment

The leader-crash experiment is the one that matters most. Crash the primary at a random point (mid-batch, mid-replication, while sending an ack) and assert:

  • No acknowledged order is lost. This holds only if the primary acknowledges after the standby has the order. Ack-before-replicate is the classic bug: faster by one network round trip, and wrong whenever the primary dies inside that window.
  • No execution is duplicated or invented. The new leader replays the replicated journal, and its state hash matches the old primary's at the last replicated sequence.
  • Exactly one leader. The standby promotes only after the primary's lease has expired by the standby's clock, with margin for drift (week 20). Pause the old primary with SIGSTOP during the lease and resume it: it must step down, not keep matching.
  • Clients resume. Sessions reconnect and sequence numbers reconcile, so unacknowledged orders are either in the journal (and reported) or rejected, never in limbo.
  • Recovery is bounded. Measure crash-to-first-ack in simulated time and set an SLO for it.

A game day is the same experiment run by people on real infrastructure: a scenario, a written hypothesis, abort criteria, named roles (someone runs it, someone decides, someone writes everything down), observers from the teams that would be paged, and a review afterwards. For exchanges it's often a regulatory obligation as well as good practice. Regulation SCI requires US venues to run business-continuity and disaster-recovery tests that include their members.

Recall#ack-before-replicate

Your failover test passes 999 of 1,000 seeds; the failing seed shows an order the client saw acknowledged missing after failover. What's the most likely bug, and the fix?

Design note#game-day

Write a one-page game-day plan for 'primary engine host loses power during the closing auction' in your member test environment: hypothesis, steady-state metrics, abort criteria, roles, the exact injection, what members are told beforehand, and what you'll measure.

0 chars

Day 6 — Invariants, written down

Day 06

The checker is the product

Faults are cheap; knowing what 'correct' means is the work

The hardest part of a chaos suite isn't the injection, it's the checker. Before you write the capstone, list every invariant as a function over simulator state, and decide when each one must hold: always, after quiescence, or within N simulated milliseconds of the fault clearing.

Design note#invariant-catalogue

Write your invariant catalogue: each invariant as a one-line predicate, which state it reads, whether it's safety (always) or liveness (eventually, within a bound), and the fault in your catalogue most likely to break it.

0 chars

Capstone

Build a fault-injection suite in crates/sim, in the chaos module. Every scenario runs in one thread on simulated steps, takes a configuration struct and a FaultPlan, and checks its invariants after every step.

The plan (provided). A FaultPlan is a seed plus Scheduled faults, each active over a window of steps. Per-packet faults act on one Link (md, exec, to-standby or to-primary): Drop, Duplicate, Reorder, Delay and Corrupt, each at a rate in per mille. Partition cuts both replication links, Stall { consumer } stops a reader, and CrashPrimary kills the primary at one step. Display and FromStr use a one-line text form, so seed=7 drop(md,50)@0..800 crash@450 describes a whole run. Fate's docs fix how the faults on one link compose for a packet: a partition loses it; otherwise the random faults are consulted in plan order, and a drop ends the list. shrink_plan and the simulated datagram format in chaos::wire are provided too. You write the Injector (one Fate per packet, logged when it isn't clean), ChaosLink (a UDP-like link: send at a step, recv what is due), the four scenario drivers, the consumers they drive, and the checks.

Four scenarios.

  1. Feed faults (FeedSim). The engine's L2 publisher sends each step's updates as checksummed incremental packets, or a heartbeat carrying the next sequence number when there's nothing to send, over a single md line to a FeedClient. The client treats a datagram that fails its checksum as lost, puts updates back in order with the week 46 ReorderBuffer, feeds the week 10 BookBuilder, and asks a snapshot service for help when it falls behind. It is Live only while the builder is synced, it has reached the highest sequence number it knows exists, and the line isn't silent (nothing valid for more than patience steps). Otherwise book() is None. After every step, FeedChecker asserts that a Live client's book is the publisher's book at the client's sequence number, never ahead of the publisher and never going backwards. The run must end Live on the publisher's book after the quiet steps, and live_within can also bound any stretch out of Live. The repo doesn't model A/B lines.
  2. Slow consumers (FanoutSim). The publisher writes every update to the week 16 BroadcastRing and stores a periodic snapshot in a SnapshotCell. Several RingConsumers (three by default) each read up to a budget per step, and the plan's Stall faults stop some of them. The writer never waits: a reader stalled for long enough gets lapped, learns how much it missed from Recv::Lagged, and resyncs from the cell. The test asserts that every update is in the ring in the step it was produced, healthy readers never lag, gap or resync, a short stall just catches up, and the lapped reader is never Live with a wrong book and ends on the publisher's book. It then repeats the point on real threads: a reader parked before the first publish can't slow the publisher down. (Week 16's conflation for WebSocket clients isn't part of this scenario.)
  3. Leader crash (FailoverSim). The week 12 Pipeline runs over a week 20 Primary, with a Standby running the engine behind it and a ChaosLink in each direction for replication, all on one ManualClock. The plan crashes the primary at a seeded step, partitions the pair, or drops, duplicates, corrupts and delays replication traffic. A command counts as acknowledged only once the standby has acknowledged it. After every step the checks are: never two leaders (the primary while its lease is current, plus the promoted standby), and nothing acknowledged missing from the surviving journal. At promotion: the promoted journal is exactly a prefix of the client's commands, and the promoted engine's state hash equals both a replay of that journal and the reference hash. The run must finish within max_steps or it is Stuck, and failover_steps measures the steps from the crash (or the partition's start) to the promotion. The tests also cover a partition longer than the lease (the primary steps down before the standby takes over, and stays down after the network heals) and a short or one-way partition, where nobody fails over (at worst, order entry pauses while the primary's lease runs out).
  4. A faulty venue (ChaosVenue). It wraps any week 23 Venue, so the Runner and its agents run unchanged, and each flush is one step of the plan. Market data goes through an md ChaosLink, and the runner's book recovers with market_snapshot. Execution reports travel on per-account order sessions, and those are TCP: a dropped or corrupted report resets the session, which reconnects at the next flush and resends from the first report the client lacks. A delay stalls everything behind it, a duplicate is a resend the client drops by sequence number, and reordering can't happen inside a stream. After every step, each agent must hold exactly a prefix of its true reports, and the runner's book, when synced, must be the true book. Once the faults stop, every report has arrived exactly once and in order, and positions and cash sum to zero and agree with pre-trade risk and with week 45's position keeping.

Design decisions. Inject at seams the simulator owns (the links between components, the ring reader, the process itself) rather than sprinkling if chaos {} through business logic. The repo has no disk faults: week 18's torn-write tests cover the journal. Express each scenario as data, a config struct plus a FaultPlan, so a failing case is one line of text you can print, save and replay. Check safety invariants after every simulated event. When a seed fails, shrink the fault plan (the idea from week 12's property tests) to the smallest one that still fails before you start debugging. w48_same_seed_same_run_and_a_failing_seed_replays_and_shrinks walks through the whole workflow. It sweeps seeds against a one-lease failover SLO under lossy replication, prints the first failing plan, parses it back and replays it exactly, then runs shrink_plan. On the reference implementation it prints minimal plan: seed=1 delay(to-standby,48,4)@86..221 crash@222. A frame the primary sent just before dying arrives late, and the standby takes it as a sign of life and renews its promise not to take over.

ℹ️
Note

Turning the rates up found two real weaknesses in this very repo, both since fixed. First, corrupted feed packets showed that a flipped bit in a price decoded as a valid update: week 46's feed layout has no checksum, and udp_frame wrote none. It now writes an RFC 768 UDP checksum (marketdata::pcap::udp_checksum) and parse_udp verifies it. The chaos link doesn't build UDP frames, so chaos::wire still seals each simulated datagram with its own CRC-32 trailer. Second, lossy replication under load showed that a lost resend request could stall every acknowledgement: the week 20 standby repeated a request only when a heartbeat arrived, and a busy primary heartbeated only when idle. The standby now repeats an unanswered request after a quarter of the lease. Heartbeating under load is still recommended, because it is the cheapest way to expose a lost last frame.

Pitfalls. A simulator that quietly uses Instant::now(), a HashMap iteration or a real thread somewhere isn't deterministic, and a non-replayable failure costs you a day. Faults injected at the wrong layer (dropping bytes from a TCP stream) give false confidence. Asserting only at the end of a run misses transient split-brain. And a suite that only passes because faults are rare is measuring luck: turn the rates up until things break, then fix them.

Capstone · in your exchange · crates/sim

Fault-injection suite

Seeded scenarios for feed loss, slow consumers and leader crash, each asserting recovery without loss.

cargo xtask test --week 48      # tests named w48_*
cargo xtask bench --week 48     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 48 tests pass w48_*

Feeds into

No later week lists chaos engineering as a hard prerequisite, but its artifacts travel. The invariant catalogue and a failing-seed story make strong material for the week 49 system design interviews ("how do you know your failover works?"), and the suite's seed count and recovery-time numbers belong in the week 51 README next to the latency figures.