Market Data Fan-Out
One publisher, thousands of subscribers, zero slow-consumer drama.
Production · Gateway, Protocols, Risk & Resilience
Why this week
In week 10 you turned engine events into a sequenced L2 feed for one consumer. A real exchange has many: the public feed, a WebSocket gateway serving hundreds of browser clients, the drop-copy service, surveillance, the journal replicator, risk. Some are fast, and some are a laptop on hotel Wi-Fi. The feed needs one property above all: the publisher never waits for a reader. If the engine's market data step blocks because consumer #417 stopped reading, matching stops for everyone.
This week you'll build the structure that gives you that property, a single-writer broadcast ring in which every reader has its own cursor and detects for itself when it has fallen behind. Then you'll build the policy for readers that do fall behind: conflation, which gives a slow reader the latest state rather than every intermediate step. The capstone makes the ring concurrent with atomics and puts a WebSocket adapter on top.
The organizing question for every consumer is "what happens when it can't keep up?" The answer differs by consumer. The journal replicator must get everything, so it may slow the publisher, or better, it reads from the journal instead of the live ring. A public book feed can skip ahead to the latest state. A trade feed can't skip trades. Decide this per feed before you write code.
Day 1 — Shapes of fan-out
Gating versus overwriting
The Disruptor, tokio's broadcast, and which one market data needs
Read the technical paper's description of the ring buffer, sequence numbers and sequence barriers. Focus on 'gating': the producer cannot wrap past the slowest consumer's sequence. That's the right rule for consumers that must see everything, and the wrong one for a public feed.
Read the 'Lagging' section. A bounded broadcast channel drops the oldest values when full, and a receiver that fell behind gets RecvError::Lagged(n) with the number it missed, then continues from the oldest retained value. Today's drill has exactly these semantics.
Two designs share the same skeleton (a ring of slots indexed by a monotonically increasing sequence number) and differ in one rule:
- Gating (Disruptor). The producer tracks every consumer's sequence and won't overwrite a slot until all of them have passed it. Nothing is lost, and the slowest consumer sets everyone's pace. Use it for internal consumers that must not miss anything and that you control: journal, replication, risk.
- Overwriting (broadcast). The producer never looks at consumers and overwrites the oldest slot. Each consumer detects for itself that its next slot was overwritten and reports how much it lost. The publisher's cost is independent of the number and speed of readers. Use it for market data to anyone you don't control.
Why is gating (Disruptor-style) the wrong design for the exchange's public market-data publisher?
Day 2 — A broadcast ring
Per-reader cursors, per-slot stamps
Drill 1: the overwrite semantics, single-threaded first
The ring has a capacity that is a power of two, so seq & mask is the slot index. The writer owns next_seq. Each reader owns a cursor, next, the sequence it wants to read next. Every slot stores the sequence number of the value it currently holds. A reader looking at slot next & mask sees one of three things:
slot.seq == next: the value it wants. Take it and advance.slot.seq < next, or never written: not published yet, so the reader is caught up.slot.seq > next: the writer has lapped this reader and overwritten its slot. It lost data. Resync to the oldest value still in the ring (next_seq − capacity) and report how many it skipped.
You'll write this single-threaded first so the semantics are exact and testable. Day 3 adds the atomics. The per-slot stamp is what makes the concurrent version possible: a reader decides everything from the one slot it's looking at, without a consistent view of the writer's cursor.
Single-writer broadcast ring with per-reader cursors
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recv<T> {
Item(T),
Empty,
Lagged(u64),
}
/// Each slot carries the sequence number of the value it holds.
#[derive(Clone, Copy)]
struct Slot<T> {
seq: u64, // u64::MAX = never written
value: T,
}
pub struct BroadcastRing<T: Copy + Default> {
slots: Vec<Slot<T>>,
mask: u64,
next_seq: u64, // seq the next publish gets; the first publish is seq 0
}
pub struct Reader {
next: u64, // next seq this reader wants
}
impl<T: Copy + Default> BroadcastRing<T> {
pub fn new(capacity: usize) -> Self {
assert!(capacity.is_power_of_two(), "capacity must be a power of two");
BroadcastRing {
slots: vec![Slot { seq: u64::MAX, value: T::default() }; capacity],
mask: capacity as u64 - 1,
next_seq: 0,
}
}
/// Never blocks and never looks at readers. Returns the assigned seq.
pub fn publish(&mut self, value: T) -> u64 {
todo!()
}
/// A new reader starts at the live edge: it sees only later publishes.
pub fn subscribe(&self) -> Reader {
todo!()
}
/// Item(v) if the reader's slot holds seq == r.next (then advance);
/// Empty if that seq isn't published yet;
/// Lagged(n) if the slot was overwritten: jump r.next to the oldest seq
/// still in the ring (next_seq - capacity) and report n skipped.
pub fn read(&self, r: &mut Reader) -> Recv<T> {
todo!()
}
}
Day 3 — Making it concurrent
A seqlock per slot
Release, Acquire, and the data race you're not allowed to have
With the writer and readers on different threads, a reader can be copying a slot at the moment the writer overwrites it. The standard fix is a seqlock per slot. The writer marks the slot as being written, writes the payload, then publishes the new sequence. The reader reads the stamp, copies the payload, then re-reads the stamp. If the stamp changed, the copy may be torn and is discarded.
// Writer (one thread). Payload words are AtomicU64 so concurrent reads are not UB.
slot.seq.store(WRITING, Ordering::Relaxed);
std::sync::atomic::fence(Ordering::Release); // the marker becomes visible no later than the payload
for (w, v) in slot.words.iter().zip(payload) { w.store(v, Ordering::Relaxed); }
slot.seq.store(seq, Ordering::Release); // publish: payload happens-before this
// Reader (many threads).
let s1 = slot.seq.load(Ordering::Acquire);
if s1 != want { /* Empty or Lagged, decided as in the drill */ }
let copy = slot.words.each_ref().map(|w| w.load(Ordering::Relaxed));
std::sync::atomic::fence(Ordering::Acquire); // the payload loads complete before the re-check
let s2 = slot.seq.load(Ordering::Relaxed);
if s2 != s1 { /* overwritten while copying: treat as Lagged */ }Two subtleties catch everyone. First, the payload must be atomic (or copied with operations the memory model allows to race). In Rust, as in C++, a plain read racing with a write is undefined behaviour even if you then discard the value, and "it's just a memcpy" isn't a defence. For small fixed-size updates, a few AtomicU64 words loaded with Relaxed compile to ordinary loads on x86-64. Second, the fences are not optional. Without the reader's Acquire fence, the second stamp load can be satisfied before the payload loads, and the check proves nothing.
The definitive treatment of exactly this pattern under the C++11 memory model, which Rust shares. Read for the reader-side fence placement and for why naive seqlock code is incorrect even when it passes tests on x86. Week 34 builds a general seqlock on this foundation.
Reader cursors belong to their readers. The writer of an overwriting ring never reads them. But if 16 cursors sit in one Vec<u64>, they share two cache lines, and every reader's increment invalidates its neighbours' copies. Pad each cursor to its own line (CachePadded from week 4). The slots are the opposite case: every reader reads the line the writer just wrote, so each publish sends that line to 16 cores. You can't avoid that, but you can amortize it. Pack several small updates per cache line and have readers consume runs of slots per wakeup.
Why does each slot carry its own sequence number instead of readers comparing their cursor with the writer's next_seq?
Day 4 — Slow consumers
Drop, conflate, or disconnect
Drill 2: latest state per level, in first-dirty order
When a consumer falls behind, there are only three honest options:
- Drop and recover. The consumer notices a sequence gap (its
Lagged), discards its book, and rebuilds from a snapshot plus later incrementals, exactly as in week 10. This is correct for any consumer, but recovery takes time and a snapshot. - Conflate. Instead of queueing every update for a slow consumer, keep only the latest value per key (per price level, for L2). The consumer receives fewer messages, and after it drains it holds the same state it would have had from every update. This is ideal for book feeds to humans and slow systems.
- Disconnect. For consumers that must see every event and can't keep up, cut them off and let them reconnect and recover. It's honest and simple, and it protects everyone else.
The details of conflation decide whether it's correct. Keep pending levels in the order they first became dirty, so a level updated constantly doesn't starve others. Overwrite a pending entry in place. Deliver deletes (qty 0) like any other value. Re-queue a level at the back if it changes again after being popped. Memory is bounded by the number of distinct levels, not the number of updates.
Conflate updates for a slow consumer
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Side {
Bid,
Ask,
}
/// L2 update: the level's new total quantity (0 = level removed).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LevelUpdate {
pub side: Side,
pub price: i64,
pub qty: u64,
pub seq: u64,
}
pub struct Conflator {
// Your fields. One option: a map from (side, price) to the latest
// pending update, plus a FIFO of keys in first-dirty order.
}
impl Conflator {
pub fn new() -> Self {
todo!()
}
/// Record an update. If its level is already pending, replace the
/// pending value in place (its queue position doesn't change).
pub fn push(&mut self, u: LevelUpdate) {
todo!()
}
/// The pending level that became dirty earliest, carrying its latest
/// qty and seq. Once popped, a level that changes again goes to the back.
pub fn pop(&mut self) -> Option<LevelUpdate> {
todo!()
}
/// Number of distinct pending levels.
pub fn len(&self) -> usize {
todo!()
}
}
A conflating consumer applies only the updates it pops. When is its book guaranteed to equal the true book, and what can it show in between?
Why can you conflate an L2 book feed but not a trade feed?
Day 5 — WebSocket feeds
Market data to browsers
Framing, snapshots on subscribe, and per-client queues
Read the base framing protocol section. Frames have a 2–14 byte header. Client-to-server frames must be masked and server-to-client frames must not be. Opcodes distinguish text, binary, close, ping and pong. Then read the closing handshake: a slow consumer should be closed with a proper close frame, not a dropped socket.
A WebSocket gateway is a reader of the broadcast ring that serves many clients:
- One ring reader per gateway thread, not per client. The gateway reads each update once, encodes it once into a shared buffer (binary frames, or JSON if you must), and copies the bytes into each subscribed client's outbound queue.
- Per-client bounded queues with a policy. Book channels conflate per level when the queue is full. Trade channels disconnect. TCP's send buffer is a second queue you don't control, so watch its fill level through the writer's progress, not just your own queue.
- Snapshot on subscribe. A new subscriber gets the book at sequence N, then only incrementals with seq above N, the week 10 protocol over a new transport.
- Compression is a trade-off. permessage-deflate (RFC 7692) cuts bandwidth for text feeds but costs CPU per client per message, and its compression context is per connection, so you can't encode once and send to many. Most latency-sensitive feeds skip it.
Write the policy table for your WebSocket feed: for each channel (L2 book, trades, own orders/fills), what happens when the client's queue is 50% full, 100% full, and when the TCP send buffer stops draining for 5 seconds. What does the client see in each case, and how does it recover?
Day 6 — Multicast and why exchanges use it
Let the switch do the fan-out
UDP multicast, A/B feeds and recovery channels
Read the overview of the feed architecture: incremental feeds published on two redundant channels (A and B), market-recovery feeds carrying periodic snapshots, and instrument-definition feeds. Note how a client is expected to arbitrate A and B by sequence number and use recovery feeds after a gap.
Find IP_ADD_MEMBERSHIP and IP_MULTICAST_LOOP. Joining a multicast group is a socket option. The kernel sends the IGMP membership report and the network delivers the group's packets to your NIC.
Inside a colocation facility, exchanges publish market data over UDP multicast. The publisher sends each packet once, and switches replicate it to every subscribed port. The fan-out cost moves into hardware, the publisher's cost doesn't depend on subscriber count, and there's no slow-consumer problem at the source: a slow subscriber drops packets in its own NIC or socket buffer and has to recover by itself.
UDP doesn't retransmit, so feeds are built to be recoverable. Each message carries a sequence number (MoldUDP64, MDP 3.0 packet headers). The same stream is published twice on separate networks (A/B feeds), and receivers take whichever copy of each sequence number arrives first. Gaps that both lines miss are filled from a snapshot or recovery channel, or a request-response retransmission service. This is your broadcast ring's Lagged → snapshot path, distributed across a network.
How does a feed handler use A and B multicast lines, and what does it do when both miss the same packet?
List every consumer of your exchange's market data and assign each one a fan-out mechanism (gated ring, overwriting ring, conflating queue, WebSocket, multicast) and a slow-consumer policy. Which of them are allowed to slow the publisher, and why exactly those?
Capstone
Extend crates/marketdata with the fan-out layer:
- A concurrent broadcast ring: one writer, any number of readers, fixed capacity (power of two), slots stamped with sequence numbers and payloads stored as atomic words, using the per-slot seqlock protocol from Day 3.
broadcast::<T>(capacity)returns the singleRingPublisherand anArc<BroadcastRing>to subscribe from.TimplementsRingValue, which packs it into at most 7u64words, so a slot (stamp plus payload) fits in one cache line: anL2Updateis 3 words, so its slots are 32 bytes, two per line.publishnever blocks and never reads reader state.Reader::try_recvreturnsItem,EmptyorLagged(n). Reader cursors live in theReader, which is aligned to 128 bytes.subscribestarts at the live edge andsubscribe_oldestat the oldest value still in the ring. - Conflation for slow consumers: a
Conflatorof L2 levels with first-dirty ordering and memory bounded by the number of distinct levels. It doesn't mark drain points itself. The WebSocket adapter sends each drain as one batch, so batch boundaries are the points where the client's book is consistent. - A WebSocket adapter, std only: the handshake (SHA-1 and base64 included), RFC 6455 framing, and
serve_ws. It's simpler than Day 5's design. Each client gets its own thread and its own ringReader, there's one book and no per-instrument subscriptions, and there's no trade channel. A client gets the latest snapshot from aSnapshotCell, then conflated batches as JSON text frames. After a lag, it gets a fresh snapshot once a newer one is stored. A client whose socket accepts nothing for 5 seconds (WRITE_TIMEOUT) is disconnected, and a client's Close frame is answered. - Tests (
w16_*): the single-threaded semantics from the drill.w16_ring_multi_reader_stress_is_gap_free_or_lagged, with real threads, checks that readers see strictly increasing values with explicitLaggedgaps and never a torn or out-of-order value. Then conflation (last value per level, first-dirty order, drain-point consistency), the RFC 6455 accept key, frame lengths at the 125/126/65,535/65,536 boundaries, the handshake, and a loopback client that receives a snapshot and then updates.
Pitfalls:
- Torn reads that "work". A seqlock with a missing fence or non-atomic payload passes tests on x86, because x86 doesn't reorder loads with other loads, and fails on ARM or after a compiler upgrade. Write it to the memory model, not to the hardware. Run the stress test under Miri on a small configuration, and on an ARM machine if you can.
- Readers that spin on Empty. A busy-polling reader per client burns a core per client. Readers of the ring should be few (gateway threads, internal services), each serving many clients. The repo's simpler server does give each client a thread and a reader, but whenever the ring is empty it blocks for up to 1 ms (
IDLE_WAIT) reading the client's socket, so an idle client costs about a thousand wakeups a second rather than a whole core. - Lag that isn't reported. A reader that silently skips to the newest data after a lap produces a corrupt book.
Laggedmust reach the consumer's recovery logic every time.
How to think about the 20M msgs/s target with 16 readers: w16.fanout publishes rounds of 2²⁰ L2Updates into a 4,096-slot ring, and a round ends when every reader has reached the last value, by reading it or by a Lagged skip. That's 50 ns per message for the writer, while 16 readers each try to read every message. The bench also prints the share of reader deliveries lost to lag, next to the core count. The writer's cost is the slot write plus making that cache line exclusive again after readers have pulled it into their caches. That's a cross-core coherence round trip, tens of nanoseconds on a typical server, so you only hit the target if the cost is amortized: small payloads packed several per cache line, readers consuming runs of slots per pass, and nothing else shared between threads. Running 16 readers plus a writer needs at least 17 hardware threads. On a smaller machine readers get descheduled, lag, and your number measures the scheduler, so report the core count with the result.
crates/marketdataFan-out that never waits for a reader
A concurrent single-writer broadcast ring with per-reader cursors and lag detection, conflation for slow consumers, and a WebSocket feed adapter.
cargo xtask test --week 16 # tests named w16_*
cargo xtask bench --week 16 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 16 tests pass w16_*
- Publish to 16 readers (needs ≥17 hardware threads) w16.fanouttarget ≥ 20.0M msgs/s
Feeds into
- Week 23 subscribes simulated traders to the feed and asserts end-to-end outcomes from what they observe.
- Week 24 runs the full exchange under load, with fan-out as one of the stages whose latency you attribute.