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
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 thePreTradehook, before the order is sequenced. The throttle uses the receive timerx_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_commandappends each command to its instrument's journal before the engine matches it, which makes it the write-ahead point. ThenStage::flushcommits the batch: one write per instrument it touched, plus an fsync underfsync = always. That's group commit. After the commit,flushpublishes the batch's market data, and only then does the gateway hand the batch's reports to the sessions. - No replication: week 20's
Primaryreplicates 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.
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?
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
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 stampsIngresswhen the session's socket read returns andGatewaywhen 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'sMatch, so it includes matching the rest of the batch as well as the fsync. The table prints this row abovematch(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.
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.
Stage-by-stage latency attribution
/// 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!()
}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
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 withRejected { reason: BUSY }.typeshas no busy reason, soBUSYreusesRisk(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.
Propagate backpressure across stages
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()
}
}
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
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.
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?
Rewatch the coordinated-omission section with your load generator in mind. Every one of his examples applies to a closed-loop order-entry test.
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.
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.
Day 5 — Failure under load, and a retrospective
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, andExchangeV2::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.
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?
Day 6 — Build and reflect
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.
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.
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.
ExchangeStageis theStagethe engine thread runs:on_commandappends each command to its instrument's journal. That's the write-ahead point, with one journal file per instrument, named byjournal_path.on_eventsfeeds that instrument's L2 publisher and trade tape.flushgroup-commits the batch, then publishes the market data it held back.on_idleruns theinterval:<ms>fsync when no orders are coming.
- Stamps and assembly.
TimedPreTradewraps the pre-trade check and stamps the order.assemblerecovers each engine from its journal withengine::recover(a full replay). It then builds aSequencer::with_engineswhose exchange ids continue after the largest id in the journals. - Start-up.
ExchangeV2::startruns everything behindGateway::start_sequencer. When[marketdata] listenis set, it also serves a WebSocket feed per instrument, one port each, counting up from thelistenport in configuration order. - You revisit your week 13 server. It needs three changes:
- receive times (
rx_ns) come fromtelemetry::Clock::global(), the clock every stage stamps on; - with
busy_rejecton, a reader whose new order finds the engine queue full answers it at once (admit_or_refuse); - the engine loop calls
Sequencer::idleon every empty poll.
- receive times (
- One binary, configured by week 22's typed config.
src/bin/exchange.rsloadsexchange.tomland runsExchangeV2with the config'sRiskGate. On shutdown it drains, syncs the journals and prints a per-stage latency summary. Sessions run onio_threadsTokio workers, and[engine] cpupins 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
Laggedand resyncs from the instrument's snapshot cell.
- Output gating: a batch's execution reports and market data are released only after
flushhas made the batch durable under thefsyncpolicy. - Telemetry: every new order that passes risk carries
StageStamps, which go into aStageLog.exchange::summarizeprints per-stage and end-to-end percentiles from them using only week 2's histogram and week 21's stamps; week 39'sAttribution(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::loadgenis the open-loop generator, andsrc/bin/loadtest.rsruns 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:
RiskGatewith limits on and throttles off, a busy-polling engine thread and 2 Tokio workers. - Journals: written every batch without fsync (
Fsync::Never), on/dev/shmwhen the machine has it. The write syscalls stay; the disk's stalls don't (loadtest --fsync alwaysmeasures 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.
crates/gatewayExchange 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.