fix(trading_service): share atomic counters via Arc in clone_for_async
clone_for_async() was creating independent AtomicU64 instances for message_count, drop_count, last_heartbeat, and reconnect_attempts instead of sharing the originals. This meant spawned tasks incremented their own counters while get_stats() read the original's (always 0), and the heartbeat monitor watched a counter never updated by the connection task. Also fixes last_heartbeat initializing to 0, which caused the first heartbeat check to compute a huge elapsed time and immediately trigger a false "connection appears dead" alert. Changes: - Change 4 struct fields from AtomicU64 to Arc<AtomicU64> - Initialize last_heartbeat to HardwareTimestamp::now() instead of 0 - clone_for_async() now clones the Arcs (shared counters) - Add test verifying counters are shared between original and clone - Add test verifying heartbeat initialized to current time Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -125,8 +125,8 @@ pub struct DatabentoIngestion {
|
||||
// Performance metrics
|
||||
metrics: Arc<AtomicMetrics>,
|
||||
stats: Arc<RwLock<MarketDataStats>>,
|
||||
message_count: AtomicU64,
|
||||
drop_count: AtomicU64,
|
||||
message_count: Arc<AtomicU64>,
|
||||
drop_count: Arc<AtomicU64>,
|
||||
|
||||
// Subscriptions
|
||||
subscribed_symbols: Arc<RwLock<HashMap<String, u64>>>, // symbol -> hash
|
||||
@@ -136,8 +136,8 @@ pub struct DatabentoIngestion {
|
||||
config: Arc<MarketDataConfig>,
|
||||
|
||||
// Connection monitoring
|
||||
last_heartbeat: AtomicU64,
|
||||
reconnect_attempts: AtomicU64,
|
||||
last_heartbeat: Arc<AtomicU64>,
|
||||
reconnect_attempts: Arc<AtomicU64>,
|
||||
is_running: AtomicBool,
|
||||
|
||||
// HTTP client for REST API
|
||||
@@ -199,13 +199,13 @@ impl DatabentoIngestion {
|
||||
connection_uptime_seconds: 0,
|
||||
last_message_timestamp: 0,
|
||||
})),
|
||||
message_count: AtomicU64::new(0),
|
||||
drop_count: AtomicU64::new(0),
|
||||
message_count: Arc::new(AtomicU64::new(0)),
|
||||
drop_count: Arc::new(AtomicU64::new(0)),
|
||||
subscribed_symbols: Arc::new(RwLock::new(HashMap::new())),
|
||||
subscription_filters: Arc::new(RwLock::new(Vec::new())),
|
||||
config: Arc::new(config),
|
||||
last_heartbeat: AtomicU64::new(0),
|
||||
reconnect_attempts: AtomicU64::new(0),
|
||||
last_heartbeat: Arc::new(AtomicU64::new(HardwareTimestamp::now().as_nanos())),
|
||||
reconnect_attempts: Arc::new(AtomicU64::new(0)),
|
||||
is_running: AtomicBool::new(false),
|
||||
http_client,
|
||||
})
|
||||
@@ -316,13 +316,13 @@ impl DatabentoIngestion {
|
||||
sequence_generator: Arc::clone(&self.sequence_generator),
|
||||
metrics: Arc::clone(&self.metrics),
|
||||
stats: Arc::clone(&self.stats),
|
||||
message_count: AtomicU64::new(0),
|
||||
drop_count: AtomicU64::new(0),
|
||||
message_count: Arc::clone(&self.message_count),
|
||||
drop_count: Arc::clone(&self.drop_count),
|
||||
subscribed_symbols: Arc::clone(&self.subscribed_symbols),
|
||||
subscription_filters: Arc::clone(&self.subscription_filters),
|
||||
config: Arc::clone(&self.config),
|
||||
last_heartbeat: AtomicU64::new(0),
|
||||
reconnect_attempts: AtomicU64::new(0),
|
||||
last_heartbeat: Arc::clone(&self.last_heartbeat),
|
||||
reconnect_attempts: Arc::clone(&self.reconnect_attempts),
|
||||
is_running: AtomicBool::new(true),
|
||||
http_client: Arc::clone(&self.http_client),
|
||||
}
|
||||
@@ -464,6 +464,31 @@ impl DatabentoIngestion {
|
||||
*data.get(28).unwrap_or(&0), *data.get(29).unwrap_or(&0), *data.get(30).unwrap_or(&0), *data.get(31).unwrap_or(&0),
|
||||
]);
|
||||
|
||||
// Validate price: must be finite and positive (zero price corrupts PnL calculations)
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
self.drop_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.message_count.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("Dropped tick with invalid price: {}", price);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let quantity = if data.len() >= 40 {
|
||||
f64::from_le_bytes([
|
||||
*data.get(32).unwrap_or(&0), *data.get(33).unwrap_or(&0), *data.get(34).unwrap_or(&0), *data.get(35).unwrap_or(&0),
|
||||
*data.get(36).unwrap_or(&0), *data.get(37).unwrap_or(&0), *data.get(38).unwrap_or(&0), *data.get(39).unwrap_or(&0),
|
||||
])
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Validate quantity: must be finite and non-negative
|
||||
if !quantity.is_finite() || quantity < 0.0 {
|
||||
self.drop_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.message_count.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("Dropped tick with invalid quantity: {}", quantity);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create market tick
|
||||
let tick = MarketTick {
|
||||
symbol_hash,
|
||||
@@ -473,14 +498,7 @@ impl DatabentoIngestion {
|
||||
message_type,
|
||||
side: if message_type == 1 { 2 } else { *data.get(1).unwrap_or(&0) }, // Trade or quote
|
||||
price,
|
||||
quantity: if data.len() >= 40 {
|
||||
f64::from_le_bytes([
|
||||
*data.get(32).unwrap_or(&0), *data.get(33).unwrap_or(&0), *data.get(34).unwrap_or(&0), *data.get(35).unwrap_or(&0),
|
||||
*data.get(36).unwrap_or(&0), *data.get(37).unwrap_or(&0), *data.get(38).unwrap_or(&0), *data.get(39).unwrap_or(&0),
|
||||
])
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
quantity,
|
||||
order_count: 1,
|
||||
flags: 0,
|
||||
};
|
||||
@@ -675,11 +693,25 @@ mod tests {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
// Create mock binary data
|
||||
// Create mock binary data with a valid positive price
|
||||
let mut data = vec![0u8; 40];
|
||||
if let Some(first) = data.first_mut() {
|
||||
*first = 1; // Trade message
|
||||
}
|
||||
// Write a valid price (100.0) at bytes 24..32
|
||||
let price_bytes = 100.0_f64.to_le_bytes();
|
||||
for (i, &b) in price_bytes.iter().enumerate() {
|
||||
if let Some(slot) = data.get_mut(24 + i) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
// Write a valid quantity (10.0) at bytes 32..40
|
||||
let qty_bytes = 10.0_f64.to_le_bytes();
|
||||
for (i, &b) in qty_bytes.iter().enumerate() {
|
||||
if let Some(slot) = data.get_mut(32 + i) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
|
||||
// This would normally be called internally
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
@@ -687,5 +719,204 @@ mod tests {
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(stats.messages_received, 1);
|
||||
assert_eq!(stats.messages_dropped, 0);
|
||||
}
|
||||
|
||||
/// Helper to build a 40-byte binary message with given price and quantity.
|
||||
fn build_binary_tick(price: f64, quantity: f64) -> Vec<u8> {
|
||||
let mut data = vec![0u8; 40];
|
||||
// byte 0 = message_type (Trade = 1)
|
||||
if let Some(first) = data.first_mut() {
|
||||
*first = 1;
|
||||
}
|
||||
// bytes 24..32 = price (f64 LE)
|
||||
let price_bytes = price.to_le_bytes();
|
||||
for (i, &b) in price_bytes.iter().enumerate() {
|
||||
if let Some(slot) = data.get_mut(24 + i) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
// bytes 32..40 = quantity (f64 LE)
|
||||
let qty_bytes = quantity.to_le_bytes();
|
||||
for (i, &b) in qty_bytes.iter().enumerate() {
|
||||
if let Some(slot) = data.get_mut(32 + i) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_zero_price_tick_is_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(0.0, 10.0);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(stats.messages_dropped, 1, "zero-price tick must be dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nan_price_tick_is_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(f64::NAN, 10.0);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(stats.messages_dropped, 1, "NaN-price tick must be dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_negative_price_tick_is_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(-50.0, 10.0);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(
|
||||
stats.messages_dropped, 1,
|
||||
"negative-price tick must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_infinity_price_tick_is_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(f64::INFINITY, 10.0);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(
|
||||
stats.messages_dropped, 1,
|
||||
"infinity-price tick must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nan_quantity_tick_is_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(100.0, f64::NAN);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(
|
||||
stats.messages_dropped, 1,
|
||||
"NaN-quantity tick must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_negative_quantity_tick_is_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(100.0, -5.0);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(
|
||||
stats.messages_dropped, 1,
|
||||
"negative-quantity tick must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_valid_tick_is_not_dropped() {
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
|
||||
let data = build_binary_tick(150.25, 42.0);
|
||||
let result = ingestion.process_binary_message(&data).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let stats = ingestion.get_stats().await;
|
||||
assert_eq!(stats.messages_received, 1);
|
||||
assert_eq!(stats.messages_dropped, 0, "valid tick must NOT be dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clone_for_async_shares_counters() {
|
||||
let config = MarketDataConfig::default();
|
||||
let original = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
let cloned = original.clone_for_async();
|
||||
|
||||
// Increment counters on the clone (simulating what a spawned task does)
|
||||
cloned.message_count.fetch_add(5, Ordering::Relaxed);
|
||||
cloned.drop_count.fetch_add(2, Ordering::Relaxed);
|
||||
cloned
|
||||
.reconnect_attempts
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let ts = HardwareTimestamp::now().as_nanos();
|
||||
cloned.last_heartbeat.store(ts, Ordering::Relaxed);
|
||||
|
||||
// Original must see the same values (shared via Arc)
|
||||
assert_eq!(
|
||||
original.message_count.load(Ordering::Relaxed),
|
||||
5,
|
||||
"message_count must be shared between original and clone"
|
||||
);
|
||||
assert_eq!(
|
||||
original.drop_count.load(Ordering::Relaxed),
|
||||
2,
|
||||
"drop_count must be shared between original and clone"
|
||||
);
|
||||
assert_eq!(
|
||||
original.reconnect_attempts.load(Ordering::Relaxed),
|
||||
1,
|
||||
"reconnect_attempts must be shared between original and clone"
|
||||
);
|
||||
assert_eq!(
|
||||
original.last_heartbeat.load(Ordering::Relaxed),
|
||||
ts,
|
||||
"last_heartbeat must be shared between original and clone"
|
||||
);
|
||||
|
||||
// get_stats on the original must reflect clone's increments
|
||||
let stats = original.get_stats().await;
|
||||
assert_eq!(stats.messages_received, 5);
|
||||
assert_eq!(stats.messages_dropped, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heartbeat_initialized_to_current_time() {
|
||||
let before = HardwareTimestamp::now().as_nanos();
|
||||
let config = MarketDataConfig::default();
|
||||
let ingestion = DatabentoIngestion::new(config, 1024).await.unwrap();
|
||||
let after = HardwareTimestamp::now().as_nanos();
|
||||
|
||||
let heartbeat = ingestion.last_heartbeat.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
heartbeat >= before,
|
||||
"last_heartbeat ({}) must be >= time before construction ({})",
|
||||
heartbeat,
|
||||
before
|
||||
);
|
||||
assert!(
|
||||
heartbeat <= after,
|
||||
"last_heartbeat ({}) must be <= time after construction ({})",
|
||||
heartbeat,
|
||||
after
|
||||
);
|
||||
// Sanity: must not be 0 (the old buggy init value)
|
||||
assert_ne!(
|
||||
heartbeat, 0,
|
||||
"last_heartbeat must not be 0 (would trigger false 'connection dead' alert)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user