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.
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
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.
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.
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.
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
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.
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.
Design a fault-injection plan
/// 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!()
}
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.
Day 3 — The feed handler under loss
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.
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.
Feed handler under packet loss
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!()
}
}
Day 4 — Deterministic simulation testing
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:
- 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.
- 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.
- Check invariants continuously. After every simulated event, not just at the end, because transient violations (two leaders for 3 ms) are the dangerous ones.
- 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.
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.
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.
Why does deterministic simulation find failover bugs that weeks of running a staging environment miss?
Day 5 — Failover drills and game days
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
SIGSTOPduring 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.
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?
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.
Day 6 — Invariants, written down
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.
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.
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.
- 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 singlemdline to aFeedClient. The client treats a datagram that fails its checksum as lost, puts updates back in order with the week 46ReorderBuffer, feeds the week 10BookBuilder, 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 thanpatiencesteps). Otherwisebook()isNone. After every step,FeedCheckerasserts 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, andlive_withincan also bound any stretch out of Live. The repo doesn't model A/B lines. - Slow consumers (
FanoutSim). The publisher writes every update to the week 16BroadcastRingand stores a periodic snapshot in aSnapshotCell. SeveralRingConsumers (three by default) each read up to a budget per step, and the plan'sStallfaults stop some of them. The writer never waits: a reader stalled for long enough gets lapped, learns how much it missed fromRecv::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.) - Leader crash (
FailoverSim). The week 12Pipelineruns over a week 20Primary, with aStandbyrunning the engine behind it and aChaosLinkin each direction for replication, all on oneManualClock. 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 withinmax_stepsor it isStuck, andfailover_stepsmeasures 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). - A faulty venue (
ChaosVenue). It wraps any week 23Venue, so theRunnerand its agents run unchanged, and each flush is one step of the plan. Market data goes through anmdChaosLink, and the runner's book recovers withmarket_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.
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.
crates/simFault-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.