Files
foxhunt/data/tests/streaming_edge_cases.rs
jgrusewski 83629f9ca8 feat(deployment): Complete Runpod GPU deployment infrastructure
Implement comprehensive Runpod deployment with S3 volume mount architecture for
FP32 ML model training on Tesla V100 GPUs.

## Infrastructure Components

### Deployment Scripts (scripts/)
- runpod_deploy.sh: Master deployment orchestrator (8-step workflow)
- runpod_upload.sh: S3 upload for binaries and test data
- upload_env_to_runpod.sh: Secure .env credentials upload
- runpod_deploy_test.sh: Prerequisites validation

### Docker Configuration
- Dockerfile.runpod: Multi-stage CUDA 12.1 runtime (~2GB, no binaries)
- entrypoint.sh: Volume verification and training execution
- Architecture: Volume mount (NO S3 downloads in pods)

### S3 Configuration
- Bucket: se3zdnb5o4 (Iceland region: eur-is-1)
- Endpoint: https://s3api-eur-is-1.runpod.io
- Structure: binaries/, test_data/, models/, .env

### OpenTofu Infrastructure (terraform/runpod/)
- main.tf: Pod and volume resources
- variables.tf: Configuration variables
- outputs.tf: Pod connection info
- Security: NO credentials in state (uses volume .env)

## Deployment Assets Uploaded

### Training Binaries (77MB)
- train_tft_parquet (23M) - TFT-225 features
- train_mamba2_parquet (22M) - MAMBA-2 state space
- train_dqn (22M) - Deep Q-Network
- train_ppo (13M) - Proximal Policy Optimization

### Test Data (13.8 MB)
- 9 Parquet files: ES.FUT, NQ.FUT, 6E.FUT, ZN.FUT (180-day datasets)

### Credentials
- .env file (1.5 KB, private access, chmod 600)

## Documentation

### Deployment Guides
- RUNPOD_DEPLOYMENT_READY_SUMMARY.md: Complete deployment status
- RUNPOD_VOLUME_DEPLOYMENT_GUIDE.md: Step-by-step guide (42KB)
- RUNPOD_DEPLOYMENT_QUICK_START.md: Quick reference
- RUNPOD_UPLOAD_GUIDE.md: S3 upload instructions
- RUNPOD_VOLUME_CONFIGURATION_COMPLETE.md: S3 setup report
- RUNPOD_S3_PARQUET_UPLOAD_REPORT.md: Data upload verification

### Architecture Documentation
- RUNPOD_VOLUME_MOUNT_ARCHITECTURE.md: Volume mount design
- RUNPOD_S3_ARCHITECTURE_DIAGRAM.txt: S3 API vs filesystem access
- DOCKERFILE_RUNPOD_FINAL_SUMMARY.md: Docker image specification

### Decision Documentation
- RUNPOD_DEPLOYMENT_CHECKLIST.md: Go/no-go decision matrix (27KB)
- RUNPOD_DEPLOYMENT_DECISION_TREE.md: Decision workflow
- FP32_RUNPOD_DEPLOYMENT_READY.md: FP32 deployment readiness

## QAT Enhancements

### Core QAT Infrastructure
- ml/src/memory_optimization/qat.rs: Enhanced QAT observer (+226 lines)
- ml/src/memory_optimization/auto_batch_size.rs: OOM recovery (+84 lines)
- ml/src/tft/qat_tft.rs: QAT TFT wrapper (+154 lines)
- ml/src/trainers/tft.rs: QAT training integration (+433 lines)
- ml/src/qat_metrics_exporter.rs: NEW - QAT metrics export

### QAT Testing
- ml/tests/qat_integration_tests.rs: NEW - Integration test suite
- ml/tests/qat_gradient_clipping_test.rs: NEW - Gradient clipping tests
- ml/tests/qat_device_consistency_test.rs: Device mismatch tests (+205 lines)
- ml/tests/qat_accuracy_validation_test.rs: Accuracy validation
- ml/tests/qat_tft_integration_test.rs: TFT QAT integration

### QAT Documentation
- ml/docs/QAT_GUIDE.md: Comprehensive QAT guide (+616 lines)
- ml/docs/QAT_GRADIENT_CHECKPOINTING_WORKAROUND.md: NEW - Workaround guide
- QAT_BLOCKERS_ROOT_CAUSE_ANALYSIS.md: P0 blocker analysis (44KB)
- QAT_ACCURACY_VALIDATION_REPORT.md: Accuracy comparison
- QAT_GRADIENT_CLIPPING_VALIDATION_REPORT.md: Clipping validation

### QAT Monitoring
- config/grafana/dashboards/qat-training-metrics.json: NEW - Grafana dashboard

## AWS CLI Configuration

### Credentials Setup
- ~/.aws/credentials: Runpod profile configured
  - Access Key: user_2xxA3XcIFj16yfL3aBon9niiSpr
  - Secret Key: (from RUNPOD_S3_SECRET)
- ~/.aws/config: Iceland region (eur-is-1)

## Production Readiness

### FP32 Models:  READY FOR DEPLOYMENT
- DQN: 15-20s training, ~6MB GPU memory
- PPO: 7-10s training, ~145MB GPU memory
- MAMBA-2: 2-3 min training, ~164MB GPU memory
- TFT-225: 3-5 min training, ~500MB GPU memory
- Total GPU Budget: 815MB (fits on 4GB+ Tesla V100)

### QAT Models: 🔴 BLOCKED
- 24 tests implemented but DO NOT COMPILE (11 errors)
- 3 P0 blockers: device mismatch, gradient checkpointing, OOM recovery
- Timeline: 1-2 weeks to fix (13h P0 fixes + validation)

### Wave D Features:  OPERATIONAL
- 225 features fully integrated
- Feature extraction: 5.10μs/bar (196x faster than target)
- Wave D backtest: Sharpe 2.00, Win Rate 60%, Drawdown 15%
- Database migration 045: Applied cleanly, zero conflicts

## Cost Analysis

### One-Time Setup
- Network Volume: $4/month (50GB SSD)
- Upload costs: FREE (S3 API included)

### Per Training Run (TFT-225)
- GPU: Tesla V100-PCIE-16GB @ $0.29/hr
- Training Time: ~4 hours
- Cost per run: $1.16

### Monthly (20 Training Runs)
- Storage: $4.00/month
- Training: $23.20/month (20 runs × $1.16)
- Total: $27.20/month

## Security

### Credentials Management
-  NO credentials in Docker image
-  NO credentials in Terraform state
-  .env gitignored and not committed
-  .env file private on S3 (HTTP 401 on public access)
-  Docker Hub repository PRIVATE (jgrusewski/foxhunt)

### Access Control
- S3 API: Local client uploads only
- Volume mount: Pod filesystem access only
- Authentication: AWS CLI with Runpod profile required

## Next Steps

1.  COMPLETE: Build Docker image
2.  PENDING: Push to Docker Hub
3.  PENDING: Deploy pod via Runpod console
4.  PENDING: Validate training on Tesla V100

## Performance Targets

- Build time: 5-10 min
- Upload time: ~20 sec (90MB total)
- Pod startup: ~30 sec
- Training time: 3-5 min (TFT-225)
- Total deployment: ~40 min from start to first training run

## Test Status

- FP32 tests: 597/608 passing (98.2%)
- QAT tests: 0/24 passing (compilation errors)
- Overall: 2,062/2,086 passing (98.8% excluding QAT)

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 01:11:43 +02:00

1098 lines
34 KiB
Rust

//! # Comprehensive Streaming Edge Case Tests
//!
//! This module contains extensive edge case testing for streaming market data,
//! covering backpressure, error handling, windowing, joins, and late data handling.
//!
//! ## Test Coverage
//!
//! - Stream backpressure (slow consumer, buffer overflow, flow control)
//! - Stream error handling (network errors, malformed data, reconnection)
//! - Stream windowing (time-based, count-based, session windows)
//! - Stream joins (inner, left, outer joins on event time)
//! - Late data handling (watermarks, allowed lateness, side outputs)
//! - Memory leak detection (long-running streams)
//! - Throughput measurements (events/sec)
#![allow(unused_crate_dependencies)]
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use common::market_data::{MarketDataEvent, QuoteEvent, TradeEvent};
use common::{OrderSide, Price, Quantity, Symbol};
use data::error::DataError;
use rust_decimal::Decimal;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex, RwLock};
use tokio::time::{sleep, timeout, Duration, Instant};
// ============================================================================
// Test Utilities
// ============================================================================
/// Generate a test trade event
fn create_trade(symbol: &str, price: f64, quantity: f64, timestamp: DateTime<Utc>) -> TradeEvent {
TradeEvent {
symbol: Symbol::from(symbol),
price: Price::from_decimal(Decimal::from_f64_retain(price).unwrap()),
quantity: Quantity::new(quantity).unwrap(),
timestamp,
trade_id: format!("trade_{}", timestamp.timestamp_nanos_opt().unwrap_or(0)),
side: OrderSide::Buy,
}
}
/// Generate a test quote event
fn create_quote(symbol: &str, bid: f64, ask: f64, timestamp: DateTime<Utc>) -> QuoteEvent {
QuoteEvent {
symbol: Symbol::from(symbol),
bid_price: Price::from_decimal(Decimal::from_f64_retain(bid).unwrap()),
ask_price: Price::from_decimal(Decimal::from_f64_retain(ask).unwrap()),
bid_quantity: Quantity::new(100.0).unwrap(),
ask_quantity: Quantity::new(100.0).unwrap(),
timestamp,
}
}
/// Backpressure controller for stream flow control
struct BackpressureController {
buffer_size: usize,
high_water_mark: usize,
low_water_mark: usize,
current_size: Arc<AtomicUsize>,
is_overloaded: Arc<AtomicBool>,
messages_dropped: Arc<AtomicU64>,
}
impl BackpressureController {
fn new(buffer_size: usize) -> Self {
Self {
buffer_size,
high_water_mark: (buffer_size as f64 * 0.8) as usize,
low_water_mark: (buffer_size as f64 * 0.2) as usize,
current_size: Arc::new(AtomicUsize::new(0)),
is_overloaded: Arc::new(AtomicBool::new(false)),
messages_dropped: Arc::new(AtomicU64::new(0)),
}
}
fn should_drop_message(&self, queue_size: usize) -> bool {
self.current_size.store(queue_size, Ordering::Relaxed);
if queue_size > self.high_water_mark {
self.is_overloaded.store(true, Ordering::Relaxed);
// Drop 10% of messages when overloaded (deterministic for testing)
if queue_size % 10 == 0 {
self.messages_dropped.fetch_add(1, Ordering::Relaxed);
return true;
}
} else if queue_size < self.low_water_mark {
self.is_overloaded.store(false, Ordering::Relaxed);
}
false
}
fn is_overloaded(&self) -> bool {
self.is_overloaded.load(Ordering::Relaxed)
}
fn get_dropped_count(&self) -> u64 {
self.messages_dropped.load(Ordering::Relaxed)
}
}
/// Time-based window for stream aggregation
struct TimeWindow<T> {
window_duration: ChronoDuration,
events: VecDeque<(DateTime<Utc>, T)>,
}
impl<T: Clone> TimeWindow<T> {
fn new(window_duration: ChronoDuration) -> Self {
Self {
window_duration,
events: VecDeque::new(),
}
}
fn add_event(&mut self, timestamp: DateTime<Utc>, event: T) {
self.events.push_back((timestamp, event));
self.evict_old_events(timestamp);
}
fn evict_old_events(&mut self, current_time: DateTime<Utc>) {
let cutoff = current_time - self.window_duration;
while let Some((ts, _)) = self.events.front() {
if *ts < cutoff {
self.events.pop_front();
} else {
break;
}
}
}
fn get_events(&self) -> Vec<T> {
self.events.iter().map(|(_, e)| e.clone()).collect()
}
fn count(&self) -> usize {
self.events.len()
}
}
/// Count-based window for stream aggregation
struct CountWindow<T> {
max_count: usize,
events: VecDeque<T>,
}
impl<T: Clone> CountWindow<T> {
fn new(max_count: usize) -> Self {
Self {
max_count,
events: VecDeque::with_capacity(max_count),
}
}
fn add_event(&mut self, event: T) {
if self.events.len() >= self.max_count {
self.events.pop_front();
}
self.events.push_back(event);
}
fn get_events(&self) -> Vec<T> {
self.events.iter().cloned().collect()
}
fn is_full(&self) -> bool {
self.events.len() >= self.max_count
}
}
/// Stream join coordinator for correlating events across streams
struct StreamJoinCoordinator {
trade_buffer: HashMap<Symbol, VecDeque<TradeEvent>>,
quote_buffer: HashMap<Symbol, VecDeque<QuoteEvent>>,
max_buffer_per_symbol: usize,
time_tolerance: ChronoDuration,
}
impl StreamJoinCoordinator {
fn new(max_buffer_per_symbol: usize, time_tolerance: ChronoDuration) -> Self {
Self {
trade_buffer: HashMap::new(),
quote_buffer: HashMap::new(),
max_buffer_per_symbol,
time_tolerance,
}
}
fn add_trade(&mut self, trade: TradeEvent) {
let buffer = self
.trade_buffer
.entry(trade.symbol.clone())
.or_insert_with(|| VecDeque::with_capacity(self.max_buffer_per_symbol));
if buffer.len() >= self.max_buffer_per_symbol {
buffer.pop_front();
}
buffer.push_back(trade);
}
fn add_quote(&mut self, quote: QuoteEvent) {
let buffer = self
.quote_buffer
.entry(quote.symbol.clone())
.or_insert_with(|| VecDeque::with_capacity(self.max_buffer_per_symbol));
if buffer.len() >= self.max_buffer_per_symbol {
buffer.pop_front();
}
buffer.push_back(quote);
}
fn inner_join(&self, symbol: &Symbol) -> Vec<(TradeEvent, QuoteEvent)> {
let trades = self.trade_buffer.get(symbol);
let quotes = self.quote_buffer.get(symbol);
if trades.is_none() || quotes.is_none() {
return Vec::new();
}
let trades = trades.unwrap();
let quotes = quotes.unwrap();
let mut results = Vec::new();
for trade in trades {
for quote in quotes {
let time_diff = (trade.timestamp - quote.timestamp).abs();
if time_diff <= self.time_tolerance {
results.push((trade.clone(), quote.clone()));
break; // Take first matching quote
}
}
}
results
}
fn left_join(&self, symbol: &Symbol) -> Vec<(TradeEvent, Option<QuoteEvent>)> {
let trades = self.trade_buffer.get(symbol);
if trades.is_none() {
return Vec::new();
}
let trades = trades.unwrap();
let quotes = self.quote_buffer.get(symbol);
let mut results = Vec::new();
for trade in trades {
if let Some(quotes) = quotes {
let mut matched = false;
for quote in quotes {
let time_diff = (trade.timestamp - quote.timestamp).abs();
if time_diff <= self.time_tolerance {
results.push((trade.clone(), Some(quote.clone())));
matched = true;
break;
}
}
if !matched {
results.push((trade.clone(), None));
}
} else {
results.push((trade.clone(), None));
}
}
results
}
}
/// Watermark manager for handling late data
struct WatermarkManager {
current_watermark: Arc<RwLock<DateTime<Utc>>>,
allowed_lateness: ChronoDuration,
late_events: Arc<Mutex<Vec<MarketDataEvent>>>,
}
impl WatermarkManager {
fn new(allowed_lateness: ChronoDuration) -> Self {
Self {
current_watermark: Arc::new(RwLock::new(Utc::now())),
allowed_lateness,
late_events: Arc::new(Mutex::new(Vec::new())),
}
}
async fn update_watermark(&self, timestamp: DateTime<Utc>) {
let mut watermark = self.current_watermark.write().await;
if timestamp > *watermark {
*watermark = timestamp;
}
}
async fn is_late(&self, timestamp: DateTime<Utc>) -> bool {
let watermark = self.current_watermark.read().await;
let cutoff = *watermark - self.allowed_lateness;
timestamp < cutoff
}
async fn process_event(&self, event: MarketDataEvent) -> bool {
let timestamp = match event.timestamp() {
Some(ts) => ts,
None => return false, // Invalid event without timestamp
};
if self.is_late(timestamp).await {
let mut late = self.late_events.lock().await;
late.push(event);
return false; // Event is late, moved to side output
}
self.update_watermark(timestamp).await;
true // Event is on time
}
async fn get_late_events(&self) -> Vec<MarketDataEvent> {
let late = self.late_events.lock().await;
late.clone()
}
}
// ============================================================================
// Backpressure Tests
// ============================================================================
#[tokio::test]
async fn test_backpressure_slow_consumer() {
// Consumer processes events slower than producer
let (tx, mut rx) = mpsc::channel::<TradeEvent>(100);
let controller = Arc::new(BackpressureController::new(100));
// Producer: 1000 events/sec
let producer_handle = tokio::spawn(async move {
for i in 0..500 {
let trade = create_trade("AAPL", 150.0 + i as f64, 100.0, Utc::now());
if tx.send(trade).await.is_err() {
break;
}
sleep(Duration::from_micros(1000)).await; // 1ms = 1000/sec
}
});
// Consumer: 100 events/sec (10x slower)
let consumer_controller = controller.clone();
let consumer_handle = tokio::spawn(async move {
let mut processed = 0;
while let Some(_event) = rx.recv().await {
processed += 1;
sleep(Duration::from_micros(10000)).await; // 10ms = 100/sec
if processed >= 100 {
break; // Process 100 events
}
}
processed
});
// Wait for both tasks
let _ = producer_handle.await;
let processed = consumer_handle.await.unwrap();
// Consumer should process exactly 100 events
assert_eq!(processed, 100);
println!(
"✓ Backpressure test: Processed {} events with slow consumer",
processed
);
}
#[tokio::test]
async fn test_backpressure_buffer_overflow() {
// Test buffer overflow and message dropping
let controller = BackpressureController::new(1000);
let mut dropped_count = 0;
// Simulate 2000 messages (exceeds buffer)
for i in 0..2000 {
if controller.should_drop_message(i) {
dropped_count += 1;
}
}
// Should have dropped some messages when queue size exceeded high water mark
assert!(dropped_count > 0, "Expected some messages to be dropped");
assert!(
controller.is_overloaded(),
"Controller should be overloaded"
);
println!("✓ Buffer overflow test: Dropped {} messages", dropped_count);
}
#[tokio::test]
async fn test_backpressure_flow_control() {
// Test flow control with dynamic rate adjustment
let controller = BackpressureController::new(100);
// Phase 1: Low load (should not drop)
for i in 0..20 {
assert!(!controller.should_drop_message(i));
}
assert!(!controller.is_overloaded());
// Phase 2: High load (should start dropping)
for i in 80..95 {
let _ = controller.should_drop_message(i);
}
assert!(controller.is_overloaded());
// Phase 3: Load decreases (should stop dropping)
for i in (15..25).rev() {
let _ = controller.should_drop_message(i);
}
assert!(!controller.is_overloaded());
println!("✓ Flow control test: Dynamic rate adjustment working");
}
#[tokio::test]
async fn test_backpressure_burst_traffic() {
// Test handling of burst traffic (1000+ events/ms)
let (tx, mut rx) = mpsc::channel::<TradeEvent>(10000);
let start = Instant::now();
// Producer: Send 5000 events as fast as possible
let producer_handle = tokio::spawn(async move {
for i in 0..5000 {
let trade = create_trade("SPY", 400.0, 100.0, Utc::now());
if tx.send(trade).await.is_err() {
break;
}
}
start.elapsed()
});
// Consumer: Process all events
let consumer_handle = tokio::spawn(async move {
let mut count = 0;
while let Some(_event) = rx.recv().await {
count += 1;
if count >= 5000 {
break;
}
}
count
});
let producer_time = producer_handle.await.unwrap();
let count = consumer_handle.await.unwrap();
assert_eq!(count, 5000);
let events_per_ms = count as f64 / producer_time.as_millis() as f64;
println!("✓ Burst traffic test: {} events/ms", events_per_ms);
}
// ============================================================================
// Error Handling Tests
// ============================================================================
#[tokio::test]
async fn test_stream_network_error_recovery() {
// Simulate network disconnection and reconnection
let (tx, mut rx) = mpsc::channel::<Result<TradeEvent, DataError>>(100);
let reconnect_count = Arc::new(AtomicUsize::new(0));
let producer_reconnect = reconnect_count.clone();
let producer_handle = tokio::spawn(async move {
// Send 10 events successfully
for i in 0..10 {
let trade = create_trade("AAPL", 150.0, 100.0, Utc::now());
let _ = tx.send(Ok(trade)).await;
}
// Simulate network error
let _ = tx
.send(Err(DataError::Connection("Network timeout".to_string())))
.await;
producer_reconnect.fetch_add(1, Ordering::Relaxed);
// Reconnect and send more events
sleep(Duration::from_millis(100)).await;
for i in 0..10 {
let trade = create_trade("AAPL", 151.0, 100.0, Utc::now());
let _ = tx.send(Ok(trade)).await;
}
});
// Consumer with error recovery
let mut success_count = 0;
let mut error_count = 0;
while let Some(result) = rx.recv().await {
match result {
Ok(_) => success_count += 1,
Err(_) => {
error_count += 1;
// Simulate reconnection logic
sleep(Duration::from_millis(50)).await;
},
}
if success_count >= 20 {
break;
}
}
let _ = producer_handle.await;
assert_eq!(success_count, 20);
assert_eq!(error_count, 1);
assert_eq!(reconnect_count.load(Ordering::Relaxed), 1);
println!(
"✓ Network error recovery: {} reconnections, {} events processed",
error_count, success_count
);
}
#[tokio::test]
async fn test_stream_malformed_data_handling() {
// Test handling of malformed/invalid data
let (tx, mut rx) = mpsc::channel::<Result<TradeEvent, DataError>>(100);
let producer_handle = tokio::spawn(async move {
// Send valid events
for _ in 0..5 {
let trade = create_trade("AAPL", 150.0, 100.0, Utc::now());
let _ = tx.send(Ok(trade)).await;
}
// Send malformed data error
let _ = tx
.send(Err(DataError::Parse {
message: "Invalid price format".to_string(),
}))
.await;
// Continue with valid events
for _ in 0..5 {
let trade = create_trade("AAPL", 151.0, 100.0, Utc::now());
let _ = tx.send(Ok(trade)).await;
}
});
let mut valid_count = 0;
let mut invalid_count = 0;
while let Some(result) = rx.recv().await {
match result {
Ok(_) => valid_count += 1,
Err(DataError::Parse { .. }) => {
invalid_count += 1;
// Skip malformed event and continue
},
Err(_) => {},
}
if valid_count >= 10 {
break;
}
}
let _ = producer_handle.await;
assert_eq!(valid_count, 10);
assert_eq!(invalid_count, 1);
println!(
"✓ Malformed data handling: {} valid, {} invalid",
valid_count, invalid_count
);
}
#[tokio::test]
async fn test_stream_very_large_messages() {
// Test handling of messages >1MB
let (tx, mut rx) = mpsc::channel::<Vec<u8>>(10);
let producer_handle = tokio::spawn(async move {
// Send a 2MB message
let large_message = vec![0u8; 2 * 1024 * 1024];
let _ = tx.send(large_message).await;
});
let result = timeout(Duration::from_secs(5), rx.recv()).await;
assert!(
result.is_ok(),
"Should receive large message within timeout"
);
if let Ok(Some(msg)) = result {
assert_eq!(msg.len(), 2 * 1024 * 1024);
println!(
"✓ Large message test: Received {}MB message",
msg.len() / (1024 * 1024)
);
}
let _ = producer_handle.await;
}
// ============================================================================
// Windowing Tests
// ============================================================================
#[tokio::test]
async fn test_time_based_windowing() {
// Test time-based window (5-second tumbling window)
let mut window = TimeWindow::<TradeEvent>::new(ChronoDuration::seconds(5));
let base_time = Utc::now();
// Add events within 5-second window
for i in 0..10 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 500);
let trade = create_trade("AAPL", 150.0, 100.0, timestamp);
window.add_event(timestamp, trade);
}
assert_eq!(window.count(), 10, "Window should contain all events");
// Add event 6 seconds later (outside window)
let late_timestamp = base_time + ChronoDuration::seconds(6);
let late_trade = create_trade("AAPL", 151.0, 100.0, late_timestamp);
window.add_event(late_timestamp, late_trade);
// Old events should be evicted
assert!(window.count() <= 3, "Old events should be evicted");
println!(
"✓ Time-based window: {} events remaining after eviction",
window.count()
);
}
#[tokio::test]
async fn test_count_based_windowing() {
// Test count-based window (sliding window of 100 events)
let mut window = CountWindow::<TradeEvent>::new(100);
// Add 150 events
for i in 0..150 {
let trade = create_trade("AAPL", 150.0 + i as f64, 100.0, Utc::now());
window.add_event(trade);
}
// Window should contain only last 100 events
assert_eq!(window.get_events().len(), 100);
assert!(window.is_full());
println!(
"✓ Count-based window: Maintained {} events max",
window.get_events().len()
);
}
#[tokio::test]
async fn test_session_windowing() {
// Test session window (gap-based windowing with 1-second inactivity gap)
let session_gap = ChronoDuration::seconds(1);
let mut sessions: Vec<Vec<TradeEvent>> = Vec::new();
let mut current_session: Vec<TradeEvent> = Vec::new();
let mut last_timestamp: Option<DateTime<Utc>> = None;
// Generate events with gaps
let base_time = Utc::now();
let event_times = vec![
0, // Session 1
100, 200, 2000, // Session 2 (1.8s gap)
2100, 2200, 4000, // Session 3 (1.8s gap)
4100,
];
for (i, offset_ms) in event_times.iter().enumerate() {
let timestamp = base_time + ChronoDuration::milliseconds(*offset_ms);
let trade = create_trade("AAPL", 150.0 + i as f64, 100.0, timestamp);
if let Some(last_ts) = last_timestamp {
if timestamp - last_ts > session_gap {
// Start new session
sessions.push(current_session.clone());
current_session.clear();
}
}
current_session.push(trade);
last_timestamp = Some(timestamp);
}
// Add final session
if !current_session.is_empty() {
sessions.push(current_session);
}
assert_eq!(sessions.len(), 3, "Should have 3 sessions");
assert_eq!(sessions[0].len(), 3, "Session 1 should have 3 events");
assert_eq!(sessions[1].len(), 3, "Session 2 should have 3 events");
assert_eq!(sessions[2].len(), 2, "Session 3 should have 2 events");
println!("✓ Session window: {} sessions detected", sessions.len());
}
// ============================================================================
// Stream Join Tests
// ============================================================================
#[tokio::test]
async fn test_stream_inner_join() {
// Test inner join between trade and quote streams
let mut coordinator = StreamJoinCoordinator::new(100, ChronoDuration::milliseconds(100));
let base_time = Utc::now();
// Add trades
for i in 0..10 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 10);
let trade = create_trade("AAPL", 150.0 + i as f64, 100.0, timestamp);
coordinator.add_trade(trade);
}
// Add matching quotes (within 100ms tolerance)
for i in 0..10 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 10 + 5);
let quote = create_quote("AAPL", 149.0 + i as f64, 151.0 + i as f64, timestamp);
coordinator.add_quote(quote);
}
let joined = coordinator.inner_join(&Symbol::from("AAPL"));
assert_eq!(joined.len(), 10, "All trades should match with quotes");
println!("✓ Inner join: {} matched pairs", joined.len());
}
#[tokio::test]
async fn test_stream_left_join() {
// Test left join (all trades, some without matching quotes)
let mut coordinator = StreamJoinCoordinator::new(100, ChronoDuration::milliseconds(50));
let base_time = Utc::now();
// Add 10 trades
for i in 0..10 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 10);
let trade = create_trade("AAPL", 150.0 + i as f64, 100.0, timestamp);
coordinator.add_trade(trade);
}
// Add only 5 matching quotes
for i in 0..5 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 10 + 5);
let quote = create_quote("AAPL", 149.0 + i as f64, 151.0 + i as f64, timestamp);
coordinator.add_quote(quote);
}
let joined = coordinator.left_join(&Symbol::from("AAPL"));
assert_eq!(joined.len(), 10, "All trades should be in result");
let matched_count = joined.iter().filter(|(_, q)| q.is_some()).count();
assert_eq!(
matched_count, 5,
"Only 5 trades should have matching quotes"
);
println!(
"✓ Left join: {} total, {} matched",
joined.len(),
matched_count
);
}
#[tokio::test]
async fn test_stream_join_different_symbols() {
// Test join with multiple symbols
let mut coordinator = StreamJoinCoordinator::new(100, ChronoDuration::milliseconds(100));
let base_time = Utc::now();
// Add trades for AAPL and SPY
for symbol in &["AAPL", "SPY"] {
for i in 0..5 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 10);
let trade = create_trade(symbol, 150.0 + i as f64, 100.0, timestamp);
coordinator.add_trade(trade);
}
}
// Add quotes only for AAPL
for i in 0..5 {
let timestamp = base_time + ChronoDuration::milliseconds(i * 10 + 5);
let quote = create_quote("AAPL", 149.0 + i as f64, 151.0 + i as f64, timestamp);
coordinator.add_quote(quote);
}
let aapl_joined = coordinator.inner_join(&Symbol::from("AAPL"));
let spy_joined = coordinator.inner_join(&Symbol::from("SPY"));
assert_eq!(aapl_joined.len(), 5, "AAPL trades should match");
assert_eq!(spy_joined.len(), 0, "SPY trades should not match");
println!(
"✓ Multi-symbol join: AAPL={}, SPY={}",
aapl_joined.len(),
spy_joined.len()
);
}
// ============================================================================
// Late Data Handling Tests
// ============================================================================
#[tokio::test]
async fn test_watermark_late_data_detection() {
// Test watermark-based late data detection
let manager = WatermarkManager::new(ChronoDuration::seconds(5));
let base_time = Utc::now();
// Process events in order
for i in 0..10 {
let timestamp = base_time + ChronoDuration::seconds(i);
let trade = create_trade("AAPL", 150.0, 100.0, timestamp);
let event = MarketDataEvent::Trade(trade);
let is_on_time = manager.process_event(event).await;
assert!(is_on_time, "Sequential events should be on time");
}
// Send a late event (before watermark - allowed lateness)
let late_timestamp = base_time + ChronoDuration::seconds(3);
let late_trade = create_trade("AAPL", 149.0, 100.0, late_timestamp);
let late_event = MarketDataEvent::Trade(late_trade);
let is_on_time = manager.process_event(late_event).await;
assert!(!is_on_time, "Late event should be detected");
let late_events = manager.get_late_events().await;
assert_eq!(late_events.len(), 1, "Should have one late event");
println!(
"✓ Watermark test: Detected {} late events",
late_events.len()
);
}
#[tokio::test]
async fn test_allowed_lateness_handling() {
// Test allowed lateness window
let manager = WatermarkManager::new(ChronoDuration::seconds(5));
let base_time = Utc::now();
// Advance watermark
manager
.update_watermark(base_time + ChronoDuration::seconds(10))
.await;
// Event within allowed lateness (6 seconds old, allowed 5)
let event1_time = base_time + ChronoDuration::seconds(6);
let trade1 = create_trade("AAPL", 150.0, 100.0, event1_time);
let is_late1 = manager.is_late(event1_time).await;
assert!(
!is_late1,
"Event within allowed lateness should not be late"
);
// Event outside allowed lateness (4 seconds old, allowed 5)
let event2_time = base_time + ChronoDuration::seconds(4);
let is_late2 = manager.is_late(event2_time).await;
assert!(is_late2, "Event outside allowed lateness should be late");
println!(
"✓ Allowed lateness: Within window={}, Outside window={}",
!is_late1, is_late2
);
}
#[tokio::test]
async fn test_side_output_for_late_events() {
// Test side output stream for late events
let manager = Arc::new(WatermarkManager::new(ChronoDuration::seconds(2)));
let base_time = Utc::now();
// Process 20 events with some late ones
let mut on_time_count = 0;
for i in 0..20 {
let timestamp = if i % 5 == 0 {
// Every 5th event is late
base_time + ChronoDuration::seconds(i / 2)
} else {
base_time + ChronoDuration::seconds(i)
};
let trade = create_trade("AAPL", 150.0, 100.0, timestamp);
let event = MarketDataEvent::Trade(trade);
if manager.process_event(event).await {
on_time_count += 1;
}
}
let late_events = manager.get_late_events().await;
assert_eq!(
on_time_count + late_events.len(),
20,
"All events should be accounted for"
);
println!(
"✓ Side output: {} on-time, {} late",
on_time_count,
late_events.len()
);
}
// ============================================================================
// Performance & Memory Tests
// ============================================================================
#[tokio::test]
async fn test_throughput_measurement() {
// Measure streaming throughput
let (tx, mut rx) = mpsc::channel::<TradeEvent>(10000);
let start = Instant::now();
let event_count = 10000;
// Producer
let producer_handle = tokio::spawn(async move {
for i in 0..event_count {
let trade = create_trade("AAPL", 150.0, 100.0, Utc::now());
if tx.send(trade).await.is_err() {
break;
}
}
});
// Consumer
let consumer_handle = tokio::spawn(async move {
let mut count = 0;
while let Some(_event) = rx.recv().await {
count += 1;
if count >= event_count {
break;
}
}
count
});
let _ = producer_handle.await;
let processed = consumer_handle.await.unwrap();
let elapsed = start.elapsed();
let events_per_sec = processed as f64 / elapsed.as_secs_f64();
assert_eq!(processed, event_count);
assert!(events_per_sec > 1000.0, "Should process >1000 events/sec");
println!(
"✓ Throughput: {:.0} events/sec ({} events in {:?})",
events_per_sec, processed, elapsed
);
}
#[tokio::test]
#[ignore = "Long-running test"]
async fn test_memory_leak_detection() {
// Test for memory leaks in long-running stream
let (tx, mut rx) = mpsc::channel::<TradeEvent>(1000);
// Producer: Send events for 10 seconds
let producer_handle = tokio::spawn(async move {
let start = Instant::now();
let mut count = 0;
while start.elapsed() < Duration::from_secs(10) {
let trade = create_trade("AAPL", 150.0, 100.0, Utc::now());
if tx.send(trade).await.is_ok() {
count += 1;
}
sleep(Duration::from_micros(100)).await;
}
count
});
// Consumer: Process events and track memory
let consumer_handle = tokio::spawn(async move {
let mut count = 0;
let mut max_buffer = 0;
while let Some(_event) = rx.recv().await {
count += 1;
// Track buffer size (approximation)
let buffer_size = rx.len();
if buffer_size > max_buffer {
max_buffer = buffer_size;
}
sleep(Duration::from_micros(150)).await;
}
(count, max_buffer)
});
let sent = producer_handle.await.unwrap();
let (received, max_buffer) = consumer_handle.await.unwrap();
// Verify reasonable buffer size (no runaway growth)
assert!(max_buffer < 500, "Buffer should not grow unbounded");
println!(
"✓ Memory leak test: {} events, max buffer size={}",
received, max_buffer
);
}
#[tokio::test]
async fn test_stream_cleanup_on_cancellation() {
// Test proper cleanup when stream is cancelled
let (tx, mut rx) = mpsc::channel::<TradeEvent>(100);
let cleanup_flag = Arc::new(AtomicBool::new(false));
let producer_cleanup = cleanup_flag.clone();
let producer_handle = tokio::spawn(async move {
for i in 0..1000 {
let trade = create_trade("AAPL", 150.0, 100.0, Utc::now());
if tx.send(trade).await.is_err() {
producer_cleanup.store(true, Ordering::Relaxed);
break;
}
sleep(Duration::from_micros(100)).await;
}
});
// Consumer: Cancel after 50 events
let mut count = 0;
while let Some(_event) = rx.recv().await {
count += 1;
if count >= 50 {
drop(rx); // Cancel stream
break;
}
}
sleep(Duration::from_millis(100)).await;
let _ = producer_handle.await;
assert!(
cleanup_flag.load(Ordering::Relaxed),
"Producer should detect cancellation"
);
println!("✓ Cleanup test: Stream cancelled after {} events", count);
}
#[tokio::test]
async fn test_out_of_order_event_handling() {
// Test handling of out-of-order events
let mut events = Vec::new();
let base_time = Utc::now();
// Generate out-of-order timestamps
let timestamps = vec![0, 5, 2, 8, 3, 10, 1, 7, 4, 9];
for (i, &offset) in timestamps.iter().enumerate() {
let timestamp = base_time + ChronoDuration::seconds(offset);
let trade = create_trade("AAPL", 150.0 + i as f64, 100.0, timestamp);
events.push(trade);
}
// Sort by event time
events.sort_by_key(|e| e.timestamp);
// Verify sorted order
for i in 1..events.len() {
assert!(
events[i].timestamp >= events[i - 1].timestamp,
"Events should be sorted by timestamp"
);
}
println!("✓ Out-of-order handling: Sorted {} events", events.len());
}
#[tokio::test]
async fn test_duplicate_event_deduplication() {
// Test deduplication of duplicate events
let mut seen_ids = std::collections::HashSet::new();
let mut unique_count = 0;
let mut duplicate_count = 0;
// Generate events with some duplicates
for i in 0..20 {
let trade_id = if i % 3 == 0 {
format!("trade_{}", i / 3) // Duplicate every 3rd event
} else {
format!("trade_{}", i)
};
if seen_ids.insert(trade_id) {
unique_count += 1;
} else {
duplicate_count += 1;
}
}
assert_eq!(unique_count + duplicate_count, 20);
assert!(duplicate_count > 0, "Should have detected duplicates");
println!(
"✓ Deduplication: {} unique, {} duplicates",
unique_count, duplicate_count
);
}