diff --git a/src/book.rs b/src/book.rs index 952e257..3f328ef 100644 --- a/src/book.rs +++ b/src/book.rs @@ -208,11 +208,11 @@ pub struct OrderBook { /// Snapshot generation used to retain book levels without rescanning input payloads. snapshot_generation: u64, - /// Last accepted full-book snapshot hash, when the feed provides one. + /// Last accepted full-book snapshot hash fingerprint, when the feed provides one. /// /// This is used as a tie-breaker for same-millisecond snapshots. If the timestamp is equal - /// but the hash differs, the snapshot can still represent a newer book state. - last_snapshot_hash: Option, + /// but the hash fingerprint differs, the snapshot can still represent a newer book state. + last_snapshot_hash_fingerprint: Option, /// Minimum tick size for this market in ticks (like 10 for $0.001 increments) /// Some markets only allow certain price increments @@ -255,7 +255,7 @@ impl OrderBook { bids: BookSide::new(BookSideKind::Bid, max_depth), // Empty to start asks: BookSide::new(BookSideKind::Ask, max_depth), // Empty to start snapshot_generation: 0, - last_snapshot_hash: None, + last_snapshot_hash_fingerprint: None, tick_size_ticks: None, // We'll set this later when we learn about the market max_depth, } @@ -620,7 +620,7 @@ impl OrderBook { } self.last_snapshot_timestamp_ms = update.timestamp; - self.last_snapshot_hash = update.hash.clone(); + self.last_snapshot_hash_fingerprint = update.hash.as_deref().map(snapshot_hash_fingerprint); self.timestamp = chrono::DateTime::::from_timestamp_millis(update.timestamp as i64) .unwrap_or_else(Utc::now); self.begin_snapshot(); @@ -730,7 +730,9 @@ impl OrderBook { return false; } - hash.is_some_and(|hash| self.last_snapshot_hash.as_deref() != Some(hash)) + hash.is_some_and(|hash| { + self.last_snapshot_hash_fingerprint != Some(snapshot_hash_fingerprint(hash)) + }) } #[inline] @@ -769,7 +771,7 @@ impl OrderBook { } self.last_snapshot_timestamp_ms = timestamp; - self.last_snapshot_hash = hash.map(str::to_owned); + self.last_snapshot_hash_fingerprint = hash.map(snapshot_hash_fingerprint); self.timestamp = chrono::DateTime::::from_timestamp_millis(timestamp as i64) .unwrap_or_else(Utc::now); self.begin_snapshot(); @@ -1005,6 +1007,16 @@ struct BookShard { books: RwLock>, } +#[inline] +fn snapshot_hash_fingerprint(hash: &str) -> u64 { + let mut fingerprint = 0xcbf2_9ce4_8422_2325u64; + for &byte in hash.as_bytes() { + fingerprint ^= byte as u64; + fingerprint = fingerprint.wrapping_mul(0x0000_0100_0000_01b3); + } + fingerprint +} + #[inline] fn shard_index(token_id: &str, shard_count: usize) -> usize { debug_assert!(shard_count > 0); diff --git a/tests/no_alloc_hot_paths.rs b/tests/no_alloc_hot_paths.rs index b71b06a..615ee7f 100644 --- a/tests/no_alloc_hot_paths.rs +++ b/tests/no_alloc_hot_paths.rs @@ -182,6 +182,50 @@ fn ws_book_message( json.into_bytes() } +fn ws_book_message_with_hash( + asset_id: &str, + timestamp: u64, + hash: &str, + bid_ticks: &[i64], + ask_ticks: &[i64], +) -> Vec { + let mut json = + String::with_capacity(190 + hash.len() + (bid_ticks.len() + ask_ticks.len()) * 40); + write!( + &mut json, + "{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":{timestamp},\"hash\":\"{hash}\",\"bids\":[" + ) + .unwrap(); + + for (idx, price_ticks) in bid_ticks.iter().enumerate() { + if idx > 0 { + json.push(','); + } + write!( + &mut json, + "{{\"price\":\"{}\",\"size\":\"100.0000\"}}", + Decimal::new(*price_ticks, 4) + ) + .unwrap(); + } + + json.push_str("],\"asks\":["); + for (idx, price_ticks) in ask_ticks.iter().enumerate() { + if idx > 0 { + json.push(','); + } + write!( + &mut json, + "{{\"price\":\"{}\",\"size\":\"100.0000\"}}", + Decimal::new(*price_ticks, 4) + ) + .unwrap(); + } + + json.push_str("]}"); + json.into_bytes() +} + fn contiguous_ticks(start: i64, len: usize, step: i64) -> Vec { (0..len).map(|idx| start + (idx as i64 * step)).collect() } @@ -410,6 +454,30 @@ fn no_alloc_ws_book_update_processor_apply_existing_levels() { guard.assert_no_heap_traffic(); } +#[test] +fn no_alloc_ws_book_update_processor_same_timestamp_hash_change() { + let asset_id = "test_asset_id"; + let manager = OrderBookManager::new(100); + manager.get_or_create_book(asset_id).unwrap(); + seed_book_levels(&manager, asset_id, &[7500], &[7600]); + + let mut processor = WsBookUpdateProcessor::new(4096); + let mut warmup_msg = ws_book_message_with_hash(asset_id, 10, "hash_a", &[7500], &[7600]); + processor + .process_bytes(warmup_msg.as_mut_slice(), &manager) + .unwrap(); + + let mut msg = ws_book_message_with_hash(asset_id, 10, "hash_b", &[7500], &[7600]); + + let _ = heap_operation_count(); + + let guard = NoHeapTrafficGuard::new(); + processor + .process_bytes(msg.as_mut_slice(), &manager) + .unwrap(); + guard.assert_no_heap_traffic(); +} + #[test] fn no_alloc_ws_book_update_processor_one_new_level_with_reserved_capacity() { let asset_id = "test_asset_id";