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

Exchange v2: End to End

Gateway → risk → engine → journal → market data, under load.

Production · Gateway, Protocols, Risk & Resilience

Why this week

Every piece of a production exchange now exists somewhere in your workspace: TCP sessions and FIX (weeks 13–14), pre-trade risk and throttles (15, 17), market-data fan-out (16), a durable journal with snapshots (18), telemetry (19), a hot standby (20), a clock you can trust (21), typed configuration (22) and a simulator (23). This week they become one binary, exchange v2, and you measure it the way a client experiences it: order in, ack out, under load, at the tail.

Integration is where the interesting decisions are. Which thread runs risk? Does matching wait for the journal? Who holds an ack until the standby has the order? What happens when one stage is slower than the stage feeding it? None of these questions has a component-level answer, and getting them wrong shows up only as a p99 that is ten times the sum of your component benchmarks.

It is also the end of Phase 2. Day 5 is a retrospective: what is fast, what is fragile, and what Phase 3 has to fix.

Day 1 — The pipeline

Day 01

Threads, queues and who owns what

Where each stage runs, and the order of durability and outputs

The shape that falls out of the previous twelve weeks:

clients ──TCP──▶ gateway threads (N)         decode, session, throttle,
                    │                         stateless risk (bands, fat finger)
                    │ bounded MPSC (week 5)
                    ▼
               engine thread                  sequence → stateful risk → journal
                    │                         append → replicate → match
          ┌─────────┴──────────┐
          │ per-session queues │ broadcast ring (week 16)
          ▼                    ▼
   gateway threads        md publisher ──▶ subscribers
   (ack / exec reports)
          └──── both release outputs only up to released_seq ────┘

Three decisions deserve to be made explicitly.

Where risk runs. Week 15's checks split naturally. Stateless checks (price band against a published reference, maximum order size, fat-finger limits, the week 17 throttle) can run on gateway threads, in parallel, and shed bad or excess orders before they cost the engine anything. Stateful checks (projected position including open orders, notional limits, the kill switch) need the exact current state, which the engine thread owns. Running them there, as deterministic logic on sequenced input, keeps them exact, replayable and covered by the journal.

The order of journal and match. The journal records inputs, so a command can be appended (to the journal's buffer and the replication stream) before it's matched. There are two ways to guarantee that nothing is lost. LMAX gates the input: its journaller and replicator run in parallel with each other off one ring buffer, and the business logic processes an event only once both have finished with it. You can instead let matching run ahead of the flush and gate the outputs. Clients get the same guarantee either way; output gating keeps the replication round trip off the engine thread. With output gating, what must wait is everything visible: an order's ack, its fills and its market data are released only when the command is matched, durable under your week 18 policy, and acknowledged by the standby (week 20). That is released_seq, and both the session writers and the market-data publisher respect it.

Who never blocks. The engine thread must never block on a downstream consumer. Its outputs go into structures it can always write to: a broadcast ring with per-reader cursors for market data, bounded per-session queues with a slow-session policy for execution reports. If a session can't keep up, the policy is to cut that session, not to stall the engine for everyone.

What the repo's exchange v2 does. It keeps the no-blocking rule and is simpler than the diagram in three places:

  • Risk: all of week 17's RiskGate (throttle, then limits) runs on the engine thread as the PreTrade hook, before the order is sequenced. The throttle uses the receive time rx_ns, so time spent in the queue never costs a client its budget. Moving the stateless checks out to the session readers is an optimization for later.
  • Gating, per batch: outputs are gated per engine batch of up to 256 messages. Stage::on_command appends each command to its instrument's journal before the engine matches it, which makes it the write-ahead point. Then Stage::flush commits the batch: one write per instrument it touched, plus an fsync under fsync = always. That's group commit. After the commit, flush publishes the batch's market data, and only then does the gateway hand the batch's reports to the sessions.
  • No replication: week 20's Primary replicates an in-memory journal only, so the repo releases outputs once they're matched and durable. Holding them for the standby as well is an extension.
Recall#release-conditions

Exchange v2 has matched order N and produced its ack and a fill. Which conditions must hold before either is sent to the client or reflected on market data?

Paperby Martin Thompson, Dave Farley, Michael Barker, Patricia Gee, Andrew Stewart (LMAX, 2011)
Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads

Read it as an architecture paper, not a queue paper: the dependency graph between journaller, replicator and business logic consumers on one ring, and how gating on the slowest dependency replaces locks.

Day 2 — Stage-by-stage attribution

Day 02

Where did the tail go?

Per-stage distributions, tail attribution, and why percentiles don't add

With week 21's stage stamps on every order, you get one distribution per stage and one end to end. The drill below has five stages; the repo's exchange v2 has six. Two mistakes are common enough that the drill is built around them.

Percentiles don't add. The p99 of the end-to-end latency is not the sum of the stages' p99s. The slow 1% of each stage is usually a different 1% of orders, so the sum overstates the tail, sometimes by a lot. The opposite also happens: a stage can have a perfectly healthy p99 while a handful of its orders (under 1%) are slow enough to dominate the end-to-end p99.9. Measure the end-to-end distribution directly. Never reconstruct it.

Per-stage percentiles find suspects; the tail finds culprits. To explain the end-to-end p99, take the orders at or above it and look at where those specific orders spent their time. In practice the culprit is usually a queue, not a computation: the gap between risk_out and eng_in is waiting, and orders wait because something ahead of them was slow (a large sweep, an fsync stall, a burst from one client). That's head-of-line blocking, and a per-stage service-time histogram can't show it.

Know what each row of the repo's table contains before you blame it:

  • gateway: exchange v2 stamps Ingress when the session's socket read returns and Gateway when the engine thread picks the order up. So this row is decode plus the wait in the sessions → engine queue. The queue has no row of its own.
  • journal: stamped once per batch, when the group commit returns. Its interval starts at the order's Match, so it includes matching the rest of the batch as well as the fsync. The table prints this row above match (stage order), but it's the interval after it.
  • The last stamp, Ack: the server's stamps stop there, when the reports are handed to the session queues. The writer's socket write appears only in the client's number.
Tick-to-trade budget (editable)2.4 µs
Wire to wire2.4 µs
Network I/OYour code

Starting values are illustrative for a kernel-bypass setup. The point: a budget only helps if every stage has an owner and a number.

The budget above is the same exercise from a trading firm's side (market data in, order out), and week 39 builds it properly. The method is identical: stamp every boundary, attribute the tail, and fix the stage the slow orders actually waited in.

Drill

Stage-by-stage latency attribution

Target: 6/6 casesNot yet run
/// Six timestamps (ns) stamped on every order as it crosses a boundary:
///   [0] gw_in      gateway read the message off the socket
///   [1] risk_out   decoded + pre-trade risk passed, enqueued to the engine
///   [2] eng_in     engine thread dequeued it
///   [3] match_out  matching finished
///   [4] durable    command journaled and replicated
///   [5] ack_out    ack written to the client socket
/// The five stage durations are the gaps between consecutive stamps:
pub const STAGES: [&str; 5] = ["gateway", "queue", "match", "journal", "ack"];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
    pub p50: [u64; 5],
    pub p99: [u64; 5],
    pub e2e_p50: u64,
    pub e2e_p99: u64,
    /// Among the tail orders (end-to-end ≥ e2e_p99), the stage with the
    /// largest TOTAL time. Ties go to the lower index.
    pub tail_stage: usize,
    /// Samples skipped because a stamp went backwards.
    pub invalid: usize,
}

/// The five stage durations, or None if any stamp is earlier than the one
/// before it (a clock or instrumentation bug: never "fix" it with a
/// saturating subtraction).
pub fn durations(s: &[u64; 6]) -> Option<[u64; 5]> {
    let _ = s;
    todo!()
}

/// Nearest-rank percentile of an ascending slice:
/// rank = ceil(q × n) clamped to 1..=n, answer = sorted[rank − 1]; 0 if empty.
pub fn nearest_rank(sorted: &[u64], q: f64) -> u64 {
    let _ = (sorted, q);
    todo!()
}

/// Per-stage and end-to-end p50/p99 over the valid samples, plus which
/// stage the tail orders spent their time in.
pub fn attribute(samples: &[[u64; 6]]) -> Report {
    let _ = samples;
    todo!()
}
Rust
Recall#percentiles-dont-add

Your five stages have p99s of 2, 50, 0.8, 21 and 0.3 µs, summing to about 74 µs, but the measured end-to-end p99 is 55 µs. Which number do you report, and why are they different?

Day 3 — Backpressure end to end

Day 03

Bounded queues, explicit rejects

Little's law, sizing queues from a latency budget, and where to say no

Little's law connects the three numbers every queue has: average items in the system L = arrival rate λ × average time in the system W. Turned around, it's the most useful sizing rule in this course. A queue holding 1,000 orders in front of an engine that drains 2 million per second adds 500 µs of waiting to the last order in it. Your end-to-end budget is under 100 µs, so a queue that deep is a latency bug, whatever memory it saves.

So size queues from the latency budget, not from memory: capacity ≈ service rate × the waiting time you can tolerate. Then decide what happens when a queue is full, stage by stage:

  • Inbound, at the edge: reject. The gateway answers with an explicit "busy" or throttle reject (week 17) that the client can act on. A rejected order costs the client one round trip; an order accepted into a queue it will wait 5 ms in costs it the market. In the repo this is [gateway] busy_reject. It's off by default: a full queue then makes the session's reader wait, and back-pressure reaches the client through TCP. With it on, a new order that finds the sessions → engine queue full is answered at once with Rejected { reason: BUSY }. types has no busy reason, so BUSY reuses Risk(RateLimit). Cancels and reduces are never refused for load: they wait for room, because they release risk.
  • Between internal stages: hold, never drop. A full queue stops the stage feeding it, which fills its input, which eventually reaches the edge, where the reject happens. Dropping an accepted order inside the exchange is unacceptable.
  • Outbound to a client: the engine can't wait for a client that isn't reading. If a session's outbound queue (or its TCP send buffer) stays full, disconnect it. The client recovers its state with sequence-number resend (week 14) on reconnect.
  • Market data: conflate or disconnect per week 16's policies. Never let a subscriber's speed reach the engine.

Trace one order through exchange v2 below, then drag the offered load past the engine thread's capacity of about 2M orders/s. The inbound queue fills to its bound, the gateway starts answering busy, and the latency of accepted orders stops growing. Trace a p99 order to see where the tail waits, and switch to Ack after match to see what output gating costs in latency and what it buys.

Exchange v2 order path: per-stage latency, backpressure and output gating under loadOpen full screen ↗
Drill

Propagate backpressure across stages

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

/// N stages joined by bounded queues:
///
///   offer → [q0] → stage 0 → [q1] → stage 1 → … → [qN-1] → stage N-1 → completed
///
/// Each tick, stage i moves up to rates[i] items from the front of queue i
/// to the back of queue i+1 (or to `completed` for the last stage), but
/// never fills queue i+1 past caps[i+1]. A full downstream queue stalls the
/// stage: that is backpressure. Nothing is ever dropped inside the
/// pipeline; the only place an order may be refused is the edge.
pub struct Pipeline {
    queues: Vec<VecDeque<u64>>,
    caps: Vec<usize>,
    rates: Vec<usize>,
    pub completed: Vec<u64>,
    pub rejected: Vec<u64>,
}

impl Pipeline {
    pub fn new(caps: &[usize], rates: &[usize]) -> Self {
        assert_eq!(caps.len(), rates.len());
        Pipeline {
            queues: caps.iter().map(|&c| VecDeque::with_capacity(c)).collect(),
            caps: caps.to_vec(),
            rates: rates.to_vec(),
            completed: Vec::new(),
            rejected: Vec::new(),
        }
    }

    /// Edge admission: if queue 0 has room, accept and return true;
    /// otherwise record the id in `rejected` (the client gets an explicit
    /// "busy" reject) and return false.
    pub fn offer(&mut self, id: u64) -> bool {
        let _ = id;
        todo!()
    }

    /// One time step. Run the stages from the LAST to the FIRST: space a
    /// downstream stage frees this tick can be used upstream this tick, yet
    /// every item still advances at most one stage per tick.
    pub fn tick(&mut self) {
        todo!()
    }

    pub fn depth(&self, i: usize) -> usize {
        self.queues[i].len()
    }

    pub fn in_flight(&self) -> usize {
        self.queues.iter().map(|q| q.len()).sum()
    }
}
Rust
Recall#littles-law

The engine sustains 1.5M orders/s. During a burst, the queue in front of it holds 3,000 orders. How long does the newest order wait, and what should the queue's capacity be if queueing may add at most 20 µs?

Day 4 — Load testing

Day 04

Measure what a client would see

Separate cores, fixed-rate schedules, warmup, and the latency–throughput curve

A load test is an experiment, and most load tests have a broken method. The rules for this one:

  • Keep the load generator off the system's cores. Pin it to its own cores (or its own machine). A generator that competes for the engine's core measures itself.
  • Drive a fixed schedule, not a conversation. A closed-loop generator sends an order, waits for the ack, then sends the next. When the exchange stalls for 5 ms, the generator stops sending for 5 ms, so almost no orders experience the stall, and the percentiles hide it. Gil Tene named this coordinated omission, and week 25 is devoted to it. For now, generate orders on a fixed-rate (open-loop) schedule and measure each order's latency from its intended send time, not from when the generator got around to sending it.
  • Measure at both ends. The client-side end-to-end number includes the kernel and TCP. The stage timestamps explain it. Report both.
  • Warm up, then measure. The first seconds include page faults, cold caches and untrained branch predictors. Discard them, or report them separately.
  • Sweep the rate. One rate gives one point. Step the offered load up and plot p50, p99 and p99.9 against throughput. The knee, where the tail starts climbing steeply, is the capacity number that matters, and it usually sits well below the maximum throughput.

Give the flow realistic structure (bursts, cancels, sweeps), not a uniform stream of identical orders. The repo's load generator, gateway::loadgen, is provided. It gives each session its own sim::OrderFlow, a synthetic trader population that sends passive and aggressive limits, IOC and market orders, and cancels of its own resting orders. Driving the load with your week 23 agents instead is an extension.

Recall#closed-loop-lie

A closed-loop load test (send, wait for ack, send) reports p99 = 40 µs. During the run the engine stalled once for 10 ms. Why doesn't the p99 show it, and how should the test be changed?

Videoby Gil Tene
How NOT to Measure Latency

Rewatch the coordinated-omission section with your load generator in mind. Every one of his examples applies to a closed-loop order-entry test.

Paperby Jeffrey Dean and Luiz André Barroso, Communications of the ACM (2013)
The Tail at Scale

Why tail latency, not average latency, determines user experience once requests touch many components, and the sources of variability (queueing, background work, shared resources) you will find in your own pipeline.

Design note#load-plan

Write your load-test plan: the cores for the generator and for each exchange thread, the agent mix, the rates you will sweep, the warmup, what you record at the client and per stage, and the single chart you would put at the top of the report.

0 chars

Day 5 — Failure under load, and a retrospective

Day 05

Break it while it's busy

Then look back at Phase 2

Component failure tests ran on an idle system. Run them again at your knee rate, and measure what clients see:

  • Kill the primary mid-load (week 20). Measure the gap in acks as seen by clients, check that no released output is lost or duplicated, and time how long the new primary takes to return to the old p99. In the repo this needs the replication extension first, because its exchange v2 has no standby.
  • A slow market-data subscriber (week 16). Engine and order-entry latency must not move.
  • The kill switch fires for one account at peak (week 15). Its orders are rejected immediately; nobody else notices.
  • A throttle storm: one agent bursts far beyond its limit (week 17). It gets throttle rejects; the other sessions' latency stays flat.
  • Restart from a snapshot after a long run (week 18), and record the recovery time next to the snapshot's position in the journal. The repo's v2 takes no snapshots yet. On start it replays each instrument's whole journal with engine::recover, and ExchangeV2::recovery() reports how many commands each one replayed. Pre-trade risk state (positions, open orders) starts empty. Record the recovery time against the journal's length, and treat snapshots and rebuilding the risk state as improvements to make.

Then the retrospective. Phase 2 turned a matching engine into an exchange, and you now have numbers for every part of it.

Design note#phase2-retro

For each component (gateway, FIX, risk, throttle, engine, journal, replication, market data, telemetry), write its measured cost per order, the failure modes you have tested, and the one thing about it that still worries you. Which three items go to the top of Phase 3's list?

0 chars

Day 6 — Build and reflect

Day 06

Ship exchange v2

And write down the number you will spend Phase 3 improving

Finish the capstone and run the full load test. Keep the report: Phase 3 starts by re-measuring this number correctly (week 25) and then spends fifteen weeks making it smaller.

Design note#week-reflection

What is exchange v2's end-to-end p99 at your knee rate, which stage owns most of the tail, and what is your hypothesis for why? Write it down now; week 26's profiler will tell you whether you were right.

0 chars

Capstone

Assemble exchange v2 in crates/gateway (src/exchange.rs; its module docs draw the whole pipeline). The repo's version is the Day 1 shape with the three simplifications above: risk on the engine thread, gating per batch, and no replication.

  • The engine-thread stage. ExchangeStage is the Stage the engine thread runs:
    • on_command appends each command to its instrument's journal. That's the write-ahead point, with one journal file per instrument, named by journal_path.
    • on_events feeds that instrument's L2 publisher and trade tape.
    • flush group-commits the batch, then publishes the market data it held back.
    • on_idle runs the interval:<ms> fsync when no orders are coming.
  • Stamps and assembly. TimedPreTrade wraps the pre-trade check and stamps the order. assemble recovers each engine from its journal with engine::recover (a full replay). It then builds a Sequencer::with_engines whose exchange ids continue after the largest id in the journals.
  • Start-up. ExchangeV2::start runs everything behind Gateway::start_sequencer. When [marketdata] listen is set, it also serves a WebSocket feed per instrument, one port each, counting up from the listen port in configuration order.
  • You revisit your week 13 server. It needs three changes:
    • receive times (rx_ns) come from telemetry::Clock::global(), the clock every stage stamps on;
    • with busy_reject on, a reader whose new order finds the engine queue full answers it at once (admit_or_refuse);
    • the engine loop calls Sequencer::idle on every empty poll.
  • One binary, configured by week 22's typed config. src/bin/exchange.rs loads exchange.toml and runs ExchangeV2 with the config's RiskGate. On shutdown it drains, syncs the journals and prints a per-stage latency summary. Sessions run on io_threads Tokio workers, and [engine] cpu pins the engine thread.
  • Bounded queues sized from Little's law and your latency budget (inbound_capacity, outbound_capacity), with:
    • busy rejects at the edge;
    • a slow-consumer cut for sessions that stop reading;
    • market-data broadcast rings that never wait. A reader that falls behind gets Lagged and resyncs from the instrument's snapshot cell.
  • Output gating: a batch's execution reports and market data are released only after flush has made the batch durable under the fsync policy.
  • Telemetry: every new order that passes risk carries StageStamps, which go into a StageLog. exchange::summarize prints per-stage and end-to-end percentiles from them using only week 2's histogram and week 21's stamps; week 39's Attribution (StageLog::attribution()) adds who owns the tail and budget checks once you build it. Exporting these numbers through week 19's Prometheus registry is yours to add.
  • The load test is provided. gateway::loadgen is the open-loop generator, and src/bin/loadtest.rs runs exchange v2 in-process against it, once per offered rate (cargo run --release -p gateway --bin loadtest -- --rates 20000,50000,100000). For each rate it prints:
    • the client's end-to-end p50/p99/p99.9, measured from each order's intended send time;
    • the busy rejects;
    • the server's per-stage summary of the same orders.
  • Tests named w24_* (eight of them):
    • an order's ack and its market data arrive end to end, in-process and over WebSocket;
    • under fsync = always, no ack or market data goes out before its command is synced, with at most one fsync per batch, and the other policies sync as often as they promise;
    • replaying the journal reproduces the engine state, and a restart continues the exchange ids;
    • every trade reaches the tape in order;
    • a slow market-data consumer never blocks matching or acks;
    • shutdown drains and syncs;
    • a stalled engine produces busy rejects at the edge, with a bounded queue and full recovery afterwards;
    • the load test reports both stage and end-to-end latency.

On the benchmark: w24.e2e_latency is measured at the client, over loopback, from each new order's intended send time to its first response. The setup:

  • Exchange: RiskGate with limits on and throttles off, a busy-polling engine thread and 2 Tokio workers.
  • Journals: written every batch without fsync (Fsync::Never), on /dev/shm when the machine has it. The write syscalls stay; the disk's stalls don't (loadtest --fsync always measures those).
  • Load: 50,000 commands/s from 2 generator sessions, 20,000 warm-up commands and then 100,000 measured.

The bench also prints the server's per-stage summary of the same orders. The target, p99 ≤ 500 µs, assumes an idle machine. On a loaded host, thread wake-ups dominate the client's p99. Compare it with the end-to-end row of the server's table to see how much of it is the exchange itself. Before optimizing anything, measure the floor. A loopback TCP round trip with no exchange behind it is typically on the order of 10–20 µs on a tuned Linux box (week 31 measures and tunes it), and your number can't go below that. Then use the Day 2 attribution on the slow orders, not on the averages, to decide what to fix first. It is usually a queue.

Capstone · in your exchange · crates/gateway

Exchange v2 under load

One binary running gateway, risk, engine, a durable journal and market-data fan-out with bounded queues and outputs gated on the journal commit, plus a load test that reports per-stage and end-to-end latency.

cargo xtask test --week 24      # tests named w24_*
cargo xtask bench --week 24     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 24 tests pass w24_*
  • Order in → ack out, loopback, 50k commands/s (on an idle machine) w24.e2e_latencytarget p99 ≤ 500 µs

Feeds into

  • Week 25 opens Phase 3 by re-measuring exchange v2 properly: open-loop load, coordinated-omission correction, HDR histograms. Expect the number to get worse before it gets better.
  • Week 47 deploys this binary for real: hosts, tuning checklists, colocation, rollouts.
  • Week 49 asks you to design an exchange on a whiteboard. Exchange v2's architecture, its numbers and the trade-offs you made this week are your answer.