Patterns applied: - Pattern 2: Float comparison (2x: utils.rs, var_edge_cases_tests.rs) - Pattern 7: Date/time construction (2x: production_streaming.rs, streaming.rs) - Pattern 1: Duration/time ops (2x: rate limiter, semaphore) - Pattern 4: Optional field access (1x: position_tracker.rs) Changes: - data/src/utils.rs: Float sort with NaN handling - data/src/providers/benzinga/production_streaming.rs: Rate limiter + semaphore + date/time - data/src/providers/benzinga/streaming.rs: Date/time construction - risk/src/position_tracker.rs: Emergency fallback counter - risk/tests/var_edge_cases_tests.rs: Test helper float sort Test impact: 0 failures (182/182 passing) Compilation: Clean (0 errors, 0 warnings) Time: 25 min (44% under budget)
1095 lines
35 KiB
Rust
1095 lines
35 KiB
Rust
//! Advanced Mock Types for Data Acquisition Service Testing
|
|
//!
|
|
//! This module provides complex mock implementations for testing:
|
|
//! - MockDatabentoDownloader - State machine for download progression
|
|
//! - MockMinIOUploader - Track upload operations with failure injection
|
|
//! - MockDataValidator - Configurable validation results
|
|
//! - RetryTracker - Track retry attempts with exponential backoff
|
|
//! - ProgressCallback - Monitor progress updates
|
|
//!
|
|
//! Based on WAVE_1_AGENT_1_DATA_ACQUISITION_ANALYSIS.md Section 5.2
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use tokio::time::sleep;
|
|
|
|
// =============================================================================
|
|
// SECTION 1: MockDatabentoDownloader - State Machine for Download Progression
|
|
// =============================================================================
|
|
|
|
/// Download state for state machine progression
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DownloadState {
|
|
Idle,
|
|
Connecting,
|
|
Downloading,
|
|
Verifying,
|
|
Completed,
|
|
Failed,
|
|
}
|
|
|
|
/// Configuration for download failure injection
|
|
#[derive(Debug, Clone)]
|
|
pub struct DownloadFailureConfig {
|
|
/// Fail on attempt number (None = no failure)
|
|
pub fail_on_attempt: Option<u32>,
|
|
/// Error type to inject
|
|
pub error_type: DownloadErrorType,
|
|
/// Simulate latency in milliseconds
|
|
pub latency_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DownloadErrorType {
|
|
NetworkTimeout,
|
|
ConnectionReset,
|
|
RateLimited,
|
|
AuthenticationFailed,
|
|
InvalidResponse,
|
|
DataCorruption,
|
|
}
|
|
|
|
impl Default for DownloadFailureConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
fail_on_attempt: None,
|
|
error_type: DownloadErrorType::NetworkTimeout,
|
|
latency_ms: 50,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mock Databento downloader with state machine progression
|
|
pub struct MockDatabentoDownloader {
|
|
state: Arc<Mutex<DownloadState>>,
|
|
config: Arc<DownloadFailureConfig>,
|
|
attempt_count: Arc<Mutex<u32>>,
|
|
bytes_downloaded: Arc<Mutex<u64>>,
|
|
total_bytes: u64,
|
|
}
|
|
|
|
impl MockDatabentoDownloader {
|
|
pub fn new(total_bytes: u64) -> Self {
|
|
Self {
|
|
state: Arc::new(Mutex::new(DownloadState::Idle)),
|
|
config: Arc::new(DownloadFailureConfig::default()),
|
|
attempt_count: Arc::new(Mutex::new(0)),
|
|
bytes_downloaded: Arc::new(Mutex::new(0)),
|
|
total_bytes,
|
|
}
|
|
}
|
|
|
|
pub fn with_failure_config(mut self, config: DownloadFailureConfig) -> Self {
|
|
self.config = Arc::new(config);
|
|
self
|
|
}
|
|
|
|
/// Start download with state machine progression
|
|
pub async fn download(&self, _url: &str, output_path: &Path) -> Result<u64, DownloadError> {
|
|
// Increment attempt count
|
|
let attempt = {
|
|
let mut count = self.attempt_count.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
*count += 1;
|
|
*count
|
|
};
|
|
|
|
// Check if we should inject failure
|
|
if let Some(fail_attempt) = self.config.fail_on_attempt {
|
|
if attempt == fail_attempt {
|
|
return Err(self.inject_error());
|
|
}
|
|
}
|
|
|
|
// State: Idle → Connecting
|
|
self.set_state(DownloadState::Connecting);
|
|
sleep(Duration::from_millis(self.config.latency_ms / 4)).await;
|
|
|
|
// State: Connecting → Downloading
|
|
self.set_state(DownloadState::Downloading);
|
|
|
|
// Simulate chunked download
|
|
let chunk_size = 1024 * 256; // 256 KB chunks
|
|
let mut downloaded = 0u64;
|
|
|
|
while downloaded < self.total_bytes {
|
|
sleep(Duration::from_millis(self.config.latency_ms / 4)).await;
|
|
|
|
let chunk = std::cmp::min(chunk_size, self.total_bytes - downloaded);
|
|
downloaded += chunk;
|
|
|
|
// Update progress
|
|
*self.bytes_downloaded.lock().expect("INVARIANT: Lock should not be poisoned") = downloaded;
|
|
}
|
|
|
|
// State: Downloading → Verifying
|
|
self.set_state(DownloadState::Verifying);
|
|
sleep(Duration::from_millis(self.config.latency_ms / 4)).await;
|
|
|
|
// Write mock file
|
|
if let Some(parent) = output_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await
|
|
.map_err(|e| DownloadError::IoError(e.to_string()))?;
|
|
}
|
|
tokio::fs::write(output_path, vec![0u8; self.total_bytes as usize])
|
|
.await
|
|
.map_err(|e| DownloadError::IoError(e.to_string()))?;
|
|
|
|
// State: Verifying → Completed
|
|
self.set_state(DownloadState::Completed);
|
|
|
|
Ok(self.total_bytes)
|
|
}
|
|
|
|
pub fn get_state(&self) -> DownloadState {
|
|
*self.state.lock().expect("INVARIANT: Lock should not be poisoned")
|
|
}
|
|
|
|
pub fn get_attempt_count(&self) -> u32 {
|
|
*self.attempt_count.lock().expect("INVARIANT: Lock should not be poisoned")
|
|
}
|
|
|
|
pub fn get_progress(&self) -> (u64, u64) {
|
|
let downloaded = *self.bytes_downloaded.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
(downloaded, self.total_bytes)
|
|
}
|
|
|
|
fn set_state(&self, new_state: DownloadState) {
|
|
*self.state.lock().expect("INVARIANT: Lock should not be poisoned") = new_state;
|
|
}
|
|
|
|
fn inject_error(&self) -> DownloadError {
|
|
self.set_state(DownloadState::Failed);
|
|
|
|
match self.config.error_type {
|
|
DownloadErrorType::NetworkTimeout => {
|
|
DownloadError::Timeout("Connection timed out after 30s".to_string())
|
|
}
|
|
DownloadErrorType::ConnectionReset => {
|
|
DownloadError::NetworkError("Connection reset by peer".to_string())
|
|
}
|
|
DownloadErrorType::RateLimited => {
|
|
DownloadError::RateLimited {
|
|
retry_after_seconds: 60,
|
|
message: "Rate limit exceeded".to_string(),
|
|
}
|
|
}
|
|
DownloadErrorType::AuthenticationFailed => {
|
|
DownloadError::AuthenticationFailed("Invalid API key".to_string())
|
|
}
|
|
DownloadErrorType::InvalidResponse => {
|
|
DownloadError::InvalidResponse("Malformed JSON response".to_string())
|
|
}
|
|
DownloadErrorType::DataCorruption => {
|
|
DownloadError::DataCorruption {
|
|
expected_checksum: "abc123".to_string(),
|
|
actual_checksum: "def456".to_string(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Download error types for testing
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum DownloadError {
|
|
NetworkError(String),
|
|
Timeout(String),
|
|
RateLimited {
|
|
retry_after_seconds: u64,
|
|
message: String,
|
|
},
|
|
AuthenticationFailed(String),
|
|
InvalidResponse(String),
|
|
DataCorruption {
|
|
expected_checksum: String,
|
|
actual_checksum: String,
|
|
},
|
|
IoError(String),
|
|
}
|
|
|
|
impl std::fmt::Display for DownloadError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
DownloadError::NetworkError(msg) => write!(f, "Network error: {}", msg),
|
|
DownloadError::Timeout(msg) => write!(f, "Timeout: {}", msg),
|
|
DownloadError::RateLimited { retry_after_seconds, message } => {
|
|
write!(f, "Rate limited (retry after {}s): {}", retry_after_seconds, message)
|
|
}
|
|
DownloadError::AuthenticationFailed(msg) => write!(f, "Authentication failed: {}", msg),
|
|
DownloadError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
|
|
DownloadError::DataCorruption { expected_checksum, actual_checksum } => {
|
|
write!(f, "Data corruption detected (expected: {}, actual: {})",
|
|
expected_checksum, actual_checksum)
|
|
}
|
|
DownloadError::IoError(msg) => write!(f, "I/O error: {}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DownloadError {}
|
|
|
|
// =============================================================================
|
|
// SECTION 2: MockMinIOUploader - Track Upload Operations
|
|
// =============================================================================
|
|
|
|
/// Upload operation record for tracking
|
|
#[derive(Debug, Clone)]
|
|
pub struct UploadOperation {
|
|
pub object_key: String,
|
|
pub file_path: PathBuf,
|
|
pub size_bytes: u64,
|
|
pub content_type: Option<String>,
|
|
pub tags: HashMap<String, String>,
|
|
pub checksum: String,
|
|
pub upload_duration_ms: u64,
|
|
pub retry_count: u32,
|
|
}
|
|
|
|
/// Configuration for upload failure injection
|
|
#[derive(Debug, Clone)]
|
|
pub struct UploadFailureConfig {
|
|
/// Number of initial failures before success
|
|
pub initial_failures: u32,
|
|
/// Latency per chunk in milliseconds
|
|
pub chunk_latency_ms: u64,
|
|
}
|
|
|
|
impl Default for UploadFailureConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
initial_failures: 0,
|
|
chunk_latency_ms: 10,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mock MinIO uploader with operation tracking
|
|
#[derive(Clone)]
|
|
pub struct MockMinIOUploader {
|
|
operations: Arc<Mutex<Vec<UploadOperation>>>,
|
|
config: Arc<UploadFailureConfig>,
|
|
failure_count: Arc<Mutex<u32>>,
|
|
}
|
|
|
|
impl MockMinIOUploader {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
operations: Arc::new(Mutex::new(Vec::new())),
|
|
config: Arc::new(UploadFailureConfig::default()),
|
|
failure_count: Arc::new(Mutex::new(0)),
|
|
}
|
|
}
|
|
|
|
pub fn with_failure_config(mut self, config: UploadFailureConfig) -> Self {
|
|
self.config = Arc::new(config);
|
|
self
|
|
}
|
|
|
|
/// Upload file with retry tracking
|
|
pub async fn upload_file(
|
|
&self,
|
|
file_path: &Path,
|
|
object_key: &str,
|
|
content_type: Option<String>,
|
|
) -> Result<UploadOperation, UploadError> {
|
|
self.upload_file_with_tags(file_path, object_key, content_type, HashMap::new()).await
|
|
}
|
|
|
|
/// Upload file with metadata tags
|
|
pub async fn upload_file_with_tags(
|
|
&self,
|
|
file_path: &Path,
|
|
object_key: &str,
|
|
content_type: Option<String>,
|
|
tags: HashMap<String, String>,
|
|
) -> Result<UploadOperation, UploadError> {
|
|
// Check if we should fail
|
|
let should_fail = {
|
|
let mut count = self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
if *count < self.config.initial_failures {
|
|
*count += 1;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
if should_fail {
|
|
return Err(UploadError::TransientError("Connection timeout".to_string()));
|
|
}
|
|
|
|
// Read file to get size
|
|
let metadata = tokio::fs::metadata(file_path)
|
|
.await
|
|
.map_err(|e| UploadError::IoError(e.to_string()))?;
|
|
let size_bytes = metadata.len();
|
|
|
|
// Calculate retry count
|
|
let retry_count = *self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
|
|
// Simulate upload with chunked progress
|
|
let start = std::time::Instant::now();
|
|
let chunks = (size_bytes / (256 * 1024)) + 1; // 256 KB chunks
|
|
|
|
for _ in 0..chunks {
|
|
sleep(Duration::from_millis(self.config.chunk_latency_ms)).await;
|
|
}
|
|
|
|
let upload_duration_ms = start.elapsed().as_millis() as u64;
|
|
|
|
// Calculate checksum (mock SHA256)
|
|
let checksum = format!("sha256:{:016x}", size_bytes);
|
|
|
|
let operation = UploadOperation {
|
|
object_key: object_key.to_string(),
|
|
file_path: file_path.to_path_buf(),
|
|
size_bytes,
|
|
content_type,
|
|
tags,
|
|
checksum,
|
|
upload_duration_ms,
|
|
retry_count,
|
|
};
|
|
|
|
// Record operation
|
|
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").push(operation.clone());
|
|
|
|
Ok(operation)
|
|
}
|
|
|
|
/// Upload file with progress callback
|
|
pub async fn upload_file_with_progress<F>(
|
|
&self,
|
|
file_path: &Path,
|
|
object_key: &str,
|
|
content_type: Option<String>,
|
|
callback: F,
|
|
) -> Result<UploadOperation, UploadError>
|
|
where
|
|
F: Fn(u64, u64) + Send + 'static,
|
|
{
|
|
// Check for failures
|
|
let should_fail = {
|
|
let mut count = self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
if *count < self.config.initial_failures {
|
|
*count += 1;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
if should_fail {
|
|
return Err(UploadError::TransientError("Connection timeout".to_string()));
|
|
}
|
|
|
|
// Read file to get size
|
|
let metadata = tokio::fs::metadata(file_path)
|
|
.await
|
|
.map_err(|e| UploadError::IoError(e.to_string()))?;
|
|
let size_bytes = metadata.len();
|
|
|
|
// Simulate chunked upload with progress updates
|
|
let start = std::time::Instant::now();
|
|
let chunk_size = 256 * 1024; // 256 KB
|
|
let mut uploaded = 0u64;
|
|
|
|
while uploaded < size_bytes {
|
|
sleep(Duration::from_millis(self.config.chunk_latency_ms)).await;
|
|
|
|
uploaded = std::cmp::min(uploaded + chunk_size, size_bytes);
|
|
callback(uploaded, size_bytes);
|
|
}
|
|
|
|
let upload_duration_ms = start.elapsed().as_millis() as u64;
|
|
let retry_count = *self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
let checksum = format!("sha256:{:016x}", size_bytes);
|
|
|
|
let operation = UploadOperation {
|
|
object_key: object_key.to_string(),
|
|
file_path: file_path.to_path_buf(),
|
|
size_bytes,
|
|
content_type,
|
|
tags: HashMap::new(),
|
|
checksum,
|
|
upload_duration_ms,
|
|
retry_count,
|
|
};
|
|
|
|
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").push(operation.clone());
|
|
|
|
Ok(operation)
|
|
}
|
|
|
|
/// Get metadata for uploaded object
|
|
pub async fn get_object_metadata(&self, object_key: &str) -> Result<ObjectMetadata, UploadError> {
|
|
let operations = self.operations.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
|
|
operations
|
|
.iter()
|
|
.find(|op| op.object_key == object_key)
|
|
.map(|op| ObjectMetadata {
|
|
object_key: op.object_key.clone(),
|
|
size_bytes: op.size_bytes,
|
|
checksum: op.checksum.clone(),
|
|
tags: op.tags.clone(),
|
|
content_type: op.content_type.clone(),
|
|
})
|
|
.ok_or_else(|| UploadError::NotFound(format!("Object not found: {}", object_key)))
|
|
}
|
|
|
|
/// Get all recorded operations
|
|
pub fn get_operations(&self) -> Vec<UploadOperation> {
|
|
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").clone()
|
|
}
|
|
|
|
/// Get count of operations
|
|
pub fn get_operation_count(&self) -> usize {
|
|
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").len()
|
|
}
|
|
|
|
/// Reset operation history
|
|
pub fn reset(&self) {
|
|
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").clear();
|
|
*self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned") = 0;
|
|
}
|
|
}
|
|
|
|
impl Default for MockMinIOUploader {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Object metadata returned by MinIO
|
|
#[derive(Debug, Clone)]
|
|
pub struct ObjectMetadata {
|
|
pub object_key: String,
|
|
pub size_bytes: u64,
|
|
pub checksum: String,
|
|
pub tags: HashMap<String, String>,
|
|
pub content_type: Option<String>,
|
|
}
|
|
|
|
/// Upload error types
|
|
#[derive(Debug, Clone)]
|
|
pub enum UploadError {
|
|
TransientError(String),
|
|
IoError(String),
|
|
NotFound(String),
|
|
}
|
|
|
|
impl std::fmt::Display for UploadError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
UploadError::TransientError(msg) => write!(f, "Transient error: {}", msg),
|
|
UploadError::IoError(msg) => write!(f, "I/O error: {}", msg),
|
|
UploadError::NotFound(msg) => write!(f, "Not found: {}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for UploadError {}
|
|
|
|
// =============================================================================
|
|
// SECTION 3: MockDataValidator - Configurable Validation Results
|
|
// =============================================================================
|
|
|
|
/// Configuration for validation behavior
|
|
#[derive(Debug, Clone)]
|
|
pub struct ValidationConfig {
|
|
/// Quality score threshold (0.0-1.0)
|
|
pub quality_threshold: f64,
|
|
/// Percentage of records that should be invalid (0.0-1.0)
|
|
pub invalid_record_rate: f64,
|
|
/// Simulate validation latency
|
|
pub validation_latency_ms: u64,
|
|
/// Specific validation issues to inject
|
|
pub inject_issues: Vec<ValidationIssue>,
|
|
}
|
|
|
|
impl Default for ValidationConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
quality_threshold: 0.95,
|
|
invalid_record_rate: 0.01, // 1% invalid
|
|
validation_latency_ms: 50,
|
|
inject_issues: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ValidationIssue {
|
|
MissingTimestamps,
|
|
InvalidPrices,
|
|
GapInSequence,
|
|
DuplicateRecords,
|
|
CorruptedHeaders,
|
|
}
|
|
|
|
impl ValidationIssue {
|
|
pub fn description(&self) -> &str {
|
|
match self {
|
|
ValidationIssue::MissingTimestamps => "Records with missing timestamps detected",
|
|
ValidationIssue::InvalidPrices => "Invalid price values (zero or negative) detected",
|
|
ValidationIssue::GapInSequence => "Gap in sequence numbers detected",
|
|
ValidationIssue::DuplicateRecords => "Duplicate records found",
|
|
ValidationIssue::CorruptedHeaders => "File headers appear corrupted",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mock data validator with configurable results
|
|
pub struct MockDataValidator {
|
|
config: Arc<ValidationConfig>,
|
|
}
|
|
|
|
impl MockDataValidator {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
config: Arc::new(ValidationConfig::default()),
|
|
}
|
|
}
|
|
|
|
pub fn with_config(mut self, config: ValidationConfig) -> Self {
|
|
self.config = Arc::new(config);
|
|
self
|
|
}
|
|
|
|
/// Validate downloaded file
|
|
pub async fn validate_file(&self, file_path: &Path) -> Result<ValidationResult, ValidationError> {
|
|
// Simulate validation latency
|
|
sleep(Duration::from_millis(self.config.validation_latency_ms)).await;
|
|
|
|
// Check file exists
|
|
if !file_path.exists() {
|
|
return Err(ValidationError::FileNotFound(file_path.display().to_string()));
|
|
}
|
|
|
|
// Get file metadata
|
|
let metadata = tokio::fs::metadata(file_path)
|
|
.await
|
|
.map_err(|e| ValidationError::IoError(e.to_string()))?;
|
|
|
|
// Calculate mock validation results
|
|
let total_records = (metadata.len() / 100) as u64; // Assume 100 bytes per record
|
|
let invalid_records = (total_records as f64 * self.config.invalid_record_rate) as u64;
|
|
let quality_score = 1.0 - self.config.invalid_record_rate;
|
|
|
|
// Collect validation issues
|
|
let mut issues = Vec::new();
|
|
|
|
// Add configured issues
|
|
for issue in &self.config.inject_issues {
|
|
issues.push(issue.description().to_string());
|
|
}
|
|
|
|
// Add quality score issue if below threshold
|
|
if quality_score < self.config.quality_threshold {
|
|
issues.push(format!(
|
|
"Data quality score {:.3} is below threshold {:.3}",
|
|
quality_score,
|
|
self.config.quality_threshold
|
|
));
|
|
}
|
|
|
|
// Add invalid records issue if any
|
|
if invalid_records > 0 {
|
|
issues.push(format!(
|
|
"{} invalid records detected ({:.2}% of total)",
|
|
invalid_records,
|
|
(invalid_records as f64 / total_records as f64) * 100.0
|
|
));
|
|
}
|
|
|
|
let is_valid = quality_score >= self.config.quality_threshold && issues.is_empty();
|
|
|
|
Ok(ValidationResult {
|
|
is_valid,
|
|
total_records,
|
|
invalid_records,
|
|
quality_score,
|
|
issues,
|
|
validation_duration_ms: self.config.validation_latency_ms,
|
|
})
|
|
}
|
|
|
|
/// Validate with custom quality score
|
|
pub async fn validate_with_quality(
|
|
&self,
|
|
file_path: &Path,
|
|
quality_score: f64,
|
|
) -> Result<ValidationResult, ValidationError> {
|
|
sleep(Duration::from_millis(self.config.validation_latency_ms)).await;
|
|
|
|
if !file_path.exists() {
|
|
return Err(ValidationError::FileNotFound(file_path.display().to_string()));
|
|
}
|
|
|
|
let metadata = tokio::fs::metadata(file_path)
|
|
.await
|
|
.map_err(|e| ValidationError::IoError(e.to_string()))?;
|
|
|
|
let total_records = (metadata.len() / 100) as u64;
|
|
let invalid_records = ((1.0 - quality_score) * total_records as f64) as u64;
|
|
|
|
let mut issues = Vec::new();
|
|
if quality_score < self.config.quality_threshold {
|
|
issues.push(format!(
|
|
"Quality score {:.3} below threshold {:.3}",
|
|
quality_score,
|
|
self.config.quality_threshold
|
|
));
|
|
}
|
|
|
|
Ok(ValidationResult {
|
|
is_valid: quality_score >= self.config.quality_threshold,
|
|
total_records,
|
|
invalid_records,
|
|
quality_score,
|
|
issues,
|
|
validation_duration_ms: self.config.validation_latency_ms,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for MockDataValidator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Validation result
|
|
#[derive(Debug, Clone)]
|
|
pub struct ValidationResult {
|
|
pub is_valid: bool,
|
|
pub total_records: u64,
|
|
pub invalid_records: u64,
|
|
pub quality_score: f64,
|
|
pub issues: Vec<String>,
|
|
pub validation_duration_ms: u64,
|
|
}
|
|
|
|
/// Validation error types
|
|
#[derive(Debug, Clone)]
|
|
pub enum ValidationError {
|
|
FileNotFound(String),
|
|
IoError(String),
|
|
}
|
|
|
|
impl std::fmt::Display for ValidationError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ValidationError::FileNotFound(path) => write!(f, "File not found: {}", path),
|
|
ValidationError::IoError(msg) => write!(f, "I/O error: {}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ValidationError {}
|
|
|
|
// =============================================================================
|
|
// SECTION 4: RetryTracker - Track Retry Attempts with Exponential Backoff
|
|
// =============================================================================
|
|
|
|
/// Retry attempt record
|
|
#[derive(Debug, Clone)]
|
|
pub struct RetryAttempt {
|
|
pub attempt_number: u32,
|
|
pub delay_ms: u64,
|
|
pub timestamp: std::time::Instant,
|
|
pub error: String,
|
|
}
|
|
|
|
/// Retry tracker with exponential backoff
|
|
pub struct RetryTracker {
|
|
attempts: Arc<Mutex<Vec<RetryAttempt>>>,
|
|
max_attempts: u32,
|
|
base_delay_ms: u64,
|
|
max_delay_ms: u64,
|
|
}
|
|
|
|
impl RetryTracker {
|
|
pub fn new(max_attempts: u32, base_delay_ms: u64) -> Self {
|
|
Self {
|
|
attempts: Arc::new(Mutex::new(Vec::new())),
|
|
max_attempts,
|
|
base_delay_ms,
|
|
max_delay_ms: 60000, // 60 seconds max
|
|
}
|
|
}
|
|
|
|
/// Record a retry attempt
|
|
pub async fn record_retry(&self, error: String) -> Result<(), String> {
|
|
let attempt_number = {
|
|
let mut attempts = self.attempts.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
attempts.len() as u32 + 1
|
|
};
|
|
|
|
if attempt_number > self.max_attempts {
|
|
return Err(format!(
|
|
"Max retry attempts ({}) exceeded",
|
|
self.max_attempts
|
|
));
|
|
}
|
|
|
|
// Calculate exponential backoff delay
|
|
let delay_ms = std::cmp::min(
|
|
self.base_delay_ms * 2u64.pow(attempt_number - 1),
|
|
self.max_delay_ms,
|
|
);
|
|
|
|
let attempt = RetryAttempt {
|
|
attempt_number,
|
|
delay_ms,
|
|
timestamp: std::time::Instant::now(),
|
|
error,
|
|
};
|
|
|
|
// Record attempt
|
|
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").push(attempt.clone());
|
|
|
|
// Apply backoff delay
|
|
sleep(Duration::from_millis(delay_ms)).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get all retry attempts
|
|
pub fn get_attempts(&self) -> Vec<RetryAttempt> {
|
|
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").clone()
|
|
}
|
|
|
|
/// Get total retry count
|
|
pub fn get_retry_count(&self) -> u32 {
|
|
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").len() as u32
|
|
}
|
|
|
|
/// Get total wait time across all retries
|
|
pub fn get_total_wait_time(&self) -> Duration {
|
|
let attempts = self.attempts.lock().expect("INVARIANT: Lock should not be poisoned");
|
|
let total_ms: u64 = attempts.iter().map(|a| a.delay_ms).sum();
|
|
Duration::from_millis(total_ms)
|
|
}
|
|
|
|
/// Check if max attempts reached
|
|
pub fn is_exhausted(&self) -> bool {
|
|
self.get_retry_count() >= self.max_attempts
|
|
}
|
|
|
|
/// Reset tracker
|
|
pub fn reset(&self) {
|
|
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").clear();
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SECTION 5: ProgressCallback - Monitor Progress Updates
|
|
// =============================================================================
|
|
|
|
/// Progress update record
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProgressUpdate {
|
|
pub bytes_processed: u64,
|
|
pub total_bytes: u64,
|
|
pub percentage: f32,
|
|
pub timestamp: std::time::Instant,
|
|
}
|
|
|
|
/// Progress callback tracker
|
|
pub struct ProgressCallback {
|
|
updates: Arc<Mutex<Vec<ProgressUpdate>>>,
|
|
}
|
|
|
|
impl ProgressCallback {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
updates: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
/// Create callback function that records updates
|
|
pub fn create_callback(&self) -> impl Fn(u64, u64) + Send + 'static {
|
|
let updates = self.updates.clone();
|
|
|
|
move |bytes_processed: u64, total_bytes: u64| {
|
|
let percentage = if total_bytes > 0 {
|
|
(bytes_processed as f32 / total_bytes as f32) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let update = ProgressUpdate {
|
|
bytes_processed,
|
|
total_bytes,
|
|
percentage,
|
|
timestamp: std::time::Instant::now(),
|
|
};
|
|
|
|
updates.lock().expect("INVARIANT: Lock should not be poisoned").push(update);
|
|
}
|
|
}
|
|
|
|
/// Get all progress updates
|
|
pub fn get_updates(&self) -> Vec<ProgressUpdate> {
|
|
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").clone()
|
|
}
|
|
|
|
/// Get update count
|
|
pub fn get_update_count(&self) -> usize {
|
|
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").len()
|
|
}
|
|
|
|
/// Get final progress percentage
|
|
pub fn get_final_percentage(&self) -> Option<f32> {
|
|
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").last().map(|u| u.percentage)
|
|
}
|
|
|
|
/// Check if progress reached 100%
|
|
pub fn is_complete(&self) -> bool {
|
|
self.get_final_percentage().map_or(false, |p| p >= 100.0)
|
|
}
|
|
|
|
/// Reset tracker
|
|
pub fn reset(&self) {
|
|
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").clear();
|
|
}
|
|
}
|
|
|
|
impl Default for ProgressCallback {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// TESTS
|
|
// =============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_databento_downloader_success() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let output_path = temp_dir.path().join("test.dbn");
|
|
|
|
let downloader = MockDatabentoDownloader::new(1024);
|
|
let result = downloader.download("http://test.com/data", &output_path).await;
|
|
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), 1024);
|
|
assert_eq!(downloader.get_state(), DownloadState::Completed);
|
|
assert_eq!(downloader.get_attempt_count(), 1);
|
|
assert!(output_path.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_databento_downloader_failure_injection() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let output_path = temp_dir.path().join("test.dbn");
|
|
|
|
let config = DownloadFailureConfig {
|
|
fail_on_attempt: Some(1),
|
|
error_type: DownloadErrorType::RateLimited,
|
|
latency_ms: 10,
|
|
};
|
|
|
|
let downloader = MockDatabentoDownloader::new(1024).with_failure_config(config);
|
|
let result = downloader.download("http://test.com/data", &output_path).await;
|
|
|
|
assert!(result.is_err());
|
|
assert_eq!(downloader.get_state(), DownloadState::Failed);
|
|
|
|
if let Err(DownloadError::RateLimited { retry_after_seconds, .. }) = result {
|
|
assert_eq!(retry_after_seconds, 60);
|
|
} else {
|
|
panic!("Expected RateLimited error");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_databento_downloader_progress() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let output_path = temp_dir.path().join("test.dbn");
|
|
|
|
let downloader = MockDatabentoDownloader::new(1024 * 1024); // 1 MB
|
|
|
|
// Start download in background
|
|
let downloader_clone = MockDatabentoDownloader::new(1024 * 1024);
|
|
let output_clone = output_path.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let _ = downloader_clone.download("http://test.com/data", &output_clone).await;
|
|
});
|
|
|
|
// Check progress
|
|
sleep(Duration::from_millis(50)).await;
|
|
let (downloaded, total) = downloader.get_progress();
|
|
assert!(downloaded <= total);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_minio_uploader_basic() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("test.dat");
|
|
tokio::fs::write(&file_path, b"test data").await.unwrap();
|
|
|
|
let uploader = MockMinIOUploader::new();
|
|
let result = uploader.upload_file(&file_path, "test/object", None).await;
|
|
|
|
assert!(result.is_ok());
|
|
let operation = result.unwrap();
|
|
assert_eq!(operation.object_key, "test/object");
|
|
assert_eq!(operation.size_bytes, 9);
|
|
assert_eq!(operation.retry_count, 0);
|
|
assert_eq!(uploader.get_operation_count(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_minio_uploader_with_retries() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("test.dat");
|
|
tokio::fs::write(&file_path, b"test data").await.unwrap();
|
|
|
|
let config = UploadFailureConfig {
|
|
initial_failures: 2,
|
|
chunk_latency_ms: 5,
|
|
};
|
|
|
|
let uploader = MockMinIOUploader::new().with_failure_config(config);
|
|
|
|
// First two attempts should fail
|
|
assert!(uploader.upload_file(&file_path, "test/obj1", None).await.is_err());
|
|
assert!(uploader.upload_file(&file_path, "test/obj2", None).await.is_err());
|
|
|
|
// Third attempt should succeed
|
|
let result = uploader.upload_file(&file_path, "test/obj3", None).await;
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap().retry_count, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_minio_uploader_progress() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("test.dat");
|
|
tokio::fs::write(&file_path, vec![0u8; 1024 * 1024]).await.unwrap(); // 1 MB
|
|
|
|
let uploader = MockMinIOUploader::new();
|
|
let progress = ProgressCallback::new();
|
|
let callback = progress.create_callback();
|
|
|
|
let result = uploader
|
|
.upload_file_with_progress(&file_path, "test/obj", None, callback)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
assert!(progress.get_update_count() > 0);
|
|
assert!(progress.is_complete());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_data_validator_valid() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("test.dbn");
|
|
tokio::fs::write(&file_path, vec![0u8; 10000]).await.unwrap(); // 100 records
|
|
|
|
let validator = MockDataValidator::new();
|
|
let result = validator.validate_file(&file_path).await;
|
|
|
|
assert!(result.is_ok());
|
|
let validation = result.unwrap();
|
|
assert!(validation.is_valid);
|
|
assert_eq!(validation.total_records, 100);
|
|
assert!(validation.quality_score >= 0.95);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_data_validator_with_issues() {
|
|
let temp_dir = tempfile::TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("test.dbn");
|
|
tokio::fs::write(&file_path, vec![0u8; 10000]).await.unwrap();
|
|
|
|
let config = ValidationConfig {
|
|
quality_threshold: 0.95,
|
|
invalid_record_rate: 0.10, // 10% invalid
|
|
validation_latency_ms: 10,
|
|
inject_issues: vec![ValidationIssue::MissingTimestamps],
|
|
};
|
|
|
|
let validator = MockDataValidator::new().with_config(config);
|
|
let result = validator.validate_file(&file_path).await;
|
|
|
|
assert!(result.is_ok());
|
|
let validation = result.unwrap();
|
|
assert!(!validation.is_valid); // Should fail due to 10% invalid rate
|
|
assert_eq!(validation.invalid_records, 10);
|
|
assert!(!validation.issues.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_retry_tracker_exponential_backoff() {
|
|
let tracker = RetryTracker::new(3, 100); // 3 attempts, 100ms base
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
tracker.record_retry("Error 1".to_string()).await.unwrap();
|
|
tracker.record_retry("Error 2".to_string()).await.unwrap();
|
|
tracker.record_retry("Error 3".to_string()).await.unwrap();
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
// Should have delays: 100ms, 200ms, 400ms = 700ms total
|
|
assert!(elapsed >= Duration::from_millis(700));
|
|
assert_eq!(tracker.get_retry_count(), 3);
|
|
|
|
let attempts = tracker.get_attempts();
|
|
assert_eq!(attempts[0].delay_ms, 100);
|
|
assert_eq!(attempts[1].delay_ms, 200);
|
|
assert_eq!(attempts[2].delay_ms, 400);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_retry_tracker_max_attempts() {
|
|
let tracker = RetryTracker::new(2, 10);
|
|
|
|
tracker.record_retry("Error 1".to_string()).await.unwrap();
|
|
tracker.record_retry("Error 2".to_string()).await.unwrap();
|
|
|
|
let result = tracker.record_retry("Error 3".to_string()).await;
|
|
assert!(result.is_err());
|
|
assert!(tracker.is_exhausted());
|
|
}
|
|
|
|
#[test]
|
|
fn test_progress_callback_tracking() {
|
|
let progress = ProgressCallback::new();
|
|
let callback = progress.create_callback();
|
|
|
|
callback(0, 1000);
|
|
callback(250, 1000);
|
|
callback(500, 1000);
|
|
callback(1000, 1000);
|
|
|
|
assert_eq!(progress.get_update_count(), 4);
|
|
assert_eq!(progress.get_final_percentage(), Some(100.0));
|
|
assert!(progress.is_complete());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validation_issue_descriptions() {
|
|
assert_eq!(
|
|
ValidationIssue::MissingTimestamps.description(),
|
|
"Records with missing timestamps detected"
|
|
);
|
|
assert_eq!(
|
|
ValidationIssue::InvalidPrices.description(),
|
|
"Invalid price values (zero or negative) detected"
|
|
);
|
|
}
|
|
}
|