Merge pull request #60 from floor-licker/fix/atomic-book-snapshots

fix: apply book snapshots atomically
This commit is contained in:
floor-licker
2026-06-22 19:48:12 -04:00
committed by GitHub
4 changed files with 281 additions and 87 deletions
+152 -58
View File
@@ -16,6 +16,13 @@ struct StoredLevel {
generation: u64, generation: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ParsedBookLevel {
pub side: Side,
pub price_ticks: Price,
pub size_units: Qty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BookSideKind { enum BookSideKind {
Bid, Bid,
@@ -519,14 +526,12 @@ impl OrderBook {
Ok(()) Ok(())
} }
/// Begin applying a WebSocket `book` update (hot-path oriented). /// Return whether a WebSocket `book` snapshot should be applied.
/// pub(crate) fn should_apply_ws_book_update(
/// This is intended for in-place WS processing where we *stream* levels out of a decoded &self,
/// message, without constructing intermediate `BookUpdate` structs. asset_id: &str,
/// timestamp: u64,
/// Returns `Ok(true)` if the update should be applied, or `Ok(false)` if the update is stale ) -> Result<bool> {
/// and should be skipped.
pub(crate) fn begin_ws_book_update(&mut self, asset_id: &str, timestamp: u64) -> Result<bool> {
if asset_id != self.token_id { if asset_id != self.token_id {
return Err(PolyfillError::validation("Token ID mismatch")); return Err(PolyfillError::validation("Token ID mismatch"));
} }
@@ -535,39 +540,27 @@ impl OrderBook {
return Ok(false); return Ok(false);
} }
self.sequence = timestamp;
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(timestamp as i64)
.unwrap_or_else(Utc::now);
self.begin_snapshot();
Ok(true) Ok(true)
} }
/// Apply a single WS `book` level (already converted to internal fixed-point). /// Atomically apply a WebSocket `book` snapshot.
/// ///
/// Note: Insertions of new price levels may allocate (Vec growth/shifting). In a strict /// The caller should parse levels into `ParsedBookLevel`s before calling this method. This
/// zero-alloc hot path, all expected levels must be warmed up ahead of time. /// method validates tick alignment before mutating sequence/generation or book levels, so a
pub(crate) fn apply_ws_book_level_fast( /// malformed snapshot leaves the existing book unchanged.
pub(crate) fn apply_ws_book_snapshot_fast(
&mut self, &mut self,
side: Side, asset_id: &str,
price_ticks: Price, timestamp: u64,
size_units: Qty, levels: &[ParsedBookLevel],
) -> Result<()> { ) -> Result<bool> {
if let Some(tick_size_ticks) = self.tick_size_ticks { if !self.should_apply_ws_book_update(asset_id, timestamp)? {
if tick_size_ticks > 0 && !price_ticks.is_multiple_of(tick_size_ticks) { return Ok(false);
return Err(PolyfillError::validation("Price not aligned to tick size"));
}
} }
self.apply_snapshot_level(side, price_ticks, size_units); self.apply_validated_snapshot(timestamp, levels)?;
Ok(()) Ok(true)
}
/// Finish applying a WS `book` snapshot.
pub(crate) fn finish_ws_book_update(&mut self) {
self.finish_snapshot();
self.trim_depth();
} }
/// Apply a WebSocket `book` update for this token. /// Apply a WebSocket `book` update for this token.
@@ -592,40 +585,38 @@ impl OrderBook {
return Ok(()); return Ok(());
} }
// Validate the whole snapshot before mutating this book. A malformed level must not leave
// behind a partial generation or an advanced sequence number.
for level in &update.bids {
let parsed = self.parse_snapshot_summary(Side::BUY, level)?;
self.validate_snapshot_level(parsed)?;
}
for level in &update.asks {
let parsed = self.parse_snapshot_summary(Side::SELL, level)?;
self.validate_snapshot_level(parsed)?;
}
self.sequence = update.timestamp; self.sequence = update.timestamp;
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(update.timestamp as i64) self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(update.timestamp as i64)
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
self.begin_snapshot(); self.begin_snapshot();
// Apply bids (BUY) and asks (SELL) as level upserts. // Re-parse after validation to preserve the existing no-allocation behavior for
// `BookUpdate` snapshots. Decimal conversion is deterministic, so these conversions cannot
// fail after the validation pass above.
for level in &update.bids { for level in &update.bids {
let price_ticks = decimal_to_price(level.price) let parsed = self
.map_err(|_| PolyfillError::validation("Invalid price"))?; .parse_snapshot_summary(Side::BUY, level)
let size_units = decimal_to_qty(level.size) .expect("book update bid level was validated before mutation");
.map_err(|_| PolyfillError::validation("Invalid size"))?; self.apply_snapshot_level(parsed.side, parsed.price_ticks, parsed.size_units);
if let Some(tick_size_ticks) = self.tick_size_ticks {
if tick_size_ticks > 0 && !price_ticks.is_multiple_of(tick_size_ticks) {
return Err(PolyfillError::validation("Price not aligned to tick size"));
}
}
self.apply_snapshot_level(Side::BUY, price_ticks, size_units);
} }
for level in &update.asks { for level in &update.asks {
let price_ticks = decimal_to_price(level.price) let parsed = self
.map_err(|_| PolyfillError::validation("Invalid price"))?; .parse_snapshot_summary(Side::SELL, level)
let size_units = decimal_to_qty(level.size) .expect("book update ask level was validated before mutation");
.map_err(|_| PolyfillError::validation("Invalid size"))?; self.apply_snapshot_level(parsed.side, parsed.price_ticks, parsed.size_units);
if let Some(tick_size_ticks) = self.tick_size_ticks {
if tick_size_ticks > 0 && !price_ticks.is_multiple_of(tick_size_ticks) {
return Err(PolyfillError::validation("Price not aligned to tick size"));
}
}
self.apply_snapshot_level(Side::SELL, price_ticks, size_units);
} }
self.finish_snapshot(); self.finish_snapshot();
@@ -707,6 +698,55 @@ impl OrderBook {
self.snapshot_generation = self.snapshot_generation.wrapping_add(1); self.snapshot_generation = self.snapshot_generation.wrapping_add(1);
} }
#[inline]
fn parse_snapshot_summary(&self, side: Side, level: &OrderSummary) -> Result<ParsedBookLevel> {
let price_ticks = decimal_to_price(level.price)
.map_err(|_| PolyfillError::validation("Invalid price"))?;
let size_units =
decimal_to_qty(level.size).map_err(|_| PolyfillError::validation("Invalid size"))?;
Ok(ParsedBookLevel {
side,
price_ticks,
size_units,
})
}
#[inline]
fn validate_snapshot_level(&self, level: ParsedBookLevel) -> Result<()> {
if let Some(tick_size_ticks) = self.tick_size_ticks {
if tick_size_ticks > 0 && !level.price_ticks.is_multiple_of(tick_size_ticks) {
return Err(PolyfillError::validation("Price not aligned to tick size"));
}
}
Ok(())
}
fn apply_validated_snapshot(
&mut self,
timestamp: u64,
levels: &[ParsedBookLevel],
) -> Result<()> {
for &level in levels {
self.validate_snapshot_level(level)?;
}
self.sequence = timestamp;
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(timestamp as i64)
.unwrap_or_else(Utc::now);
self.begin_snapshot();
for &level in levels {
self.apply_snapshot_level(level.side, level.price_ticks, level.size_units);
}
self.finish_snapshot();
self.trim_depth();
Ok(())
}
#[inline] #[inline]
fn apply_snapshot_level(&mut self, side: Side, price_ticks: Price, size_units: Qty) { fn apply_snapshot_level(&mut self, side: Side, price_ticks: Price, size_units: Qty) {
let generation = self.snapshot_generation; let generation = self.snapshot_generation;
@@ -1346,6 +1386,60 @@ mod tests {
assert_eq!(book.best_ask().unwrap().size, dec!(45)); assert_eq!(book.best_ask().unwrap().size, dec!(45));
} }
#[test]
fn test_book_update_error_keeps_existing_snapshot() {
let mut book = OrderBook::new("test_token".to_string(), 10);
book.set_tick_size_ticks(100);
book.apply_book_update(&BookUpdate {
asset_id: "test_token".to_string(),
market: "0xabc".to_string(),
timestamp: 100,
bids: vec![OrderSummary {
price: dec!(0.50),
size: dec!(10),
}],
asks: vec![OrderSummary {
price: dec!(0.60),
size: dec!(20),
}],
hash: None,
})
.unwrap();
let err = book
.apply_book_update(&BookUpdate {
asset_id: "test_token".to_string(),
market: "0xabc".to_string(),
timestamp: 101,
bids: vec![
OrderSummary {
price: dec!(0.51),
size: dec!(11),
},
OrderSummary {
price: dec!(0.515),
size: dec!(12),
},
],
asks: vec![OrderSummary {
price: dec!(0.61),
size: dec!(21),
}],
hash: None,
})
.unwrap_err();
assert!(err.to_string().contains("Price not aligned"));
assert_eq!(book.sequence, 100);
assert_eq!(book.bids(None).len(), 1);
assert_eq!(book.asks(None).len(), 1);
assert_eq!(book.best_bid().unwrap().price, dec!(0.50));
assert_eq!(book.best_bid().unwrap().size, dec!(10));
assert_eq!(book.best_ask().unwrap().price, dec!(0.60));
assert_eq!(book.best_ask().unwrap().size, dec!(20));
}
#[test] #[test]
fn test_book_update_depth_keeps_best_prices_independent_of_payload_order() { fn test_book_update_depth_keeps_best_prices_independent_of_payload_order() {
let mut book = OrderBook::new("test_token".to_string(), 2); let mut book = OrderBook::new("test_token".to_string(), 2);
+13
View File
@@ -705,4 +705,17 @@ mod tests {
let results: Vec<serde_json::Value> = decoder.parse_json_stream(data).unwrap(); let results: Vec<serde_json::Value> = decoder.parse_json_stream(data).unwrap();
assert_eq!(results.len(), 2); assert_eq!(results.len(), 2);
} }
#[test]
fn stream_book_message_requires_bids_and_asks() {
let missing_asks = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"bids":[]}"#;
assert!(parse_stream_messages_bytes(missing_asks).is_err());
let missing_bids = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"asks":[]}"#;
assert!(parse_stream_messages_bytes(missing_bids).is_err());
let empty_sides = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"bids":[],"asks":[]}"#;
let messages = parse_stream_messages_bytes(empty_sides).unwrap();
assert_eq!(messages.len(), 1);
}
} }
+2 -8
View File
@@ -909,15 +909,9 @@ pub struct BookUpdate {
pub market: String, pub market: String,
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")] #[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
pub timestamp: u64, pub timestamp: u64,
#[serde( #[serde(deserialize_with = "crate::decode::deserializers::vec_from_null")]
default,
deserialize_with = "crate::decode::deserializers::vec_from_null"
)]
pub bids: Vec<OrderSummary>, pub bids: Vec<OrderSummary>,
#[serde( #[serde(deserialize_with = "crate::decode::deserializers::vec_from_null")]
default,
deserialize_with = "crate::decode::deserializers::vec_from_null"
)]
pub asks: Vec<OrderSummary>, pub asks: Vec<OrderSummary>,
#[serde(default)] #[serde(default)]
pub hash: Option<String>, pub hash: Option<String>,
+114 -21
View File
@@ -7,7 +7,7 @@
//! may still allocate when producing `Message::Text(String)`. This module aims to //! may still allocate when producing `Message::Text(String)`. This module aims to
//! make the *processing* layer allocation-free so we can enforce it with tests. //! make the *processing* layer allocation-free so we can enforce it with tests.
use crate::book::OrderBookManager; use crate::book::{OrderBookManager, ParsedBookLevel};
use crate::errors::{PolyfillError, Result}; use crate::errors::{PolyfillError, Result};
use crate::types::{Price, Qty, Side, MAX_PRICE_TICKS, MAX_QTY, MIN_PRICE_TICKS, SCALE_FACTOR}; use crate::types::{Price, Qty, Side, MAX_PRICE_TICKS, MAX_QTY, MIN_PRICE_TICKS, SCALE_FACTOR};
use simd_json::prelude::*; use simd_json::prelude::*;
@@ -26,6 +26,7 @@ pub struct WsBookApplyStats {
pub struct WsBookUpdateProcessor { pub struct WsBookUpdateProcessor {
buffers: simd_json::Buffers, buffers: simd_json::Buffers,
tape: Option<simd_json::Tape<'static>>, tape: Option<simd_json::Tape<'static>>,
parsed_levels: Vec<ParsedBookLevel>,
} }
impl WsBookUpdateProcessor { impl WsBookUpdateProcessor {
@@ -37,6 +38,7 @@ impl WsBookUpdateProcessor {
buffers: simd_json::Buffers::new(input_len_hint), buffers: simd_json::Buffers::new(input_len_hint),
// Store an empty tape with a `'static` lifetime so we can reuse its allocation. // Store an empty tape with a `'static` lifetime so we can reuse its allocation.
tape: Some(simd_json::Tape::null().reset()), tape: Some(simd_json::Tape::null().reset()),
parsed_levels: Vec::with_capacity((input_len_hint / 32).max(8)),
} }
} }
@@ -55,7 +57,7 @@ impl WsBookUpdateProcessor {
let result = match simd_json::fill_tape(bytes, &mut self.buffers, &mut tape) { let result = match simd_json::fill_tape(bytes, &mut self.buffers, &mut tape) {
Ok(()) => { Ok(()) => {
let root = tape.as_value(); let root = tape.as_value();
process_root_value(root, books) process_root_value(root, books, &mut self.parsed_levels)
}, },
Err(e) => Err(PolyfillError::parse( Err(e) => Err(PolyfillError::parse(
"Failed to parse WebSocket JSON", "Failed to parse WebSocket JSON",
@@ -82,9 +84,10 @@ impl WsBookUpdateProcessor {
fn process_root_value<'tape, 'input>( fn process_root_value<'tape, 'input>(
value: simd_json::tape::Value<'tape, 'input>, value: simd_json::tape::Value<'tape, 'input>,
books: &OrderBookManager, books: &OrderBookManager,
parsed_levels: &mut Vec<ParsedBookLevel>,
) -> Result<WsBookApplyStats> { ) -> Result<WsBookApplyStats> {
if let Some(obj) = value.as_object() { if let Some(obj) = value.as_object() {
return process_stream_object(obj, books); return process_stream_object(obj, books, parsed_levels);
} }
let Some(arr) = value.as_array() else { let Some(arr) = value.as_array() else {
@@ -96,7 +99,7 @@ fn process_root_value<'tape, 'input>(
let Some(obj) = elem.as_object() else { let Some(obj) = elem.as_object() else {
continue; continue;
}; };
let stats = process_stream_object(obj, books)?; let stats = process_stream_object(obj, books, parsed_levels)?;
total.book_messages += stats.book_messages; total.book_messages += stats.book_messages;
total.book_levels_applied += stats.book_levels_applied; total.book_levels_applied += stats.book_levels_applied;
} }
@@ -107,6 +110,7 @@ fn process_root_value<'tape, 'input>(
fn process_stream_object<'tape, 'input>( fn process_stream_object<'tape, 'input>(
obj: simd_json::tape::Object<'tape, 'input>, obj: simd_json::tape::Object<'tape, 'input>,
books: &OrderBookManager, books: &OrderBookManager,
parsed_levels: &mut Vec<ParsedBookLevel>,
) -> Result<WsBookApplyStats> { ) -> Result<WsBookApplyStats> {
let Some(event_type) = obj.get("event_type").and_then(|v| v.into_string()) else { let Some(event_type) = obj.get("event_type").and_then(|v| v.into_string()) else {
return Ok(WsBookApplyStats::default()); return Ok(WsBookApplyStats::default());
@@ -127,25 +131,36 @@ fn process_stream_object<'tape, 'input>(
let timestamp = parse_u64(timestamp_value) let timestamp = parse_u64(timestamp_value)
.ok_or_else(|| PolyfillError::parse("Invalid timestamp", None))?; .ok_or_else(|| PolyfillError::parse("Invalid timestamp", None))?;
let bids = obj.get("bids").and_then(|v| v.as_array()); let bids = obj
let asks = obj.get("asks").and_then(|v| v.as_array()); .get("bids")
.ok_or_else(|| PolyfillError::parse("Missing bids", None))?
.as_array()
.ok_or_else(|| PolyfillError::parse("Invalid bids", None))?;
let asks = obj
.get("asks")
.ok_or_else(|| PolyfillError::parse("Missing asks", None))?
.as_array()
.ok_or_else(|| PolyfillError::parse("Invalid asks", None))?;
let levels_applied = books.with_book_mut(asset_id, |book| { let result = books.with_book_mut(asset_id, |book| {
if !book.begin_ws_book_update(asset_id, timestamp)? { parsed_levels.clear();
if !book.should_apply_ws_book_update(asset_id, timestamp)? {
return Ok(0); return Ok(0);
} }
let mut applied = 0usize; collect_levels(Side::BUY, bids, parsed_levels)?;
if let Some(bids) = bids { collect_levels(Side::SELL, asks, parsed_levels)?;
applied += apply_levels(book, Side::BUY, bids)?;
}
if let Some(asks) = asks {
applied += apply_levels(book, Side::SELL, asks)?;
}
book.finish_ws_book_update(); let parsed_count = parsed_levels.len();
Ok(applied) if book.apply_ws_book_snapshot_fast(asset_id, timestamp, parsed_levels)? {
})?; Ok(parsed_count)
} else {
Ok(0)
}
});
parsed_levels.clear();
let levels_applied = result?;
Ok(WsBookApplyStats { Ok(WsBookApplyStats {
book_messages: 1, book_messages: 1,
@@ -159,10 +174,10 @@ fn parse_u64<'tape, 'input>(value: simd_json::tape::Value<'tape, 'input>) -> Opt
.or_else(|| value.into_string().and_then(|s| s.parse::<u64>().ok())) .or_else(|| value.into_string().and_then(|s| s.parse::<u64>().ok()))
} }
fn apply_levels<'tape, 'input>( fn collect_levels<'tape, 'input>(
book: &mut crate::book::OrderBook,
side: Side, side: Side,
levels: simd_json::tape::Array<'tape, 'input>, levels: simd_json::tape::Array<'tape, 'input>,
parsed_levels: &mut Vec<ParsedBookLevel>,
) -> Result<usize> { ) -> Result<usize> {
let mut applied = 0usize; let mut applied = 0usize;
for level in levels.iter() { for level in levels.iter() {
@@ -182,7 +197,11 @@ fn apply_levels<'tape, 'input>(
let price_ticks = parse_price_ticks_4dp(price_str)?; let price_ticks = parse_price_ticks_4dp(price_str)?;
let size_units = parse_qty_scaled_4dp(size_str)?; let size_units = parse_qty_scaled_4dp(size_str)?;
book.apply_ws_book_level_fast(side, price_ticks, size_units)?; parsed_levels.push(ParsedBookLevel {
side,
price_ticks,
size_units,
});
applied += 1; applied += 1;
} }
@@ -273,6 +292,8 @@ fn parse_scaled_4_u64(value: &str) -> Result<u64> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::types::{BookUpdate, OrderSummary};
use rust_decimal_macros::dec;
#[test] #[test]
fn fixed_point_parser_matches_expected_price_ticks() { fn fixed_point_parser_matches_expected_price_ticks() {
@@ -318,4 +339,76 @@ mod tests {
assert_eq!(stats.book_messages, 1); assert_eq!(stats.book_messages, 1);
assert_eq!(stats.book_levels_applied, 1); assert_eq!(stats.book_levels_applied, 1);
} }
#[test]
fn processor_error_keeps_existing_snapshot() {
let books = OrderBookManager::new(10);
books.get_or_create_book("test_asset_id").unwrap();
books
.apply_book_update(&BookUpdate {
asset_id: "test_asset_id".to_string(),
market: "0xabc".to_string(),
timestamp: 1000,
bids: vec![OrderSummary {
price: dec!(0.50),
size: dec!(10),
}],
asks: vec![OrderSummary {
price: dec!(0.60),
size: dec!(20),
}],
hash: None,
})
.unwrap();
let mut processor = WsBookUpdateProcessor::new(1024);
let mut invalid_snapshot = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1001,"bids":[{"price":"0.5100","size":"11.0000"},{"price":"0.51001","size":"12.0000"}],"asks":[{"price":"0.6100","size":"21.0000"}]}"#.to_vec();
assert!(processor
.process_bytes(invalid_snapshot.as_mut_slice(), &books)
.is_err());
let snapshot = books.get_book("test_asset_id").unwrap();
assert_eq!(snapshot.sequence, 1000);
assert_eq!(snapshot.bids.len(), 1);
assert_eq!(snapshot.asks.len(), 1);
assert_eq!(snapshot.bids[0].price, dec!(0.50));
assert_eq!(snapshot.bids[0].size, dec!(10));
assert_eq!(snapshot.asks[0].price, dec!(0.60));
assert_eq!(snapshot.asks[0].size, dec!(20));
}
#[test]
fn processor_missing_side_keeps_existing_snapshot() {
let books = OrderBookManager::new(10);
books.get_or_create_book("test_asset_id").unwrap();
books
.apply_book_update(&BookUpdate {
asset_id: "test_asset_id".to_string(),
market: "0xabc".to_string(),
timestamp: 1000,
bids: vec![OrderSummary {
price: dec!(0.50),
size: dec!(10),
}],
asks: vec![OrderSummary {
price: dec!(0.60),
size: dec!(20),
}],
hash: None,
})
.unwrap();
let mut processor = WsBookUpdateProcessor::new(1024);
let mut missing_asks = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1001,"bids":[{"price":"0.5100","size":"11.0000"}]}"#.to_vec();
assert!(processor
.process_bytes(missing_asks.as_mut_slice(), &books)
.is_err());
let snapshot = books.get_book("test_asset_id").unwrap();
assert_eq!(snapshot.sequence, 1000);
assert_eq!(snapshot.bids.len(), 1);
assert_eq!(snapshot.asks.len(), 1);
assert_eq!(snapshot.bids[0].price, dec!(0.50));
assert_eq!(snapshot.asks[0].price, dec!(0.60));
}
} }