// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! High-performance batch processor using JoinSet //! //! This module provides optimized batching utilities to reduce async runtime overhead //! and improve concurrent operation performance. use crate::disk::error::{Error, Result}; use std::future::Future; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::task::JoinSet; #[allow( dead_code, reason = "default operation label for the test-only AsyncBatchProcessor::new (backlog#1823)" )] const BATCH_PROCESSOR_OPERATION_CUSTOM: &str = "custom"; const BATCH_PROCESSOR_OPERATION_READ: &str = "read"; const BATCH_PROCESSOR_OPERATION_WRITE: &str = "write"; const BATCH_PROCESSOR_OPERATION_METADATA: &str = "metadata"; const ENV_RUSTFS_BATCH_PROCESSOR_ADAPTIVE: &str = "RUSTFS_BATCH_PROCESSOR_ADAPTIVE"; const BATCH_PROCESSOR_ADAPTIVE_OFF: &str = "off"; const BATCH_PROCESSOR_ADAPTIVE_OBSERVE: &str = "observe"; const BATCH_PROCESSOR_ADAPTIVE_ON: &str = "on"; const BATCH_SUGGESTION_REASON_IMPROVING: &str = "improving"; const BATCH_SUGGESTION_REASON_DEGRADING: &str = "degrading"; const BATCH_SUGGESTION_REASON_STABLE: &str = "stable"; const BATCH_SUGGESTION_REASON_COOLDOWN: &str = "cooldown"; const BATCH_OBSERVATION_IMPROVING_LATENCY: Duration = Duration::from_millis(50); const BATCH_OBSERVATION_DEGRADING_LATENCY: Duration = Duration::from_millis(250); const BATCH_OBSERVATION_COOLDOWN: Duration = Duration::from_secs(30); const BATCH_ADAPTIVE_MAX_CONCURRENCY_FACTOR: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BatchProcessorAdaptiveMode { Off, Observe, On, } impl BatchProcessorAdaptiveMode { fn should_observe(self) -> bool { matches!(self, Self::Observe | Self::On) } fn should_apply(self) -> bool { matches!(self, Self::On) } } fn parse_batch_processor_adaptive_mode(raw: &str) -> BatchProcessorAdaptiveMode { match raw.trim() { value if value.eq_ignore_ascii_case(BATCH_PROCESSOR_ADAPTIVE_OBSERVE) => BatchProcessorAdaptiveMode::Observe, value if value.eq_ignore_ascii_case(BATCH_PROCESSOR_ADAPTIVE_ON) => BatchProcessorAdaptiveMode::On, value if value.eq_ignore_ascii_case(BATCH_PROCESSOR_ADAPTIVE_OFF) => BatchProcessorAdaptiveMode::Off, _ => BatchProcessorAdaptiveMode::Off, } } fn batch_processor_adaptive_mode_from_env() -> BatchProcessorAdaptiveMode { rustfs_utils::get_env_opt_str(ENV_RUSTFS_BATCH_PROCESSOR_ADAPTIVE) .as_deref() .map(parse_batch_processor_adaptive_mode) .unwrap_or(BatchProcessorAdaptiveMode::Off) } fn batch_processor_adaptive_mode() -> BatchProcessorAdaptiveMode { // The gate cannot change at runtime; parse it once instead of re-reading // the environment on every batch. static MODE: std::sync::LazyLock = std::sync::LazyLock::new(batch_processor_adaptive_mode_from_env); *MODE } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BatchConcurrencySuggestion { concurrency: usize, reason: &'static str, } #[derive(Debug, Clone, Copy)] struct BatchObservation { batch_size: usize, success_count: usize, error_count: usize, timeout_count: usize, max_queue_wait: Duration, execution_latency: Duration, } #[derive(Debug)] struct BatchObservationState { last_suggestion_at: Option, last_suggested_concurrency: usize, } impl BatchObservationState { fn new(configured_concurrency: usize) -> Self { Self { last_suggestion_at: None, last_suggested_concurrency: configured_concurrency, } } fn suggest( &mut self, current_concurrency: usize, configured_concurrency: usize, observation: BatchObservation, now: Instant, ) -> BatchConcurrencySuggestion { let raw = calculate_batch_concurrency_suggestion(current_concurrency, configured_concurrency, observation); if raw.concurrency == current_concurrency { self.last_suggested_concurrency = current_concurrency; return raw; } if self .last_suggestion_at .is_some_and(|last_suggestion_at| now.duration_since(last_suggestion_at) < BATCH_OBSERVATION_COOLDOWN) { return BatchConcurrencySuggestion { concurrency: self.last_suggested_concurrency, reason: BATCH_SUGGESTION_REASON_COOLDOWN, }; } self.last_suggestion_at = Some(now); self.last_suggested_concurrency = raw.concurrency; raw } } fn calculate_batch_concurrency_suggestion( current_concurrency: usize, configured_concurrency: usize, observation: BatchObservation, ) -> BatchConcurrencySuggestion { let current_concurrency = current_concurrency.max(1); let configured_concurrency = configured_concurrency.max(1); if observation.batch_size == 0 { return BatchConcurrencySuggestion { concurrency: current_concurrency, reason: BATCH_SUGGESTION_REASON_STABLE, }; } if observation.timeout_count > 0 || observation.error_count > 0 || (observation.batch_size >= current_concurrency && observation.execution_latency >= BATCH_OBSERVATION_DEGRADING_LATENCY) { return BatchConcurrencySuggestion { concurrency: decrease_batch_concurrency(current_concurrency), reason: BATCH_SUGGESTION_REASON_DEGRADING, }; } if observation.success_count == observation.batch_size && observation.batch_size >= current_concurrency && observation.execution_latency <= BATCH_OBSERVATION_IMPROVING_LATENCY { return BatchConcurrencySuggestion { concurrency: increase_batch_concurrency(current_concurrency, configured_concurrency, observation.batch_size), reason: BATCH_SUGGESTION_REASON_IMPROVING, }; } BatchConcurrencySuggestion { concurrency: current_concurrency, reason: BATCH_SUGGESTION_REASON_STABLE, } } fn decrease_batch_concurrency(current_concurrency: usize) -> usize { (current_concurrency.saturating_mul(3) / 4).max(1) } fn increase_batch_concurrency(current_concurrency: usize, configured_concurrency: usize, batch_size: usize) -> usize { let step = (current_concurrency / 4).max(1); // Suggestions build on the previous suggestion, so bound the ratchet at a // hard multiple of the configured baseline instead of the current value. let upper_bound = configured_concurrency .saturating_mul(BATCH_ADAPTIVE_MAX_CONCURRENCY_FACTOR) .max(current_concurrency); current_concurrency .saturating_add(step) .min(upper_bound) .min(batch_size.max(current_concurrency)) } fn is_timeout_error(err: &Error) -> bool { matches!(err, Error::Timeout | Error::SourceStalled) || matches!(err, Error::Io(io_err) if io_err.kind() == std::io::ErrorKind::TimedOut) } /// Batch processor that executes tasks concurrently with a semaphore pub struct AsyncBatchProcessor { max_concurrent: usize, operation: &'static str, observation_state: Mutex, } impl AsyncBatchProcessor { #[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")] pub fn new(max_concurrent: usize) -> Self { Self::new_with_operation(max_concurrent, BATCH_PROCESSOR_OPERATION_CUSTOM) } fn new_with_operation(max_concurrent: usize, operation: &'static str) -> Self { let max_concurrent = max_concurrent.max(1); Self { max_concurrent, operation, observation_state: Mutex::new(BatchObservationState::new(max_concurrent)), } } fn execution_concurrency(&self, mode: BatchProcessorAdaptiveMode) -> usize { if !mode.should_apply() { return self.max_concurrent; } self.observation_state .lock() .map(|state| state.last_suggested_concurrency.max(1)) .unwrap_or(self.max_concurrent) } fn observe_batch_with_mode( &self, mode: BatchProcessorAdaptiveMode, execution_concurrency: usize, observation: BatchObservation, ) -> BatchConcurrencySuggestion { let execution_concurrency = execution_concurrency.max(1); if !mode.should_observe() { return BatchConcurrencySuggestion { concurrency: execution_concurrency, reason: BATCH_SUGGESTION_REASON_STABLE, }; } let suggestion = match self.observation_state.lock() { Ok(mut state) => state.suggest(execution_concurrency, self.max_concurrent, observation, Instant::now()), Err(_) => calculate_batch_concurrency_suggestion(execution_concurrency, self.max_concurrent, observation), }; rustfs_io_metrics::record_batch_processor_observation(rustfs_io_metrics::BatchProcessorObservation { operation: self.operation, batch_size: observation.batch_size, configured_concurrency: execution_concurrency, max_queue_wait_secs: observation.max_queue_wait.as_secs_f64(), execution_latency_secs: observation.execution_latency.as_secs_f64(), successes: observation.success_count, errors: observation.error_count, timeouts: observation.timeout_count, suggested_concurrency: suggestion.concurrency, suggestion_reason: suggestion.reason, }); suggestion } /// Execute a batch of tasks concurrently with concurrency control pub async fn execute_batch(&self, tasks: Vec) -> Vec> where T: Send + 'static, F: Future> + Send + 'static, { if tasks.is_empty() { return Vec::new(); } let batch_size = tasks.len(); let batch_started_at = Instant::now(); let adaptive_mode = batch_processor_adaptive_mode(); let execution_concurrency = self.execution_concurrency(adaptive_mode); let semaphore = Arc::new(tokio::sync::Semaphore::new(execution_concurrency)); let mut join_set = JoinSet::new(); let mut results = Vec::with_capacity(batch_size); for _ in 0..batch_size { results.push(Err(Error::other("Not completed"))); } // Spawn all tasks with semaphore control for (i, task) in tasks.into_iter().enumerate() { let sem = semaphore.clone(); let queued_at = Instant::now(); join_set.spawn(async move { let _permit = sem.acquire().await.map_err(|_| Error::other("Semaphore error"))?; let queue_wait = queued_at.elapsed(); let result = task.await; Ok::<(usize, Result, Duration), Error>((i, result, queue_wait)) }); } // Collect results let mut max_queue_wait = Duration::ZERO; while let Some(join_result) = join_set.join_next().await { match join_result { Ok(Ok((index, task_result, queue_wait))) => { max_queue_wait = max_queue_wait.max(queue_wait); if index < results.len() { results[index] = task_result; } } Ok(Err(e)) => { // Semaphore or other system error - this is rare tracing::warn!("Batch processor system error: {:?}", e); } Err(join_error) => { // Task panicked - log but continue tracing::warn!("Task panicked in batch processor: {:?}", join_error); } } } let mut success_count = 0; let mut error_count = 0; let mut timeout_count = 0; for result in &results { match result { Ok(_) => success_count += 1, Err(err) if is_timeout_error(err) => timeout_count += 1, Err(_) => error_count += 1, } } self.observe_batch_with_mode( adaptive_mode, execution_concurrency, BatchObservation { batch_size, success_count, error_count, timeout_count, max_queue_wait, execution_latency: batch_started_at.elapsed(), }, ); results } /// Execute batch with early termination when sufficient successful results are obtained pub async fn execute_batch_with_quorum(&self, tasks: Vec, required_successes: usize) -> Result> where T: Send + 'static, F: Future> + Send + 'static, { if required_successes == 0 { return Ok(Vec::new()); } if tasks.is_empty() { return Err(Error::other(format!( "Insufficient successful results: got 0, needed {required_successes}" ))); } let batch_size = tasks.len(); let batch_started_at = Instant::now(); let adaptive_mode = batch_processor_adaptive_mode(); let execution_concurrency = self.execution_concurrency(adaptive_mode); let semaphore = Arc::new(tokio::sync::Semaphore::new(execution_concurrency)); let mut join_set = JoinSet::new(); let mut successes = Vec::new(); let mut pending_tasks = batch_size; let mut first_error = None; let mut error_count = 0; let mut timeout_count = 0; let mut max_queue_wait = Duration::ZERO; for task in tasks { let sem = semaphore.clone(); let queued_at = Instant::now(); join_set.spawn(async move { let _permit = sem .acquire() .await .map_err(|_| (Error::other("Semaphore error"), queued_at.elapsed()))?; let queue_wait = queued_at.elapsed(); task.await.map(|value| (value, queue_wait)).map_err(|err| (err, queue_wait)) }); } while let Some(join_result) = join_set.join_next().await { pending_tasks = pending_tasks.saturating_sub(1); match join_result { Ok(Ok((value, queue_wait))) => { max_queue_wait = max_queue_wait.max(queue_wait); successes.push(value); if successes.len() >= required_successes { self.observe_batch_with_mode( adaptive_mode, execution_concurrency, BatchObservation { batch_size, success_count: successes.len(), error_count, timeout_count, max_queue_wait, execution_latency: batch_started_at.elapsed(), }, ); return Ok(successes); } } Ok(Err((err, queue_wait))) => { max_queue_wait = max_queue_wait.max(queue_wait); if is_timeout_error(&err) { timeout_count += 1; } else { error_count += 1; } if first_error.is_none() { first_error = Some(err); } } Err(join_error) => { error_count += 1; if first_error.is_none() { first_error = Some(Error::other(format!("Task panicked in quorum batch processor: {join_error}"))); } } } if successes.len() + pending_tasks < required_successes { self.observe_batch_with_mode( adaptive_mode, execution_concurrency, BatchObservation { batch_size, success_count: successes.len(), error_count, timeout_count, max_queue_wait, execution_latency: batch_started_at.elapsed(), }, ); return Err(first_error.unwrap_or_else(|| { Error::other(format!( "Insufficient successful results: got {}, needed {}", successes.len(), required_successes )) })); } } self.observe_batch_with_mode( adaptive_mode, execution_concurrency, BatchObservation { batch_size, success_count: successes.len(), error_count, timeout_count, max_queue_wait, execution_latency: batch_started_at.elapsed(), }, ); Err(first_error.unwrap_or_else(|| { Error::other(format!( "Insufficient successful results: got {}, needed {}", successes.len(), required_successes )) })) } } /// Global batch processor instances pub struct GlobalBatchProcessors { read_processor: AsyncBatchProcessor, write_processor: AsyncBatchProcessor, metadata_processor: AsyncBatchProcessor, } impl GlobalBatchProcessors { pub fn new() -> Self { Self { read_processor: AsyncBatchProcessor::new_with_operation(16, BATCH_PROCESSOR_OPERATION_READ), write_processor: AsyncBatchProcessor::new_with_operation(8, BATCH_PROCESSOR_OPERATION_WRITE), metadata_processor: AsyncBatchProcessor::new_with_operation(12, BATCH_PROCESSOR_OPERATION_METADATA), } } pub fn read_processor(&self) -> &AsyncBatchProcessor { &self.read_processor } pub fn write_processor(&self) -> &AsyncBatchProcessor { &self.write_processor } pub fn metadata_processor(&self) -> &AsyncBatchProcessor { &self.metadata_processor } } impl Default for GlobalBatchProcessors { fn default() -> Self { Self::new() } } // Global instance use std::sync::OnceLock; static GLOBAL_PROCESSORS: OnceLock = OnceLock::new(); pub fn get_global_processors() -> &'static GlobalBatchProcessors { GLOBAL_PROCESSORS.get_or_init(GlobalBatchProcessors::new) } #[cfg(test)] mod tests { use super::*; use std::time::Duration; #[tokio::test] async fn test_batch_processor_basic() { let processor = AsyncBatchProcessor::new(4); let tasks: Vec<_> = (0..10) .map(|i| async move { tokio::time::sleep(Duration::from_millis(10)).await; Ok::(i) }) .collect(); let results = processor.execute_batch(tasks).await; assert_eq!(results.len(), 10); // All tasks should succeed for (i, result) in results.iter().enumerate() { assert!(result.is_ok()); assert_eq!(result.as_ref().unwrap(), &(i as i32)); } } #[tokio::test] async fn test_batch_processor_with_errors() { let processor = AsyncBatchProcessor::new(2); let tasks: Vec<_> = (0..5) .map(|i| async move { tokio::time::sleep(Duration::from_millis(10)).await; if i % 2 == 0 { Ok::(i) } else { Err(Error::other("Test error")) } }) .collect(); let results = processor.execute_batch(tasks).await; assert_eq!(results.len(), 5); // Check results pattern for (i, result) in results.iter().enumerate() { if i % 2 == 0 { assert!(result.is_ok()); assert_eq!(result.as_ref().unwrap(), &(i as i32)); } else { assert!(result.is_err()); } } } #[tokio::test] async fn test_batch_processor_quorum() { let processor = AsyncBatchProcessor::new(4); let tasks: Vec<_> = (0..10) .map(|i| async move { tokio::time::sleep(Duration::from_millis(10)).await; if i < 3 { Ok::(i) } else { Err(Error::other("Test error")) } }) .collect(); let results = processor.execute_batch_with_quorum(tasks, 2).await; assert!(results.is_ok()); let successes = results.unwrap(); assert!(successes.len() >= 2); } #[tokio::test(start_paused = true)] async fn test_batch_processor_quorum_returns_before_slow_tail() { let processor = AsyncBatchProcessor::new(4); let started = tokio::time::Instant::now(); let tasks: Vec<_> = [(10_u64, Ok(1_i32)), (15, Ok(2)), (250, Ok(3))] .into_iter() .map(|(delay_ms, outcome)| async move { tokio::time::sleep(Duration::from_millis(delay_ms)).await; outcome }) .collect(); let results = processor .execute_batch_with_quorum(tasks, 2) .await .expect("quorum should succeed"); assert_eq!(results.len(), 2); assert!(started.elapsed() < Duration::from_millis(100)); } #[tokio::test(start_paused = true)] async fn test_batch_processor_quorum_fails_once_quorum_becomes_impossible() { let processor = AsyncBatchProcessor::new(4); let started = tokio::time::Instant::now(); let tasks: Vec<_> = vec![ (10_u64, Ok(1_i32)), (15, Err(Error::other("first failure"))), (20, Err(Error::other("second failure"))), (250, Ok(4)), ] .into_iter() .map(|(delay_ms, outcome)| async move { tokio::time::sleep(Duration::from_millis(delay_ms)).await; outcome }) .collect(); let err = processor .execute_batch_with_quorum(tasks, 3) .await .expect_err("quorum should fail once it becomes impossible"); assert!(err.to_string().contains("first failure")); assert!(started.elapsed() < Duration::from_millis(120)); } #[tokio::test] async fn test_batch_processor_observe_only_keeps_configured_concurrency() { let processor = AsyncBatchProcessor::new(2); let tasks: Vec<_> = (0..4) .map(|i| async move { tokio::time::sleep(Duration::from_millis(5)).await; Ok::(i) }) .collect(); let results = processor.execute_batch(tasks).await; assert_eq!(results.len(), 4); assert_eq!(processor.max_concurrent, 2); } #[test] fn batch_processor_adaptive_mode_defaults_to_off_and_parses_supported_values() { assert_eq!(parse_batch_processor_adaptive_mode(""), BatchProcessorAdaptiveMode::Off); assert_eq!(parse_batch_processor_adaptive_mode("off"), BatchProcessorAdaptiveMode::Off); assert_eq!(parse_batch_processor_adaptive_mode("observe"), BatchProcessorAdaptiveMode::Observe); assert_eq!(parse_batch_processor_adaptive_mode("on"), BatchProcessorAdaptiveMode::On); assert_eq!(parse_batch_processor_adaptive_mode("unknown"), BatchProcessorAdaptiveMode::Off); assert!(!BatchProcessorAdaptiveMode::Off.should_observe()); assert!(BatchProcessorAdaptiveMode::Observe.should_observe()); assert!(BatchProcessorAdaptiveMode::On.should_observe()); assert!(!BatchProcessorAdaptiveMode::Observe.should_apply()); assert!(BatchProcessorAdaptiveMode::On.should_apply()); } #[test] fn batch_processor_adaptive_mode_env_gate_is_parsed_from_env() { temp_env::with_var(ENV_RUSTFS_BATCH_PROCESSOR_ADAPTIVE, None::<&str>, || { assert_eq!(batch_processor_adaptive_mode_from_env(), BatchProcessorAdaptiveMode::Off); }); temp_env::with_var(ENV_RUSTFS_BATCH_PROCESSOR_ADAPTIVE, Some("observe"), || { assert_eq!(batch_processor_adaptive_mode_from_env(), BatchProcessorAdaptiveMode::Observe); }); } #[test] fn batch_processor_observation_gate_keeps_default_off_stable() { let processor = AsyncBatchProcessor::new(8); let suggestion = processor.observe_batch_with_mode( BatchProcessorAdaptiveMode::Off, processor.execution_concurrency(BatchProcessorAdaptiveMode::Off), BatchObservation { batch_size: 16, success_count: 16, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }, ); assert_eq!(suggestion.reason, BATCH_SUGGESTION_REASON_STABLE); assert_eq!(suggestion.concurrency, 8); } #[test] fn batch_processor_observation_gate_observe_records_suggestion() { let processor = AsyncBatchProcessor::new(8); let suggestion = processor.observe_batch_with_mode( BatchProcessorAdaptiveMode::Observe, processor.execution_concurrency(BatchProcessorAdaptiveMode::Observe), BatchObservation { batch_size: 16, success_count: 16, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }, ); assert_eq!(suggestion.reason, BATCH_SUGGESTION_REASON_IMPROVING); assert!(suggestion.concurrency > 8); assert_eq!(processor.execution_concurrency(BatchProcessorAdaptiveMode::Observe), 8); } #[test] fn batch_processor_adaptive_on_applies_last_suggestion_to_next_batch() { let processor = AsyncBatchProcessor::new(8); let suggestion = processor.observe_batch_with_mode( BatchProcessorAdaptiveMode::On, processor.execution_concurrency(BatchProcessorAdaptiveMode::On), BatchObservation { batch_size: 16, success_count: 16, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }, ); assert_eq!(suggestion.reason, BATCH_SUGGESTION_REASON_IMPROVING); assert!(suggestion.concurrency > 8); assert_eq!(processor.execution_concurrency(BatchProcessorAdaptiveMode::On), suggestion.concurrency); } #[test] fn batch_processor_suggestion_increases_for_fast_successful_batches() { let suggestion = calculate_batch_concurrency_suggestion( 8, 8, BatchObservation { batch_size: 16, success_count: 16, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }, ); assert_eq!(suggestion.reason, BATCH_SUGGESTION_REASON_IMPROVING); assert!(suggestion.concurrency > 8); } #[test] fn batch_processor_suggestion_decreases_for_timeout_batches() { let suggestion = calculate_batch_concurrency_suggestion( 8, 8, BatchObservation { batch_size: 16, success_count: 14, error_count: 0, timeout_count: 2, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }, ); assert_eq!(suggestion.reason, BATCH_SUGGESTION_REASON_DEGRADING); assert!(suggestion.concurrency < 8); assert!(suggestion.concurrency >= 1); } #[test] fn batch_processor_suggestion_clamps_to_batch_size() { let suggestion = calculate_batch_concurrency_suggestion( 64, 64, BatchObservation { batch_size: 65, success_count: 65, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }, ); assert_eq!(suggestion.reason, BATCH_SUGGESTION_REASON_IMPROVING); assert_eq!(suggestion.concurrency, 65); } #[test] fn batch_processor_suggestion_cooldown_prevents_rapid_oscillation() { let mut state = BatchObservationState::new(8); let now = Instant::now(); let observation = BatchObservation { batch_size: 16, success_count: 16, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }; let first = state.suggest(8, 8, observation, now); let second = state.suggest(8, 8, observation, now + Duration::from_secs(1)); assert_eq!(first.reason, BATCH_SUGGESTION_REASON_IMPROVING); assert_eq!(second.reason, BATCH_SUGGESTION_REASON_COOLDOWN); assert_eq!(second.concurrency, first.concurrency); } #[test] fn batch_processor_suggestion_growth_is_capped_relative_to_configured_concurrency() { const CONFIGURED: usize = 8; let mut state = BatchObservationState::new(CONFIGURED); let mut current = CONFIGURED; let mut now = Instant::now(); let observation = BatchObservation { batch_size: 1024, success_count: 1024, error_count: 0, timeout_count: 0, max_queue_wait: Duration::ZERO, execution_latency: Duration::from_millis(20), }; for _ in 0..64 { current = state.suggest(current, CONFIGURED, observation, now).concurrency; now += BATCH_OBSERVATION_COOLDOWN; } assert_eq!(current, CONFIGURED * BATCH_ADAPTIVE_MAX_CONCURRENCY_FACTOR); } }