mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-15 17:43:13 +00:00
d6c62b9601
Removing the blanket exposes twenty-five items across tier, notification and rebalance. Only eight are deleted — the lowest ratio of this burn-down so far, and the reason is that these subsystems carry heavy test coverage, so the blanket was mostly hiding test-only seams rather than dead weight. Deleted: - crates/ecstore/src/services/tier/warm_backend_s3sdk.rs entirely (200 lines). Its WarmBackendS3 is never constructed; the type of the same name in warm_backend_s3.rs is the live one, wrapped by the Azure backend. Two implementations of one S3 warm backend, one of them never wired. - TierConfigMgr::begin_publish_transition and publish_candidate_inner, thin wrappers whose _with_allowed_mutation_blocks siblings carry every real caller, plus retire_driver. - The GCS backend's MAX_MULTIPART_PUT_OBJECT_SIZE, MAX_PARTS_COUNT and MIN_PART_SIZE, and its write-only storage_class field. - mark_started_rebalance_pools_stopped and the RStats alias. Two deletions were withdrawn after a per-name grep, both because of an inference rather than a check: AsyncBatchProcessor::new was deleted on the strength of grepping only BATCH_PROCESSOR_OPERATION_CUSTOM, whose two hits are its definition and its use inside new. That looked like a self-contained dead pair; new in fact has seven test callers. The warning listed both items, and only one of them was actually checked. Deleting the two dead publish wrappers then revealed a second layer — publish_candidate_owned, remove_and_save_with, clear_and_save_with, save_tiering_config_if_current. These are not dead: publish_candidate, their caller, is #[cfg(test)], so a callee that lives in the main body has no caller in the lib build and a live one in the test build. rustc reports the roots of a dead subgraph, and the next layer down can have a different character, so each layer needs its own grep. Kept with allows: the tier mutation-intent record helpers (asserted by store::init tests), affected_targets, tier_object_blocks_target_rebind, the rebalance snapshot and retry-wait helpers, notification_sys's tier_config_reload_worker_active and call_peer_with_timeout, and active_operation_lease_count, whose only caller sits behind #[cfg(feature = "test-util")]. Also kept, with a module note rather than removal: the ecstore-side EventNotifier. All four of its methods are unreachable and init_bucket_targets logs that it is a no-op in this build; the working stack is rustfs-notify, whose own EventNotifier drives bucket configuration. Removing it means also retiring the InstanceContext slot that holds it (backlog#939 Phase 5), which belongs in its own PR. Worth a separate issue: MAX_MULTIPART_PUT_OBJECT_SIZE, MAX_PARTS_COUNT and MIN_PART_SIZE are declared independently in eight warm-backend files plus client/constants.rs. Only the GCS copies were dead; the other seven backends each use their own. Verification, four lanes warning-free: default, --tests, --features rio-v2 --tests, --features test-util --tests. cargo nextest run -p rustfs-ecstore 4041 passed; clippy --lib --tests -D warnings clean; make pre-commit exit 0. Ref rustfs/backlog#1823 (step 2).
859 lines
30 KiB
Rust
859 lines
30 KiB
Rust
// 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<BatchProcessorAdaptiveMode> =
|
|
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<Instant>,
|
|
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<BatchObservationState>,
|
|
}
|
|
|
|
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<T, F>(&self, tasks: Vec<F>) -> Vec<Result<T>>
|
|
where
|
|
T: Send + 'static,
|
|
F: Future<Output = Result<T>> + 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<T>, 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<T, F>(&self, tasks: Vec<F>, required_successes: usize) -> Result<Vec<T>>
|
|
where
|
|
T: Send + 'static,
|
|
F: Future<Output = Result<T>> + 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<GlobalBatchProcessors> = 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::<i32, Error>(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::<i32, Error>(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::<i32, Error>(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::<i32, Error>(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);
|
|
}
|
|
}
|