mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-07 22:03:14 +00:00
3f13d098b4
Merge the hotpath-rs wall-time instrumentation from the backlog#936 analysis worktree behind an opt-in 'hotpath' cargo feature, keeping the default build at zero overhead and zero dependency. - hotpath is an optional dependency everywhere (dep:hotpath feature syntax); the default dependency tree contains no hotpath crate at all - 40+ measurement points across S3 handlers, ECStore/SetDisks object and multipart ops, erasure encode/decode, bitrot, LocalDisk I/O, FileMeta codec, and HashReader - attribute sites use #[cfg_attr(feature = "hotpath", hotpath::measure)]; async_trait bodies use per-crate hp_guard! macros (ecstore + rustfs bin); rio gates measure_block! behind hp_measure_block! - feature chain: rustfs -> rustfs-ecstore -> rustfs-rio / rustfs-filemeta, each crate owning its own gate - hotpath-alloc is intentionally not wired up (hotpath 0.21.x TLS panic on cross-thread guard drop under tokio, see backlog#935); mimalloc stays the unconditional global allocator - docs/development/hotpath-profiling.md documents building, HOTPATH_* env vars, SIGTERM report flow, and how to reproduce the backlog#936 timing reports Refs: https://github.com/rustfs/backlog/issues/935 (HP-14, item 2), https://github.com/rustfs/backlog/issues/936 Co-authored-by: heihutu <heihutu@gmail.com>
3025 lines
120 KiB
Rust
3025 lines
120 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.
|
|
|
|
use crate::diagnostics::get::{
|
|
GET_OBJECT_PATH_LEGACY_DUPLEX, GET_SHARD_READ_ERROR_MISSING, GET_SHARD_READ_ERROR_NONE, GET_SHARD_READ_OUTCOME_ERROR,
|
|
GET_SHARD_READ_OUTCOME_MISSING, GET_SHARD_READ_OUTCOME_SUCCESS, GET_SHARD_ROLE_DATA, GET_SHARD_ROLE_PARITY, GET_STAGE_EMIT,
|
|
GET_STAGE_RANGE, GET_STAGE_RECONSTRUCT, GET_STAGE_STRIPE_READ, GET_STAGE_STRIPE_READ_FIRST_SHARD,
|
|
GET_STAGE_STRIPE_READ_QUORUM, GetObjectFailureReason, classify_io_error, get_stage_timer_if_enabled,
|
|
record_get_object_pipeline_failure, record_get_stage_duration_if_enabled,
|
|
};
|
|
use crate::disk::disk_store::get_object_disk_read_timeout;
|
|
use crate::disk::error::Error;
|
|
use crate::disk::error_reduce::reduce_errs;
|
|
use crate::erasure::codec::workspace::ShardBufferPool;
|
|
use crate::erasure::coding::{BitrotReader, Erasure};
|
|
use crate::io_support::bitrot::DeferredReaderStripeHandle;
|
|
use crate::set_disk::shard_source::{ShardReadCost, ShardStripeSource, StripeReadState};
|
|
use futures::FutureExt;
|
|
use futures::stream::{FuturesUnordered, StreamExt};
|
|
use pin_project_lite::pin_project;
|
|
use std::future::Future;
|
|
use std::io;
|
|
use std::io::ErrorKind;
|
|
use std::pin::Pin;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::io::AsyncRead;
|
|
use tokio::io::AsyncWrite;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tracing::{error, warn};
|
|
|
|
type ShardReadFuture<'a> = Pin<Box<dyn Future<Output = (usize, ShardReadCost, Result<Vec<u8>, Error>, bool)> + Send + 'a>>;
|
|
|
|
const ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING: &str = "RUSTFS_SHARD_LOCALITY_SCHEDULING";
|
|
const ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE: &str = "RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE";
|
|
const SHARD_LOCALITY_SCHEDULING_OFF: &str = "off";
|
|
const SHARD_LOCALITY_SCHEDULING_OBSERVE: &str = "observe";
|
|
const SHARD_LOCALITY_SCHEDULING_ON: &str = "on";
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ShardLocalitySchedulingMode {
|
|
Off,
|
|
Observe,
|
|
On,
|
|
}
|
|
|
|
impl ShardLocalitySchedulingMode {
|
|
fn is_on(self) -> bool {
|
|
matches!(self, ShardLocalitySchedulingMode::On)
|
|
}
|
|
}
|
|
|
|
fn parse_shard_locality_scheduling_mode(value: &str) -> ShardLocalitySchedulingMode {
|
|
match value.trim() {
|
|
value if value.eq_ignore_ascii_case(SHARD_LOCALITY_SCHEDULING_OFF) => ShardLocalitySchedulingMode::Off,
|
|
value if value.eq_ignore_ascii_case(SHARD_LOCALITY_SCHEDULING_OBSERVE) => ShardLocalitySchedulingMode::Observe,
|
|
value if value.eq_ignore_ascii_case(SHARD_LOCALITY_SCHEDULING_ON) => ShardLocalitySchedulingMode::On,
|
|
_ => ShardLocalitySchedulingMode::Off,
|
|
}
|
|
}
|
|
|
|
fn get_shard_locality_scheduling_mode() -> ShardLocalitySchedulingMode {
|
|
if let Some(value) = rustfs_utils::get_env_opt_str(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING) {
|
|
return parse_shard_locality_scheduling_mode(&value);
|
|
}
|
|
|
|
if rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, false) {
|
|
return ShardLocalitySchedulingMode::On;
|
|
}
|
|
|
|
ShardLocalitySchedulingMode::Off
|
|
}
|
|
|
|
fn get_shard_locality_preference_enabled() -> bool {
|
|
get_shard_locality_scheduling_mode().is_on()
|
|
}
|
|
|
|
pub(crate) fn should_collect_shard_read_costs() -> bool {
|
|
// `observe` mode only feeds the stage-metrics histograms, so skip the
|
|
// cost-collection overhead when those metrics cannot be reported anyway.
|
|
rustfs_io_metrics::get_stage_metrics_enabled() || get_shard_locality_scheduling_mode().is_on()
|
|
}
|
|
|
|
/// Number of stripes to prefetch in the legacy decode path.
|
|
/// When > 1, stripe reads are batched to overlap disk I/O with decode.
|
|
/// Default: 1 (no prefetch, current behavior).
|
|
/// Set via environment variable RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT.
|
|
const ENV_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT: &str = "RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT";
|
|
const DEFAULT_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT: usize = 1;
|
|
|
|
/// Get the stripe prefetch count from environment variable.
|
|
fn get_decode_stripe_prefetch_count() -> usize {
|
|
rustfs_utils::get_env_usize(
|
|
ENV_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT,
|
|
DEFAULT_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT,
|
|
)
|
|
}
|
|
|
|
/// Enable overlapping bitrot verification with stripe decode.
|
|
/// When enabled, bitrot verification for stripe N+1 runs concurrently
|
|
/// with decode of stripe N, reducing total pipeline latency.
|
|
/// Default: false (sequential behavior, current implementation).
|
|
const ENV_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE: &str = "RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE";
|
|
const DEFAULT_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE: bool = false;
|
|
|
|
/// Enable the data-shards-only lockstep GET read (backlog#923).
|
|
/// When enabled, the reconstruction-verifying (lockstep) GET path reads only
|
|
/// the data shards per stripe while the object is healthy; parity slots stay
|
|
/// unopened deferred readers that are engaged — realigned to the failing
|
|
/// stripe via their stripe handle — only when a data shard is missing or dies.
|
|
/// This halves per-GET read bytes, IOPS, and bitrot hashing on healthy
|
|
/// objects with 2+2 layouts.
|
|
/// Default: false (current behavior: every live shard reader is read on every
|
|
/// stripe).
|
|
const ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE: &str = "RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE";
|
|
const DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE: bool = false;
|
|
|
|
/// Whether the data-shards-only lockstep GET read is enabled (backlog#923).
|
|
pub(crate) fn get_lockstep_data_shards_only_enabled() -> bool {
|
|
rustfs_utils::get_env_bool(
|
|
ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE,
|
|
DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE,
|
|
)
|
|
}
|
|
|
|
/// Get whether bitrot-decode overlap is enabled.
|
|
fn is_bitrot_decode_overlap_enabled() -> bool {
|
|
rustfs_utils::get_env_bool(
|
|
ENV_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE,
|
|
DEFAULT_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE,
|
|
)
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ShardReadCostCounts {
|
|
local: usize,
|
|
same_node: usize,
|
|
remote: usize,
|
|
unknown: usize,
|
|
}
|
|
|
|
impl ShardReadCostCounts {
|
|
fn record(&mut self, cost: ShardReadCost) {
|
|
match cost {
|
|
ShardReadCost::Local => self.local += 1,
|
|
ShardReadCost::SameNode => self.same_node += 1,
|
|
ShardReadCost::Remote => self.remote += 1,
|
|
ShardReadCost::Unknown => self.unknown += 1,
|
|
}
|
|
}
|
|
|
|
fn low_cost(&self) -> usize {
|
|
self.local + self.same_node
|
|
}
|
|
}
|
|
fn shard_read_launch_rank(cost: ShardReadCost) -> u8 {
|
|
match cost {
|
|
ShardReadCost::Local => 0,
|
|
ShardReadCost::SameNode => 1,
|
|
ShardReadCost::Remote => 2,
|
|
ShardReadCost::Unknown => 3,
|
|
}
|
|
}
|
|
|
|
fn shard_read_launch_order(read_costs: &[ShardReadCost], num_readers: usize, locality_preference_enabled: bool) -> Vec<usize> {
|
|
let mut order: Vec<usize> = (0..num_readers).collect();
|
|
if locality_preference_enabled {
|
|
order.sort_by_key(|index| {
|
|
(
|
|
shard_read_launch_rank(read_costs.get(*index).copied().unwrap_or(ShardReadCost::Unknown)),
|
|
*index,
|
|
)
|
|
});
|
|
}
|
|
order
|
|
}
|
|
|
|
enum ReaderLaunchIter<'a, R> {
|
|
Original(std::iter::Enumerate<std::slice::IterMut<'a, Option<BitrotReader<R>>>>),
|
|
Locality(std::vec::IntoIter<(usize, &'a mut Option<BitrotReader<R>>)>),
|
|
}
|
|
|
|
impl<'a, R> ReaderLaunchIter<'a, R> {
|
|
fn new(readers: &'a mut [Option<BitrotReader<R>>], read_costs: &[ShardReadCost], locality_preference_enabled: bool) -> Self {
|
|
if !locality_preference_enabled {
|
|
return Self::Original(readers.iter_mut().enumerate());
|
|
}
|
|
|
|
let mut ordered_readers: Vec<_> = readers.iter_mut().enumerate().collect();
|
|
ordered_readers.sort_by_key(|(index, _)| {
|
|
(
|
|
shard_read_launch_rank(read_costs.get(*index).copied().unwrap_or(ShardReadCost::Unknown)),
|
|
*index,
|
|
)
|
|
});
|
|
Self::Locality(ordered_readers.into_iter())
|
|
}
|
|
}
|
|
|
|
impl<'a, R> Iterator for ReaderLaunchIter<'a, R> {
|
|
type Item = (usize, &'a mut Option<BitrotReader<R>>);
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
match self {
|
|
Self::Original(iter) => iter.next(),
|
|
Self::Locality(iter) => iter.next(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn shard_role(index: usize, data_shards: usize) -> &'static str {
|
|
if index < data_shards {
|
|
GET_SHARD_ROLE_DATA
|
|
} else {
|
|
GET_SHARD_ROLE_PARITY
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn read_shard<'a, R>(
|
|
index: usize,
|
|
read_cost: ShardReadCost,
|
|
reader: &'a mut Option<BitrotReader<R>>,
|
|
recycled_buf: Option<Vec<u8>>,
|
|
shard_size: usize,
|
|
data_shards: usize,
|
|
read_timeout: Duration,
|
|
metrics_path: Option<&'static str>,
|
|
) -> ShardReadFuture<'a>
|
|
where
|
|
R: AsyncRead + Unpin + Send + Sync + 'a,
|
|
{
|
|
let role = shard_role(index, data_shards);
|
|
if let Some(reader) = reader {
|
|
Box::pin(async move {
|
|
let mut buf = recycled_buf.unwrap_or_else(|| vec![0; shard_size]);
|
|
debug_assert_eq!(buf.len(), shard_size);
|
|
let read_start = metrics_path.map(|_| Instant::now());
|
|
let read_result = if read_timeout.is_zero() {
|
|
reader.read(&mut buf).await
|
|
} else {
|
|
match tokio::time::timeout(read_timeout, reader.read(&mut buf)).await {
|
|
Ok(result) => result,
|
|
Err(_) => {
|
|
let timeout_error = io::Error::new(ErrorKind::TimedOut, "shard read timed out");
|
|
let error_class = classify_io_error(&timeout_error).as_str();
|
|
if let Some(path) = metrics_path {
|
|
rustfs_io_metrics::record_get_object_shard_read_observation(
|
|
path,
|
|
index,
|
|
role,
|
|
read_cost.as_str(),
|
|
GET_SHARD_READ_OUTCOME_ERROR,
|
|
error_class,
|
|
0,
|
|
read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()),
|
|
reader.last_verify_duration().as_secs_f64(),
|
|
);
|
|
}
|
|
return (index, read_cost, Err(Error::from(timeout_error)), true);
|
|
}
|
|
}
|
|
};
|
|
|
|
match read_result {
|
|
Ok(n) => {
|
|
buf.truncate(n);
|
|
if let Some(path) = metrics_path {
|
|
rustfs_io_metrics::record_get_object_shard_read_observation(
|
|
path,
|
|
index,
|
|
role,
|
|
read_cost.as_str(),
|
|
GET_SHARD_READ_OUTCOME_SUCCESS,
|
|
GET_SHARD_READ_ERROR_NONE,
|
|
n,
|
|
read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()),
|
|
reader.last_verify_duration().as_secs_f64(),
|
|
);
|
|
}
|
|
(index, read_cost, Ok(buf), false)
|
|
}
|
|
Err(e) => {
|
|
let verify_duration_secs = reader.last_verify_duration().as_secs_f64();
|
|
let error_class = classify_io_error(&e).as_str();
|
|
let should_retire = e.kind() == ErrorKind::TimedOut;
|
|
if let Some(path) = metrics_path {
|
|
rustfs_io_metrics::record_get_object_shard_read_observation(
|
|
path,
|
|
index,
|
|
role,
|
|
read_cost.as_str(),
|
|
GET_SHARD_READ_OUTCOME_ERROR,
|
|
error_class,
|
|
0,
|
|
read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()),
|
|
verify_duration_secs,
|
|
);
|
|
}
|
|
(index, read_cost, Err(Error::from(e)), should_retire)
|
|
}
|
|
}
|
|
})
|
|
} else {
|
|
Box::pin(async move {
|
|
if let Some(path) = metrics_path {
|
|
rustfs_io_metrics::record_get_object_shard_read_observation(
|
|
path,
|
|
index,
|
|
role,
|
|
read_cost.as_str(),
|
|
GET_SHARD_READ_OUTCOME_MISSING,
|
|
GET_SHARD_READ_ERROR_MISSING,
|
|
0,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
}
|
|
(index, read_cost, Err(Error::FileNotFound), false)
|
|
})
|
|
}
|
|
}
|
|
|
|
pin_project! {
|
|
pub(crate) struct ParallelReader<R> {
|
|
#[pin]
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
offset: usize,
|
|
shard_size: usize,
|
|
shard_file_size: usize,
|
|
data_shards: usize,
|
|
total_shards: usize,
|
|
metrics_path: Option<&'static str>,
|
|
read_costs: Vec<ShardReadCost>,
|
|
read_timeout: Duration,
|
|
verify_reconstruction: bool,
|
|
locality_preference_enabled: bool,
|
|
// Request-scoped shard buffers keyed by shard index. Keeping ownership in
|
|
// `ParallelReader` avoids dropping unused parity/backup slot buffers between stripes.
|
|
buffers: ShardBufferPool,
|
|
// Lockstep-path state (verify_reconstruction == true). `engaged[i]` marks
|
|
// readers that participate in each stripe read: all data slots from the
|
|
// start, parity slots only once a data shard is missing/dead. Unengaged
|
|
// parity stays an unopened deferred reader; `deferred_handles[i]` realigns
|
|
// it to the current stripe when it is engaged mid-object (backlog#923).
|
|
engaged: Vec<bool>,
|
|
deferred_handles: Vec<Option<DeferredReaderStripeHandle>>,
|
|
stripe_index: usize,
|
|
}
|
|
}
|
|
|
|
impl<R> ParallelReader<R>
|
|
where
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
// Readers should handle disk errors before being passed in, ensuring each reader reaches the available number of BitrotReaders
|
|
pub fn new(readers: Vec<Option<BitrotReader<R>>>, e: Erasure, offset: usize, total_length: usize) -> Self {
|
|
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
None,
|
|
get_object_disk_read_timeout(),
|
|
false,
|
|
)
|
|
}
|
|
|
|
pub fn new_with_metrics_path(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
) -> Self {
|
|
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
metrics_path,
|
|
get_object_disk_read_timeout(),
|
|
false,
|
|
)
|
|
}
|
|
|
|
pub fn new_with_metrics_path_and_read_costs(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
read_costs: Vec<ShardReadCost>,
|
|
) -> Self {
|
|
Self::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
metrics_path,
|
|
read_costs,
|
|
get_object_disk_read_timeout(),
|
|
false,
|
|
)
|
|
}
|
|
|
|
fn new_for_decode(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
) -> Self {
|
|
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
metrics_path,
|
|
get_object_disk_read_timeout(),
|
|
true,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn new_with_metrics_path_and_reconstruction_verification(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
) -> Self {
|
|
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
metrics_path,
|
|
get_object_disk_read_timeout(),
|
|
true,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn new_with_metrics_path_read_costs_and_reconstruction_verification(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
read_costs: Vec<ShardReadCost>,
|
|
) -> Self {
|
|
Self::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
metrics_path,
|
|
read_costs,
|
|
get_object_disk_read_timeout(),
|
|
true,
|
|
)
|
|
}
|
|
|
|
fn new_with_read_timeout(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
read_timeout: Duration,
|
|
) -> Self {
|
|
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
None,
|
|
read_timeout,
|
|
false,
|
|
)
|
|
}
|
|
|
|
fn new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
read_timeout: Duration,
|
|
verify_reconstruction: bool,
|
|
) -> Self {
|
|
let read_costs = vec![ShardReadCost::Unknown; readers.len()];
|
|
Self::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
|
readers,
|
|
e,
|
|
offset,
|
|
total_length,
|
|
metrics_path,
|
|
read_costs,
|
|
read_timeout,
|
|
verify_reconstruction,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
e: Erasure,
|
|
offset: usize,
|
|
total_length: usize,
|
|
metrics_path: Option<&'static str>,
|
|
mut read_costs: Vec<ShardReadCost>,
|
|
read_timeout: Duration,
|
|
verify_reconstruction: bool,
|
|
) -> Self {
|
|
let metrics_path = metrics_path.filter(|_| rustfs_io_metrics::get_stage_metrics_enabled());
|
|
let shard_size = e.shard_size();
|
|
let shard_file_size = e.shard_file_size(total_length as i64) as usize;
|
|
|
|
let offset = (offset / e.block_size) * shard_size;
|
|
read_costs.resize(readers.len(), ShardReadCost::Unknown);
|
|
read_costs.truncate(readers.len());
|
|
|
|
// Ensure offset does not exceed shard_file_size
|
|
|
|
// Default (gate off): every slot is engaged, i.e. the lockstep path
|
|
// reads all live readers on every stripe — the pre-backlog#923
|
|
// behavior. With the gate on, only data slots start engaged; parity is
|
|
// engaged on demand, stripe-aligned through its deferred handle.
|
|
let data_shards_only = get_lockstep_data_shards_only_enabled();
|
|
let engaged = (0..readers.len())
|
|
.map(|index| !data_shards_only || index < e.data_shards)
|
|
.collect();
|
|
ParallelReader {
|
|
readers,
|
|
offset,
|
|
shard_size,
|
|
shard_file_size,
|
|
data_shards: e.data_shards,
|
|
total_shards: e.data_shards + e.parity_shards,
|
|
metrics_path,
|
|
read_costs,
|
|
read_timeout,
|
|
verify_reconstruction,
|
|
locality_preference_enabled: get_shard_locality_preference_enabled(),
|
|
buffers: ShardBufferPool::new(e.data_shards + e.parity_shards),
|
|
engaged,
|
|
deferred_handles: Vec::new(),
|
|
stripe_index: 0,
|
|
}
|
|
}
|
|
|
|
/// Attach the per-slot deferred stripe handles produced during bitrot
|
|
/// reader setup. Only parity slots are consulted: a handle lets the
|
|
/// lockstep path open a parity shard aligned to the stripe where a data
|
|
/// shard failed, instead of reading every parity shard on every stripe
|
|
/// (backlog#923).
|
|
pub(crate) fn with_deferred_parity_handles(mut self, mut handles: Vec<Option<DeferredReaderStripeHandle>>) -> Self {
|
|
handles.resize_with(self.readers.len(), || None);
|
|
handles.truncate(self.readers.len());
|
|
self.deferred_handles = handles;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn record_shard_read_result(
|
|
shards: &mut [Option<Vec<u8>>],
|
|
errs: &mut [Option<Error>],
|
|
retire_readers: &mut Vec<usize>,
|
|
success: &mut usize,
|
|
successful_costs: &mut ShardReadCostCounts,
|
|
i: usize,
|
|
read_cost: ShardReadCost,
|
|
result: Result<Vec<u8>, Error>,
|
|
should_retire: bool,
|
|
) -> bool {
|
|
match result {
|
|
Ok(v) => {
|
|
shards[i] = Some(v);
|
|
successful_costs.record(read_cost);
|
|
*success += 1;
|
|
false
|
|
}
|
|
Err(e) => {
|
|
errs[i] = Some(e);
|
|
if should_retire {
|
|
retire_readers.push(i);
|
|
}
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
fn retire_abandoned_readers(errs: &mut [Option<Error>], retire_readers: &mut Vec<usize>, active_readers: &[bool]) {
|
|
for (i, active) in active_readers.iter().enumerate() {
|
|
if !*active {
|
|
continue;
|
|
}
|
|
|
|
if errs[i].is_none() {
|
|
errs[i] = Some(Error::from(io::Error::new(ErrorKind::TimedOut, "shard read abandoned after read quorum")));
|
|
}
|
|
retire_readers.push(i);
|
|
warn!(shard_index = i, "retiring in-flight shard reader after read quorum");
|
|
}
|
|
}
|
|
|
|
fn shard_read_hedge_delay(read_timeout: Duration) -> Option<Duration> {
|
|
if read_timeout.is_zero() {
|
|
None
|
|
} else {
|
|
Some(read_timeout.min(Duration::from_millis(100)))
|
|
}
|
|
}
|
|
|
|
fn shard_locality_remote_avoid_potential(remote_scheduled: usize, low_cost_available: usize, data_shards: usize) -> usize {
|
|
let theoretical_remote_needed = data_shards.saturating_sub(low_cost_available);
|
|
remote_scheduled.saturating_sub(theoretical_remote_needed)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn record_scheduled_read_cost(
|
|
read_cost: ShardReadCost,
|
|
locality_preference_enabled: bool,
|
|
low_cost_available: usize,
|
|
data_shards: usize,
|
|
count_remote_as_fallback: bool,
|
|
local_preferred: &mut usize,
|
|
remote_scheduled: &mut usize,
|
|
fallback_to_remote: &mut usize,
|
|
) {
|
|
if read_cost.is_low_cost() {
|
|
if locality_preference_enabled {
|
|
*local_preferred += 1;
|
|
}
|
|
} else if read_cost.is_remote() {
|
|
*remote_scheduled += 1;
|
|
if locality_preference_enabled && (count_remote_as_fallback || low_cost_available < data_shards) {
|
|
*fallback_to_remote += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<R> ParallelReader<R>
|
|
where
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
|
pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
|
// On the reconstruction-verifying GET path, read every live shard reader
|
|
// in lockstep so all readers advance one block per stripe and stay
|
|
// mutually aligned. The adaptive data-first path below only reads
|
|
// `data_shards` readers per stripe and pulls in a parity reader as a
|
|
// substitute on demand; a parity reader first used mid-object is still
|
|
// positioned at its stream start (block 0) and returns an earlier stripe
|
|
// than the data shards, producing "inconsistent read source shards" and
|
|
// truncating large-object GETs under concurrency (backlog#832).
|
|
if self.verify_reconstruction {
|
|
return self.read_lockstep().await;
|
|
}
|
|
// if self.readers.len() != self.total_shards {
|
|
// return Err(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers"));
|
|
// }
|
|
let num_readers = self.readers.len();
|
|
|
|
let shard_size = if self.offset + self.shard_size > self.shard_file_size {
|
|
self.shard_file_size - self.offset
|
|
} else {
|
|
self.shard_size
|
|
};
|
|
|
|
if shard_size == 0 {
|
|
return (vec![None; num_readers], vec![None; num_readers]);
|
|
}
|
|
|
|
// Advance to the next stripe so the following read() computes the correct
|
|
// (possibly shorter, final) stripe length. `offset` previously never
|
|
// advanced, so every stripe after the first reused the first stripe's
|
|
// geometry; combined with BitrotReader now rejecting short reads, the
|
|
// per-stripe expected length must be exact (backlog#799 B2). `self.offset`
|
|
// is only read above to derive `shard_size`, so advancing here is safe.
|
|
self.offset += shard_size;
|
|
|
|
let mut shards: Vec<Option<Vec<u8>>> = vec![None; num_readers];
|
|
let mut errs = vec![None; num_readers];
|
|
let read_costs = self.read_costs.as_slice();
|
|
let locality_preference_enabled = self.locality_preference_enabled;
|
|
let low_cost_available = self
|
|
.readers
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(index, reader)| {
|
|
reader.is_some()
|
|
&& read_costs
|
|
.get(*index)
|
|
.copied()
|
|
.unwrap_or(ShardReadCost::Unknown)
|
|
.is_low_cost()
|
|
})
|
|
.count();
|
|
let remote_available = self
|
|
.readers
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(index, reader)| {
|
|
reader.is_some() && read_costs.get(*index).copied().unwrap_or(ShardReadCost::Unknown).is_remote()
|
|
})
|
|
.count();
|
|
let mut successful_costs = ShardReadCostCounts::default();
|
|
let mut local_preferred = 0usize;
|
|
let mut fallback_to_remote = 0usize;
|
|
let mut remote_scheduled = 0usize;
|
|
|
|
self.buffers.ensure_slots(num_readers);
|
|
|
|
let mut retire_readers = Vec::new();
|
|
let mut unavailable_data_sources = self
|
|
.readers
|
|
.iter()
|
|
.take(self.data_shards)
|
|
.map(|reader| reader.is_none())
|
|
.collect::<Vec<_>>();
|
|
if num_readers >= self.data_shards {
|
|
let mut reader_iter = ReaderLaunchIter::new(&mut self.readers, read_costs, locality_preference_enabled);
|
|
let mut sets = FuturesUnordered::new();
|
|
let mut active_readers = vec![false; num_readers];
|
|
let stripe_read_start = self.metrics_path.map(|_| Instant::now());
|
|
let mut scheduled = 0usize;
|
|
for _ in 0..self.data_shards {
|
|
if let Some((i, reader)) = reader_iter.next() {
|
|
let has_reader = reader.is_some();
|
|
// Only claim a request-scoped buffer when a shard will actually be read.
|
|
let recycled_buf = if has_reader {
|
|
Some(self.buffers.take(i, shard_size))
|
|
} else {
|
|
None
|
|
};
|
|
let read_cost = read_costs.get(i).copied().unwrap_or(ShardReadCost::Unknown);
|
|
record_scheduled_read_cost(
|
|
read_cost,
|
|
locality_preference_enabled,
|
|
low_cost_available,
|
|
self.data_shards,
|
|
false,
|
|
&mut local_preferred,
|
|
&mut remote_scheduled,
|
|
&mut fallback_to_remote,
|
|
);
|
|
scheduled += 1;
|
|
active_readers[i] = has_reader;
|
|
sets.push(read_shard(
|
|
i,
|
|
read_cost,
|
|
reader,
|
|
recycled_buf,
|
|
shard_size,
|
|
self.data_shards,
|
|
self.read_timeout,
|
|
self.metrics_path,
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut success = 0;
|
|
let mut completed = 0usize;
|
|
let mut failed = 0usize;
|
|
let mut first_shard_recorded = false;
|
|
let mut pending = sets.len();
|
|
let mut scheduled_all = false;
|
|
let verification_success_target = self.total_shards.min(self.data_shards + 1);
|
|
let parity_shards = self.total_shards.saturating_sub(self.data_shards);
|
|
loop {
|
|
let item = if !scheduled_all {
|
|
match shard_read_hedge_delay(self.read_timeout) {
|
|
Some(hedge_delay) => {
|
|
let hedge_sleep = tokio::time::sleep(hedge_delay);
|
|
tokio::pin!(hedge_sleep);
|
|
tokio::select! {
|
|
item = sets.next() => item,
|
|
_ = &mut hedge_sleep => {
|
|
if let Some((next_i, next_reader)) = reader_iter.next() {
|
|
let has_reader = next_reader.is_some();
|
|
let recycled_buf = if has_reader {
|
|
Some(self.buffers.take(next_i, shard_size))
|
|
} else {
|
|
None
|
|
};
|
|
let next_read_cost = read_costs.get(next_i).copied().unwrap_or(ShardReadCost::Unknown);
|
|
record_scheduled_read_cost(
|
|
next_read_cost,
|
|
locality_preference_enabled,
|
|
low_cost_available,
|
|
self.data_shards,
|
|
true,
|
|
&mut local_preferred,
|
|
&mut remote_scheduled,
|
|
&mut fallback_to_remote,
|
|
);
|
|
active_readers[next_i] = has_reader;
|
|
scheduled += 1;
|
|
pending += 1;
|
|
sets.push(read_shard(
|
|
next_i,
|
|
next_read_cost,
|
|
next_reader,
|
|
recycled_buf,
|
|
shard_size,
|
|
self.data_shards,
|
|
self.read_timeout,
|
|
self.metrics_path,
|
|
));
|
|
} else {
|
|
scheduled_all = true;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
None => sets.next().await,
|
|
}
|
|
} else {
|
|
sets.next().await
|
|
};
|
|
|
|
let Some((i, read_cost, result, should_retire)) = item else {
|
|
break;
|
|
};
|
|
|
|
pending = pending.saturating_sub(1);
|
|
active_readers[i] = false;
|
|
completed += 1;
|
|
if !first_shard_recorded {
|
|
if let Some(path) = self.metrics_path {
|
|
record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_FIRST_SHARD, stripe_read_start);
|
|
}
|
|
first_shard_recorded = true;
|
|
}
|
|
|
|
let result_is_err = record_shard_read_result(
|
|
&mut shards,
|
|
&mut errs,
|
|
&mut retire_readers,
|
|
&mut success,
|
|
&mut successful_costs,
|
|
i,
|
|
read_cost,
|
|
result,
|
|
should_retire,
|
|
);
|
|
if self.verify_reconstruction && i < self.data_shards && result_is_err {
|
|
unavailable_data_sources[i] = true;
|
|
}
|
|
if result_is_err {
|
|
failed += 1;
|
|
if let Some((next_i, next_reader)) = reader_iter.next() {
|
|
let has_reader = next_reader.is_some();
|
|
let recycled_buf = if has_reader {
|
|
Some(self.buffers.take(next_i, shard_size))
|
|
} else {
|
|
None
|
|
};
|
|
let next_read_cost = read_costs.get(next_i).copied().unwrap_or(ShardReadCost::Unknown);
|
|
record_scheduled_read_cost(
|
|
next_read_cost,
|
|
locality_preference_enabled,
|
|
low_cost_available,
|
|
self.data_shards,
|
|
true,
|
|
&mut local_preferred,
|
|
&mut remote_scheduled,
|
|
&mut fallback_to_remote,
|
|
);
|
|
scheduled += 1;
|
|
active_readers[next_i] = has_reader;
|
|
pending += 1;
|
|
sets.push(read_shard(
|
|
next_i,
|
|
next_read_cost,
|
|
next_reader,
|
|
recycled_buf,
|
|
shard_size,
|
|
self.data_shards,
|
|
self.read_timeout,
|
|
self.metrics_path,
|
|
));
|
|
} else {
|
|
scheduled_all = true;
|
|
}
|
|
}
|
|
|
|
let mut missing_data_sources = unavailable_data_sources.iter().filter(|missing| **missing).count();
|
|
if self.verify_reconstruction && success >= self.data_shards {
|
|
for (idx, active) in active_readers.iter().take(self.data_shards).enumerate() {
|
|
if *active && !unavailable_data_sources[idx] {
|
|
missing_data_sources += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let needs_reconstruction_verification = self.verify_reconstruction
|
|
&& verification_success_target > self.data_shards
|
|
&& missing_data_sources > 0
|
|
&& missing_data_sources < parity_shards;
|
|
|
|
let target_success = if needs_reconstruction_verification {
|
|
verification_success_target
|
|
} else {
|
|
self.data_shards
|
|
};
|
|
|
|
while success + pending < target_success {
|
|
if let Some((next_i, next_reader)) = reader_iter.next() {
|
|
let has_reader = next_reader.is_some();
|
|
let recycled_buf = if has_reader {
|
|
Some(self.buffers.take(next_i, shard_size))
|
|
} else {
|
|
None
|
|
};
|
|
let next_read_cost = read_costs.get(next_i).copied().unwrap_or(ShardReadCost::Unknown);
|
|
record_scheduled_read_cost(
|
|
next_read_cost,
|
|
locality_preference_enabled,
|
|
low_cost_available,
|
|
self.data_shards,
|
|
true,
|
|
&mut local_preferred,
|
|
&mut remote_scheduled,
|
|
&mut fallback_to_remote,
|
|
);
|
|
scheduled += 1;
|
|
active_readers[next_i] = has_reader;
|
|
pending += 1;
|
|
sets.push(read_shard(
|
|
next_i,
|
|
next_read_cost,
|
|
next_reader,
|
|
recycled_buf,
|
|
shard_size,
|
|
self.data_shards,
|
|
self.read_timeout,
|
|
self.metrics_path,
|
|
));
|
|
} else {
|
|
scheduled_all = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if success >= target_success {
|
|
break;
|
|
}
|
|
|
|
if success >= self.data_shards && (!needs_reconstruction_verification || success + pending < target_success) {
|
|
break;
|
|
}
|
|
|
|
if success + pending < target_success {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if success >= self.data_shards {
|
|
while let Some(Some((i, read_cost, result, should_retire))) = sets.next().now_or_never() {
|
|
active_readers[i] = false;
|
|
completed += 1;
|
|
if record_shard_read_result(
|
|
&mut shards,
|
|
&mut errs,
|
|
&mut retire_readers,
|
|
&mut success,
|
|
&mut successful_costs,
|
|
i,
|
|
read_cost,
|
|
result,
|
|
should_retire,
|
|
) {
|
|
failed += 1;
|
|
}
|
|
}
|
|
retire_abandoned_readers(&mut errs, &mut retire_readers, &active_readers);
|
|
}
|
|
|
|
if let Some(path) = self.metrics_path {
|
|
record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_QUORUM, stripe_read_start);
|
|
rustfs_io_metrics::record_get_object_shard_read_fanout(path, scheduled, completed, success, failed);
|
|
if locality_preference_enabled {
|
|
let remote_avoided = remote_available.saturating_sub(remote_scheduled);
|
|
rustfs_io_metrics::record_get_object_shard_locality_policy(
|
|
path,
|
|
local_preferred,
|
|
remote_avoided,
|
|
fallback_to_remote,
|
|
);
|
|
} else {
|
|
let remote_avoid_potential =
|
|
shard_locality_remote_avoid_potential(remote_scheduled, low_cost_available, self.data_shards);
|
|
rustfs_io_metrics::record_get_object_shard_locality_observe_only(
|
|
path,
|
|
remote_scheduled,
|
|
remote_avoid_potential,
|
|
);
|
|
rustfs_io_metrics::record_get_object_shard_locality_policy_disabled(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(path) = self.metrics_path {
|
|
rustfs_io_metrics::record_get_object_shard_read_cost_summary(
|
|
path,
|
|
successful_costs.local,
|
|
successful_costs.same_node,
|
|
successful_costs.remote,
|
|
successful_costs.unknown,
|
|
low_cost_available,
|
|
successful_costs.low_cost(),
|
|
self.data_shards,
|
|
low_cost_available >= self.data_shards,
|
|
);
|
|
}
|
|
|
|
for i in retire_readers {
|
|
self.readers[i] = None;
|
|
}
|
|
|
|
(shards, errs)
|
|
}
|
|
|
|
/// Lockstep stripe read for the reconstruction-verifying GET path.
|
|
///
|
|
/// Reads every *engaged* shard reader exactly once per stripe and waits for
|
|
/// all of them, so all engaged readers advance by one block per stripe and
|
|
/// remain mutually block-aligned. This preserves the alignment invariant
|
|
/// that removed the "inconsistent read source shards" desync (backlog#832):
|
|
/// a reader must never contribute a block from a different stripe than the
|
|
/// rest of the set.
|
|
///
|
|
/// While the object is healthy only the data shards are engaged, so a
|
|
/// healthy GET reads exactly `data_shards` shards per stripe instead of all
|
|
/// `data + parity` shards (backlog#923). Parity slots stay unopened
|
|
/// deferred readers; when a data shard is missing or dies at stripe `k`,
|
|
/// enough parity readers are engaged — realigned to stripe `k` through
|
|
/// their [`DeferredReaderStripeHandle`] — to restore the decode quorum
|
|
/// *plus one extra shard* so reconstruction verification still has a
|
|
/// source to check against (`erasure.rs` only verifies when
|
|
/// `available_shards > data_shards`).
|
|
///
|
|
/// Any reader that errors is retired for the rest of the object — data and
|
|
/// newly engaged parity alike: a streaming shard read that failed mid-block
|
|
/// can no longer be trusted to be aligned, so re-reading it on a later
|
|
/// stripe would reintroduce the desync. A parity reader that cannot be
|
|
/// realigned (no pending deferred handle) is likewise retired instead of
|
|
/// being read out of position.
|
|
async fn read_lockstep(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
|
let num_readers = self.readers.len();
|
|
let shard_size = if self.offset + self.shard_size > self.shard_file_size {
|
|
self.shard_file_size - self.offset
|
|
} else {
|
|
self.shard_size
|
|
};
|
|
|
|
let mut shards: Vec<Option<Vec<u8>>> = vec![None; num_readers];
|
|
let mut errs: Vec<Option<Error>> = vec![None; num_readers];
|
|
if shard_size == 0 {
|
|
return (shards, errs);
|
|
}
|
|
|
|
// Advance to the next stripe (see the matching note in `read`); the
|
|
// lockstep path must track stripe geometry identically (backlog#799 B2).
|
|
self.offset += shard_size;
|
|
let stripe_index = self.stripe_index;
|
|
self.stripe_index += 1;
|
|
|
|
self.buffers.ensure_slots(num_readers);
|
|
|
|
// Engage parity up front when data slots are already known to be
|
|
// missing (offline at setup or retired on an earlier stripe), so the
|
|
// substitute reads run in parallel with the surviving data reads.
|
|
let missing_data_readers = self.readers.iter().take(self.data_shards).filter(|r| r.is_none()).count();
|
|
if missing_data_readers > 0 {
|
|
// One extra engaged parity beyond the reconstruction quorum keeps
|
|
// reconstruction verification effective: with exactly
|
|
// `data_shards` available shards the verification would silently
|
|
// turn itself off.
|
|
let want = missing_data_readers + 1;
|
|
let mut have = (self.data_shards..num_readers)
|
|
.filter(|&i| self.engaged[i] && self.readers[i].is_some())
|
|
.count();
|
|
for idx in self.data_shards..num_readers {
|
|
if have >= want {
|
|
break;
|
|
}
|
|
if self.readers[idx].is_some() && !self.engaged[idx] && self.try_engage_parity(idx, stripe_index) {
|
|
have += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-claim per-slot buffers so the `self.readers` borrow below stays
|
|
// disjoint from `self.buffers`.
|
|
let participating: Vec<bool> = (0..num_readers)
|
|
.map(|i| self.engaged[i] && self.readers[i].is_some())
|
|
.collect();
|
|
let mut bufs: Vec<Option<Vec<u8>>> = Vec::with_capacity(num_readers);
|
|
for (i, participates) in participating.iter().enumerate() {
|
|
bufs.push(if *participates {
|
|
Some(self.buffers.take(i, shard_size))
|
|
} else {
|
|
None
|
|
});
|
|
}
|
|
|
|
let data_shards = self.data_shards;
|
|
let read_timeout = self.read_timeout;
|
|
let metrics_path = self.metrics_path;
|
|
let read_costs = self.read_costs.clone();
|
|
let locality_preference_enabled = self.locality_preference_enabled;
|
|
let stripe_read_start = metrics_path.map(|_| Instant::now());
|
|
|
|
let mut retire_readers = Vec::new();
|
|
let mut scheduled = 0usize;
|
|
let mut success = 0usize;
|
|
let mut completed = 0usize;
|
|
let mut failed = 0usize;
|
|
let mut first_shard_recorded = false;
|
|
// Scope the reader borrow (held by `reader_iter`/`sets`) so it is released
|
|
// before the retirement pass mutates `self.readers` below.
|
|
{
|
|
let mut sets = FuturesUnordered::new();
|
|
let reader_iter = ReaderLaunchIter::new(&mut self.readers, &read_costs, locality_preference_enabled);
|
|
for (i, reader) in reader_iter {
|
|
if reader.is_none() || !participating[i] {
|
|
continue;
|
|
}
|
|
let read_cost = read_costs.get(i).copied().unwrap_or(ShardReadCost::Unknown);
|
|
let recycled_buf = bufs[i].take();
|
|
scheduled += 1;
|
|
sets.push(read_shard(
|
|
i,
|
|
read_cost,
|
|
reader,
|
|
recycled_buf,
|
|
shard_size,
|
|
data_shards,
|
|
read_timeout,
|
|
metrics_path,
|
|
));
|
|
}
|
|
|
|
while let Some((i, _read_cost, result, _should_retire)) = sets.next().await {
|
|
completed += 1;
|
|
if !first_shard_recorded {
|
|
if let Some(path) = metrics_path {
|
|
record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_FIRST_SHARD, stripe_read_start);
|
|
}
|
|
first_shard_recorded = true;
|
|
}
|
|
match result {
|
|
Ok(v) => {
|
|
shards[i] = Some(v);
|
|
success += 1;
|
|
}
|
|
Err(e) => {
|
|
errs[i] = Some(e);
|
|
retire_readers.push(i);
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A data shard may have died during this stripe's reads. The unengaged
|
|
// parity readers are still unopened, so they can be aligned to *this*
|
|
// stripe and read now: engage them one at a time until the stripe has
|
|
// `data_shards + 1` successful shards (decode quorum plus one
|
|
// reconstruction-verification source) or parity runs out.
|
|
loop {
|
|
let success_now = shards.iter().filter(|shard| shard.is_some()).count();
|
|
let data_shard_missing = shards.iter().take(data_shards).any(|shard| shard.is_none());
|
|
if !data_shard_missing || success_now > data_shards {
|
|
break;
|
|
}
|
|
let Some(idx) = (data_shards..num_readers).find(|&i| self.readers[i].is_some() && !self.engaged[i]) else {
|
|
break;
|
|
};
|
|
if !self.try_engage_parity(idx, stripe_index) {
|
|
continue;
|
|
}
|
|
let read_cost = read_costs.get(idx).copied().unwrap_or(ShardReadCost::Unknown);
|
|
let recycled_buf = Some(self.buffers.take(idx, shard_size));
|
|
scheduled += 1;
|
|
let (i, _read_cost, result, _should_retire) = read_shard(
|
|
idx,
|
|
read_cost,
|
|
&mut self.readers[idx],
|
|
recycled_buf,
|
|
shard_size,
|
|
data_shards,
|
|
read_timeout,
|
|
metrics_path,
|
|
)
|
|
.await;
|
|
completed += 1;
|
|
match result {
|
|
Ok(v) => {
|
|
shards[i] = Some(v);
|
|
success += 1;
|
|
}
|
|
Err(e) => {
|
|
errs[i] = Some(e);
|
|
retire_readers.push(i);
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(path) = metrics_path {
|
|
record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_QUORUM, stripe_read_start);
|
|
rustfs_io_metrics::record_get_object_shard_read_fanout(path, scheduled, completed, success, failed);
|
|
}
|
|
|
|
for i in retire_readers {
|
|
self.readers[i] = None;
|
|
}
|
|
|
|
(shards, errs)
|
|
}
|
|
|
|
/// Attempt to bring an as-yet-unread parity reader into the lockstep read
|
|
/// set at `stripe_index`.
|
|
///
|
|
/// At stripe 0 every reader is still positioned at the stream start, so
|
|
/// engagement is trivially aligned. Past stripe 0 the parity reader must
|
|
/// still be an unopened deferred reader: its pending open offset is
|
|
/// advanced by `stripe_index` bitrot blocks (the `bitrot_encoded_range`
|
|
/// geometry) so its first read returns the current stripe. A parity reader
|
|
/// that cannot be realigned is retired for the rest of the object,
|
|
/// mirroring the retire-on-error rule: reading it would return an earlier
|
|
/// stripe and reintroduce the backlog#832 desync.
|
|
fn try_engage_parity(&mut self, idx: usize, stripe_index: usize) -> bool {
|
|
if stripe_index == 0 {
|
|
self.engaged[idx] = true;
|
|
return true;
|
|
}
|
|
let advanced = self
|
|
.deferred_handles
|
|
.get(idx)
|
|
.and_then(|handle| handle.as_ref())
|
|
.is_some_and(|handle| handle.advance_stripes(stripe_index));
|
|
if advanced {
|
|
self.engaged[idx] = true;
|
|
true
|
|
} else {
|
|
warn!(
|
|
shard_index = idx,
|
|
stripe_index, "retiring parity reader that cannot be aligned to the current stripe"
|
|
);
|
|
self.readers[idx] = None;
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn recycle_shards(&mut self, shards: &mut [Option<Vec<u8>>]) {
|
|
for (i, reader) in self.readers.iter().enumerate() {
|
|
if reader.is_some()
|
|
&& let Some(buf) = shards.get_mut(i).and_then(Option::take)
|
|
{
|
|
self.buffers.put(i, buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn can_decode(&self, shards: &[Option<Vec<u8>>]) -> bool {
|
|
shards.iter().filter(|s| s.is_some()).count() >= self.data_shards
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl<R> ShardStripeSource for ParallelReader<R>
|
|
where
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
async fn read_next_stripe(&mut self) -> StripeReadState {
|
|
let read_quorum = self.data_shards;
|
|
let (shards, errors) = ParallelReader::read(self).await;
|
|
StripeReadState::from_parts_with_read_costs(shards, errors, &self.read_costs, read_quorum)
|
|
}
|
|
}
|
|
|
|
/// Get the total length of data blocks
|
|
fn get_data_block_len(shards: &[Option<Vec<u8>>], data_blocks: usize) -> usize {
|
|
let mut size = 0;
|
|
for shard in shards.iter().take(data_blocks).flatten() {
|
|
size += shard.len();
|
|
}
|
|
|
|
size
|
|
}
|
|
|
|
/// Write data blocks from encoded blocks to target, supporting offset and length
|
|
async fn write_data_blocks<W>(
|
|
writer: &mut W,
|
|
en_blocks: &[Option<Vec<u8>>],
|
|
data_blocks: usize,
|
|
mut offset: usize,
|
|
length: usize,
|
|
) -> std::io::Result<usize>
|
|
where
|
|
W: tokio::io::AsyncWrite + Send + Sync + Unpin,
|
|
{
|
|
if en_blocks.len() < data_blocks {
|
|
let reason = GetObjectFailureReason::RangeOrLengthInvalid;
|
|
record_get_object_pipeline_failure(GET_STAGE_RANGE, reason);
|
|
error!(
|
|
data_blocks,
|
|
available_shards = en_blocks.len(),
|
|
stage = GET_STAGE_RANGE,
|
|
reason = reason.as_str(),
|
|
"Write data blocks received fewer shards than data blocks"
|
|
);
|
|
return Err(io::Error::new(ErrorKind::InvalidInput, "data block count exceeds available shards"));
|
|
}
|
|
|
|
if length == 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
let Some(required_len) = offset.checked_add(length) else {
|
|
let reason = GetObjectFailureReason::RangeOrLengthInvalid;
|
|
record_get_object_pipeline_failure(GET_STAGE_RANGE, reason);
|
|
error!(
|
|
offset,
|
|
length,
|
|
stage = GET_STAGE_RANGE,
|
|
reason = reason.as_str(),
|
|
"Write data blocks offset and length overflow"
|
|
);
|
|
return Err(io::Error::new(ErrorKind::InvalidInput, "offset + length overflows"));
|
|
};
|
|
let data_len = get_data_block_len(en_blocks, data_blocks);
|
|
if data_len < required_len {
|
|
let reason = GetObjectFailureReason::ShortRead;
|
|
error!(
|
|
data_blocks,
|
|
data_len,
|
|
required_len,
|
|
offset,
|
|
length,
|
|
stage = GET_STAGE_EMIT,
|
|
reason = reason.as_str(),
|
|
"Write data blocks had insufficient data after offset"
|
|
);
|
|
record_get_object_pipeline_failure(GET_STAGE_EMIT, reason);
|
|
return Err(io::Error::new(ErrorKind::UnexpectedEof, "Not enough data blocks to write"));
|
|
}
|
|
|
|
let mut total_written = 0;
|
|
let mut write_left = length;
|
|
|
|
for block_op in &en_blocks[..data_blocks] {
|
|
let Some(block) = block_op else {
|
|
let reason = GetObjectFailureReason::ShortRead;
|
|
error!(
|
|
data_blocks,
|
|
offset,
|
|
length,
|
|
stage = GET_STAGE_EMIT,
|
|
reason = reason.as_str(),
|
|
"Write data blocks found a missing data shard"
|
|
);
|
|
record_get_object_pipeline_failure(GET_STAGE_EMIT, reason);
|
|
return Err(io::Error::new(ErrorKind::UnexpectedEof, "Missing data block"));
|
|
};
|
|
|
|
if offset >= block.len() {
|
|
offset -= block.len();
|
|
continue;
|
|
}
|
|
|
|
let block_slice = &block[offset..];
|
|
offset = 0;
|
|
|
|
let write_len = write_left.min(block_slice.len());
|
|
let write_stage_start = get_stage_timer_if_enabled(rustfs_io_metrics::get_stage_metrics_enabled());
|
|
if let Err(e) = writer.write_all(&block_slice[..write_len]).await {
|
|
if let Some(write_stage_start) = write_stage_start {
|
|
rustfs_io_metrics::record_get_object_duplex_backpressure_duration(write_stage_start.elapsed().as_secs_f64());
|
|
}
|
|
let reason = classify_io_error(&e);
|
|
record_get_object_pipeline_failure(GET_STAGE_EMIT, reason);
|
|
error!(
|
|
write_len,
|
|
total_written,
|
|
write_left,
|
|
stage = GET_STAGE_EMIT,
|
|
reason = reason.as_str(),
|
|
error = ?e,
|
|
"Write data blocks failed to emit bytes"
|
|
);
|
|
return Err(e);
|
|
}
|
|
if let Some(write_stage_start) = write_stage_start {
|
|
rustfs_io_metrics::record_get_object_duplex_backpressure_duration(write_stage_start.elapsed().as_secs_f64());
|
|
}
|
|
|
|
total_written += write_len;
|
|
write_left -= write_len;
|
|
|
|
if write_left == 0 {
|
|
return Ok(total_written);
|
|
}
|
|
}
|
|
|
|
let reason = GetObjectFailureReason::ShortRead;
|
|
error!(
|
|
total_written,
|
|
write_left,
|
|
length,
|
|
stage = GET_STAGE_EMIT,
|
|
reason = reason.as_str(),
|
|
"Write data blocks exhausted data before completing requested length"
|
|
);
|
|
record_get_object_pipeline_failure(GET_STAGE_EMIT, reason);
|
|
Err(io::Error::new(ErrorKind::UnexpectedEof, "Not enough data blocks to write"))
|
|
}
|
|
|
|
impl Erasure {
|
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
|
pub async fn decode<W, R>(
|
|
&self,
|
|
writer: &mut W,
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
offset: usize,
|
|
length: usize,
|
|
total_length: usize,
|
|
) -> (usize, Option<std::io::Error>)
|
|
where
|
|
W: AsyncWrite + Send + Sync + Unpin,
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
self.decode_inner(writer, readers, offset, length, total_length, None, Vec::new())
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn decode_with_read_costs<W, R>(
|
|
&self,
|
|
writer: &mut W,
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
offset: usize,
|
|
length: usize,
|
|
total_length: usize,
|
|
read_costs: Vec<ShardReadCost>,
|
|
) -> (usize, Option<std::io::Error>)
|
|
where
|
|
W: AsyncWrite + Send + Sync + Unpin,
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
self.decode_inner(writer, readers, offset, length, total_length, Some(read_costs), Vec::new())
|
|
.await
|
|
}
|
|
|
|
/// GET decode entry point that also carries the deferred-parity stripe
|
|
/// handles from bitrot reader setup, so unengaged parity readers can be
|
|
/// opened aligned to the stripe where a data shard fails (backlog#923).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn decode_with_stripe_handles<W, R>(
|
|
&self,
|
|
writer: &mut W,
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
offset: usize,
|
|
length: usize,
|
|
total_length: usize,
|
|
read_costs: Option<Vec<ShardReadCost>>,
|
|
deferred_handles: Vec<Option<DeferredReaderStripeHandle>>,
|
|
) -> (usize, Option<std::io::Error>)
|
|
where
|
|
W: AsyncWrite + Send + Sync + Unpin,
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
self.decode_inner(writer, readers, offset, length, total_length, read_costs, deferred_handles)
|
|
.await
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn decode_inner<W, R>(
|
|
&self,
|
|
writer: &mut W,
|
|
readers: Vec<Option<BitrotReader<R>>>,
|
|
offset: usize,
|
|
length: usize,
|
|
total_length: usize,
|
|
read_costs: Option<Vec<ShardReadCost>>,
|
|
deferred_handles: Vec<Option<DeferredReaderStripeHandle>>,
|
|
) -> (usize, Option<std::io::Error>)
|
|
where
|
|
W: AsyncWrite + Send + Sync + Unpin,
|
|
R: AsyncRead + Unpin + Send + Sync,
|
|
{
|
|
if readers.len() != self.data_shards + self.parity_shards {
|
|
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
|
|
return (0, Some(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers")));
|
|
}
|
|
|
|
// block_size/data_shards come from on-disk metadata; a corrupt FileInfo with a
|
|
// zero here must surface as an error, not a divide-by-zero panic on every GET.
|
|
if self.block_size == 0 || self.data_shards == 0 {
|
|
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
|
|
return (0, Some(io::Error::new(ErrorKind::InvalidInput, "Invalid erasure coding parameters")));
|
|
}
|
|
|
|
let Some(end_offset) = offset.checked_add(length) else {
|
|
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
|
|
return (0, Some(io::Error::new(ErrorKind::InvalidInput, "offset + length exceeds total length")));
|
|
};
|
|
if end_offset > total_length {
|
|
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
|
|
return (0, Some(io::Error::new(ErrorKind::InvalidInput, "offset + length exceeds total length")));
|
|
}
|
|
|
|
let mut ret_err = None;
|
|
|
|
if length == 0 {
|
|
return (0, ret_err);
|
|
}
|
|
|
|
let mut written = 0;
|
|
|
|
let mut reader = if let Some(read_costs) = read_costs {
|
|
ParallelReader::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
|
readers,
|
|
self.clone(),
|
|
offset,
|
|
total_length,
|
|
Some(GET_OBJECT_PATH_LEGACY_DUPLEX),
|
|
read_costs,
|
|
get_object_disk_read_timeout(),
|
|
true,
|
|
)
|
|
} else {
|
|
ParallelReader::new_for_decode(readers, self.clone(), offset, total_length, Some(GET_OBJECT_PATH_LEGACY_DUPLEX))
|
|
}
|
|
.with_deferred_parity_handles(deferred_handles);
|
|
|
|
let start = offset / self.block_size;
|
|
let end = end_offset.saturating_sub(1) / self.block_size;
|
|
|
|
for i in start..=end {
|
|
let (block_offset, block_length) = if start == end {
|
|
(offset % self.block_size, length)
|
|
} else if i == start {
|
|
(offset % self.block_size, self.block_size - (offset % self.block_size))
|
|
} else if i == end {
|
|
let end_remainder = end_offset % self.block_size;
|
|
(0, if end_remainder == 0 { self.block_size } else { end_remainder })
|
|
} else {
|
|
(0, self.block_size)
|
|
};
|
|
|
|
if block_length == 0 {
|
|
// error!("erasure decode decode block_length == 0");
|
|
break;
|
|
}
|
|
|
|
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
|
let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
|
let (mut shards, errs) = reader.read().await;
|
|
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_STRIPE_READ, stripe_read_stage_start);
|
|
|
|
if ret_err.is_none()
|
|
&& let (_, Some(err)) = reduce_errs(&errs, &[])
|
|
&& (err == Error::FileNotFound || err == Error::FileCorrupt)
|
|
{
|
|
ret_err = Some(err.into());
|
|
}
|
|
|
|
if !reader.can_decode(&shards) {
|
|
let reason = GetObjectFailureReason::ReadQuorum;
|
|
error!(
|
|
data_shards = self.data_shards,
|
|
total_shards = self.data_shards + self.parity_shards,
|
|
available_shards = shards.iter().filter(|shard| shard.is_some()).count(),
|
|
block_offset,
|
|
block_length,
|
|
stage = GET_STAGE_STRIPE_READ,
|
|
reason = reason.as_str(),
|
|
errors = ?errs,
|
|
"Erasure decode could not gather enough shards"
|
|
);
|
|
record_get_object_pipeline_failure(GET_STAGE_STRIPE_READ, reason);
|
|
ret_err = Some(Error::ErasureReadQuorum.into());
|
|
break;
|
|
}
|
|
|
|
// Decode the shards. If this stripe needed parity to reconstruct a
|
|
// missing data shard and an extra source shard was available, verify
|
|
// the reconstructed data against that source before streaming bytes.
|
|
let reconstruct_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
|
if let Err(e) = self.decode_data_with_reconstruction_verification(&mut shards) {
|
|
record_get_stage_duration_if_enabled(
|
|
GET_OBJECT_PATH_LEGACY_DUPLEX,
|
|
GET_STAGE_RECONSTRUCT,
|
|
reconstruct_stage_start,
|
|
);
|
|
let reason = GetObjectFailureReason::DecodeError;
|
|
error!(
|
|
data_shards = self.data_shards,
|
|
total_shards = self.data_shards + self.parity_shards,
|
|
block_offset,
|
|
block_length,
|
|
stage = GET_STAGE_RECONSTRUCT,
|
|
reason = reason.as_str(),
|
|
error = ?e,
|
|
"Erasure shard reconstruction failed"
|
|
);
|
|
record_get_object_pipeline_failure(GET_STAGE_RECONSTRUCT, reason);
|
|
ret_err = Some(e);
|
|
break;
|
|
}
|
|
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
|
|
|
|
let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
|
let n = match write_data_blocks(writer, &shards, self.data_shards, block_offset, block_length).await {
|
|
Ok(n) => {
|
|
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_EMIT, emit_stage_start);
|
|
n
|
|
}
|
|
Err(e) => {
|
|
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_EMIT, emit_stage_start);
|
|
error!(
|
|
block_offset,
|
|
block_length,
|
|
bytes_written = written,
|
|
stage = GET_STAGE_EMIT,
|
|
reason = classify_io_error(&e).as_str(),
|
|
error = ?e,
|
|
"Erasure decode failed to emit reconstructed data"
|
|
);
|
|
ret_err = Some(e);
|
|
break;
|
|
}
|
|
};
|
|
|
|
written += n;
|
|
|
|
// Hand active-reader buffers back so the next stripe can reuse them
|
|
// without retaining offline shard data that cannot be read again.
|
|
reader.recycle_shards(&mut shards);
|
|
}
|
|
|
|
if ret_err.is_some() {
|
|
return (written, ret_err);
|
|
}
|
|
|
|
if written < length {
|
|
ret_err = Some(Error::LessData.into());
|
|
}
|
|
|
|
(written, ret_err)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{
|
|
disk::error::DiskError,
|
|
erasure::coding::{BitrotReader, BitrotWriter},
|
|
};
|
|
use rustfs_utils::HashAlgorithm;
|
|
use std::io::Cursor;
|
|
use std::pin::Pin;
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicUsize, Ordering},
|
|
};
|
|
use std::task::{Context, Poll};
|
|
use tokio::io::ReadBuf;
|
|
|
|
type BoxedShardReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
|
|
|
|
/// Counts the raw bytes pulled from a shard stream, to prove which shards
|
|
/// a decode path actually touches (backlog#923 call-count evidence).
|
|
struct CountingShardReader {
|
|
inner: Cursor<Vec<u8>>,
|
|
bytes_read: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl AsyncRead for CountingShardReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
|
let before = buf.filled().len();
|
|
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
|
if let Poll::Ready(Ok(())) = result {
|
|
let delta = buf.filled().len() - before;
|
|
self.bytes_read.fetch_add(delta, Ordering::SeqCst);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
/// Build a production-shaped reader set: data shards as opened stream
|
|
/// readers, parity shards as *unopened* deferred readers carrying stripe
|
|
/// handles (mirroring `fill_deferred_bitrot_readers`). `truncate` shortens
|
|
/// the given data shard buffers to simulate a reader dying mid-object.
|
|
fn readers_with_deferred_parity(
|
|
shard_bufs: &[Vec<u8>],
|
|
data_shards: usize,
|
|
shard_size: usize,
|
|
hash_algo: &HashAlgorithm,
|
|
truncate: &[(usize, usize)],
|
|
) -> (Vec<Option<BitrotReader<BoxedShardReader>>>, Vec<Option<DeferredReaderStripeHandle>>) {
|
|
use crate::io_support::bitrot::create_deferred_bitrot_reader_with_stripe_handle;
|
|
|
|
let mut readers = Vec::with_capacity(shard_bufs.len());
|
|
let mut handles: Vec<Option<DeferredReaderStripeHandle>> = vec![None; shard_bufs.len()];
|
|
for (i, buf) in shard_bufs.iter().enumerate() {
|
|
if i < data_shards {
|
|
let bytes = truncate
|
|
.iter()
|
|
.find(|(index, _)| *index == i)
|
|
.map(|(_, len)| buf[..*len].to_vec())
|
|
.unwrap_or_else(|| buf.clone());
|
|
readers.push(Some(BitrotReader::new(
|
|
Box::new(Cursor::new(bytes)) as BoxedShardReader,
|
|
shard_size,
|
|
hash_algo.clone(),
|
|
false,
|
|
)));
|
|
} else {
|
|
let (reader, handle) = create_deferred_bitrot_reader_with_stripe_handle(
|
|
Some(bytes::Bytes::from(buf.clone())),
|
|
None,
|
|
"test-bucket",
|
|
"test-object",
|
|
0,
|
|
buf.len(),
|
|
shard_size,
|
|
hash_algo.clone(),
|
|
false,
|
|
false,
|
|
);
|
|
readers.push(Some(reader));
|
|
handles[i] = Some(handle);
|
|
}
|
|
}
|
|
(readers, handles)
|
|
}
|
|
|
|
enum TestShardReader {
|
|
Ready(Cursor<Vec<u8>>),
|
|
Pending,
|
|
PartialThenPending { data: Vec<u8>, emitted: bool },
|
|
TimedOut,
|
|
}
|
|
|
|
impl AsyncRead for TestShardReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
|
match &mut *self {
|
|
TestShardReader::Ready(cursor) => Pin::new(cursor).poll_read(cx, buf),
|
|
TestShardReader::Pending => {
|
|
cx.waker().wake_by_ref();
|
|
Poll::Pending
|
|
}
|
|
TestShardReader::PartialThenPending { data, emitted } => {
|
|
if *emitted {
|
|
cx.waker().wake_by_ref();
|
|
return Poll::Pending;
|
|
}
|
|
|
|
let len = data.len().min(buf.remaining());
|
|
buf.put_slice(&data[..len]);
|
|
*emitted = true;
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
TestShardReader::TimedOut => Poll::Ready(Err(io::Error::new(ErrorKind::TimedOut, "test shard read timed out"))),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_data_blocks_writes_range_across_blocks() {
|
|
let blocks = vec![Some(vec![1, 2, 3, 4]), Some(vec![5, 6, 7]), Some(vec![8, 9])];
|
|
let mut out = Vec::new();
|
|
|
|
let written = write_data_blocks(&mut out, &blocks, 3, 2, 5).await.unwrap();
|
|
|
|
assert_eq!(written, 5);
|
|
assert_eq!(out, vec![3, 4, 5, 6, 7]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_data_blocks_rejects_short_data_after_offset() {
|
|
let blocks = vec![Some(vec![1, 2, 3, 4]), Some(vec![5, 6, 7])];
|
|
let mut out = Vec::new();
|
|
|
|
let err = write_data_blocks(&mut out, &blocks, 2, 3, 5).await.unwrap_err();
|
|
|
|
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_data_blocks_rejects_invalid_data_block_count() {
|
|
let blocks = vec![Some(vec![1, 2, 3, 4])];
|
|
let mut out = Vec::new();
|
|
|
|
let err = write_data_blocks(&mut out, &blocks, 2, 0, 1).await.unwrap_err();
|
|
|
|
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
/// Regression for upstream issue #2716: ranged GETs going through
|
|
/// `Erasure::decode` must return the requested byte range without
|
|
/// panicking or truncating, including when the range starts at a
|
|
/// non-zero offset and crosses EC block boundaries.
|
|
#[tokio::test]
|
|
async fn test_erasure_decode_ranged_read_returns_correct_bytes() {
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
// 200 bytes spans 3 full blocks + 1 partial block, exercising
|
|
// the start/middle/end branches in `Erasure::decode`.
|
|
let total_data: Vec<u8> = (0..200u32).map(|i| i as u8).collect();
|
|
let total_len = total_data.len();
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
|
let shard_size = erasure.shard_size();
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
let mut offset = 0;
|
|
while offset < total_len {
|
|
let end = (offset + BLOCK_SIZE).min(total_len);
|
|
let shards = erasure.encode_data(&total_data[offset..end]).unwrap();
|
|
for (i, shard) in shards.iter().enumerate() {
|
|
shard_writers[i].write(shard).await.unwrap();
|
|
}
|
|
offset = end;
|
|
}
|
|
|
|
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
|
|
|
// `Erasure::decode` does not seek the readers; the production caller
|
|
// (`create_bitrot_reader`) positions each reader at the shard byte
|
|
// offset corresponding to the request's start block. Mirror that here.
|
|
let hash_size = hash_algo.size();
|
|
let make_readers = |off: usize| -> Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> {
|
|
let start_block = off / BLOCK_SIZE;
|
|
let cursor_pos = start_block * (shard_size + hash_size);
|
|
shard_bufs
|
|
.iter()
|
|
.map(|buf| {
|
|
let mut cursor = Cursor::new(buf.clone());
|
|
cursor.set_position(cursor_pos as u64);
|
|
Some(BitrotReader::new(cursor, shard_size, hash_algo.clone(), false))
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// (offset, length, description)
|
|
let cases: &[(usize, usize, &str)] = &[
|
|
(0, total_len, "full read"),
|
|
(0, 50, "head from start, partial block"),
|
|
(10, 30, "small range within first block"),
|
|
(60, 80, "range crossing two block boundaries"),
|
|
(128, 50, "range starting at block boundary"),
|
|
(130, 10, "small range deep in middle"),
|
|
(192, 8, "tail covering last partial block"),
|
|
];
|
|
|
|
for &(off, len, desc) in cases {
|
|
let mut output = Vec::new();
|
|
let (written, err) = erasure.decode(&mut output, make_readers(off), off, len, total_len).await;
|
|
assert!(err.is_none(), "{}: unexpected error: {:?}", desc, err);
|
|
assert_eq!(written, len, "{}: written != length", desc);
|
|
assert_eq!(output, total_data[off..off + len], "{}: bytes mismatch", desc);
|
|
}
|
|
}
|
|
|
|
/// Guards the shard-buffer reuse in `ParallelReader`: a multi-stripe
|
|
/// `Erasure::decode` that reconstructs missing data shards on every stripe
|
|
/// must still return byte-exact output. Reconstructed and parity buffers are
|
|
/// recycled into the next stripe, so this catches stale-byte leaks or a
|
|
/// missing resize between stripes (including the short final stripe). Run
|
|
/// with bitrot verification both on and off.
|
|
#[tokio::test]
|
|
async fn test_erasure_decode_with_missing_shards_reuses_buffers() {
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
// 200 bytes => 3 full blocks + 1 partial: several stripes are decoded
|
|
// through the same `ParallelReader`, so its buffers are reused.
|
|
let total_data: Vec<u8> = (0..200u32).map(|i| i as u8).collect();
|
|
let total_len = total_data.len();
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
|
let shard_size = erasure.shard_size();
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
let mut offset = 0;
|
|
while offset < total_len {
|
|
let end = (offset + BLOCK_SIZE).min(total_len);
|
|
let shards = erasure.encode_data(&total_data[offset..end]).unwrap();
|
|
for (i, shard) in shards.iter().enumerate() {
|
|
shard_writers[i].write(shard).await.unwrap();
|
|
}
|
|
offset = end;
|
|
}
|
|
|
|
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
|
|
|
// Drop two data shards: each stripe must be reconstructed from the
|
|
// remaining data + parity shards, and those reconstructed buffers are
|
|
// what gets recycled.
|
|
let missing = [0usize, 2usize];
|
|
for verify in [true, false] {
|
|
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = shard_bufs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, buf)| {
|
|
if missing.contains(&i) {
|
|
None
|
|
} else {
|
|
Some(BitrotReader::new(Cursor::new(buf.clone()), shard_size, hash_algo.clone(), !verify))
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let mut output = Vec::new();
|
|
let (written, err) = erasure.decode(&mut output, readers, 0, total_len, total_len).await;
|
|
assert!(err.is_none(), "verify={verify}: unexpected error: {err:?}");
|
|
assert_eq!(written, total_len, "verify={verify}: short write");
|
|
assert_eq!(output, total_data, "verify={verify}: reconstructed bytes mismatch");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_erasure_decode_rejects_inconsistent_reconstruction_sources() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let data: Vec<u8> = (0..BLOCK_SIZE as u8).collect();
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let shard_size = erasure.shard_size();
|
|
let encoded = erasure.encode_data(&data).expect("encode should succeed");
|
|
let mut corrupt_parity = encoded[DATA_SHARDS].to_vec();
|
|
|
|
corrupt_parity[0] ^= 0x80;
|
|
let readers = vec![
|
|
None,
|
|
Some(BitrotReader::new(
|
|
Cursor::new(encoded[1].to_vec()),
|
|
shard_size,
|
|
HashAlgorithm::None,
|
|
false,
|
|
)),
|
|
Some(BitrotReader::new(Cursor::new(corrupt_parity), shard_size, HashAlgorithm::None, false)),
|
|
Some(BitrotReader::new(
|
|
Cursor::new(encoded[DATA_SHARDS + 1].to_vec()),
|
|
shard_size,
|
|
HashAlgorithm::None,
|
|
false,
|
|
)),
|
|
];
|
|
|
|
let mut output = Vec::new();
|
|
let (written, err) = erasure.decode(&mut output, readers, 0, data.len(), data.len()).await;
|
|
|
|
assert_eq!(written, 0);
|
|
let err = err.expect("inconsistent parity sources must fail the read");
|
|
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
|
assert!(err.to_string().contains("inconsistent read source shards"));
|
|
assert!(output.is_empty());
|
|
}
|
|
|
|
/// One mid-object data-shard death scenario, shared by the gate-on and
|
|
/// gate-off variants of `test_erasure_decode_recovers_when_data_shard_dies_midway`.
|
|
async fn run_decode_midway_death_case(hash_algo: HashAlgorithm, context: &str) {
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
// 200 bytes => 3 full stripes + 1 partial: the failing data shard reads
|
|
// the first stripe, then errors on every later stripe.
|
|
let total_data: Vec<u8> = (0..200u32).map(|i| i as u8).collect();
|
|
let total_len = total_data.len();
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
|
let shard_size = erasure.shard_size();
|
|
let hash_size = hash_algo.size();
|
|
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
let mut offset = 0;
|
|
while offset < total_len {
|
|
let end = (offset + BLOCK_SIZE).min(total_len);
|
|
let shards = erasure.encode_data(&total_data[offset..end]).unwrap();
|
|
for (i, shard) in shards.iter().enumerate() {
|
|
shard_writers[i].write(shard).await.unwrap();
|
|
}
|
|
offset = end;
|
|
}
|
|
|
|
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
|
|
|
// Truncate data shard 0 to only its first (hash+data) block: it reads
|
|
// the first stripe successfully, then errors (UnexpectedEof), forcing
|
|
// every later stripe to engage the deferred parity readers aligned to
|
|
// the failing stripe.
|
|
let first_block_len = (hash_size + shard_size).min(shard_bufs[0].len());
|
|
let (readers, handles) =
|
|
readers_with_deferred_parity(&shard_bufs, DATA_SHARDS, shard_size, &hash_algo, &[(0, first_block_len)]);
|
|
|
|
let mut output = Vec::new();
|
|
let (written, err) = erasure
|
|
.decode_with_stripe_handles(&mut output, readers, 0, total_len, total_len, None, handles)
|
|
.await;
|
|
assert!(
|
|
err.is_none(),
|
|
"{context}, algo={hash_algo:?}: mid-object data-shard failure must still reconstruct: {err:?}"
|
|
);
|
|
assert_eq!(
|
|
written, total_len,
|
|
"{context}, algo={hash_algo:?}: short write after mid-object shard failure"
|
|
);
|
|
assert_eq!(
|
|
output, total_data,
|
|
"{context}, algo={hash_algo:?}: reconstructed bytes mismatch (stripe desync?)"
|
|
);
|
|
}
|
|
|
|
/// Regression for backlog#832 (extended for backlog#923): a data shard
|
|
/// reader that dies partway through a multi-stripe object must still
|
|
/// reconstruct byte-exact output, and the parity readers that fill in for
|
|
/// it must be aligned to the current stripe. With the data-shards-only
|
|
/// gate on, parity slots are unopened deferred readers whose stripe handle
|
|
/// advances the pending open offset to the failing stripe — mirroring the
|
|
/// production GET reader setup. Covers both the streaming per-block
|
|
/// checksum format and the no-per-block-hash (`hash_size == 0`) format,
|
|
/// where the offset mapping degrades to the identity function (merge gate
|
|
/// for backlog#923). Also run with the gate off to lock the default
|
|
/// (read-all-shards) behavior.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_erasure_decode_recovers_when_data_shard_dies_midway() {
|
|
for enabled in [None, Some("true")] {
|
|
temp_env::async_with_vars([(ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, enabled)], async {
|
|
let context = if enabled.is_some() { "data-shards-only" } else { "default" };
|
|
for hash_algo in [HashAlgorithm::HighwayHash256, HashAlgorithm::None] {
|
|
run_decode_midway_death_case(hash_algo, context).await;
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
/// Decode a healthy 2+2 multi-stripe object through the lockstep GET path
|
|
/// and return the raw bytes pulled from each shard stream.
|
|
async fn healthy_lockstep_shard_bytes() -> (usize, Vec<usize>) {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
// 200 bytes => 3 full stripes + 1 partial.
|
|
let total_data: Vec<u8> = (0..200u32).map(|i| i as u8).collect();
|
|
let total_len = total_data.len();
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
|
let shard_size = erasure.shard_size();
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
let mut offset = 0;
|
|
while offset < total_len {
|
|
let end = (offset + BLOCK_SIZE).min(total_len);
|
|
let shards = erasure.encode_data(&total_data[offset..end]).unwrap();
|
|
for (i, shard) in shards.iter().enumerate() {
|
|
shard_writers[i].write(shard).await.unwrap();
|
|
}
|
|
offset = end;
|
|
}
|
|
|
|
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
|
|
|
let counters: Vec<Arc<AtomicUsize>> = (0..total_shards).map(|_| Arc::new(AtomicUsize::new(0))).collect();
|
|
let readers: Vec<Option<BitrotReader<CountingShardReader>>> = shard_bufs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, buf)| {
|
|
Some(BitrotReader::new(
|
|
CountingShardReader {
|
|
inner: Cursor::new(buf.clone()),
|
|
bytes_read: Arc::clone(&counters[i]),
|
|
},
|
|
shard_size,
|
|
hash_algo.clone(),
|
|
false,
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
// `Erasure::decode` is the reconstruction-verifying (lockstep) GET path.
|
|
let mut output = Vec::new();
|
|
let (written, err) = erasure.decode(&mut output, readers, 0, total_len, total_len).await;
|
|
assert!(err.is_none(), "healthy decode must succeed: {err:?}");
|
|
assert_eq!(written, total_len);
|
|
assert_eq!(output, total_data);
|
|
|
|
(DATA_SHARDS, counters.iter().map(|counter| counter.load(Ordering::SeqCst)).collect())
|
|
}
|
|
|
|
/// Merge gate for backlog#923: with the gate on, the healthy lockstep GET
|
|
/// path must read only the data shards — parity readers stay unopened and
|
|
/// contribute zero read bytes. This is the call-count proof of the 2x read
|
|
/// amplification fix (2+2 layout reads 2 shards per stripe, not 4).
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_lockstep_healthy_get_reads_only_data_shards() {
|
|
temp_env::async_with_vars([(ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, Some("true"))], async {
|
|
let (data_shards, shard_bytes) = healthy_lockstep_shard_bytes().await;
|
|
for (i, bytes) in shard_bytes.iter().enumerate() {
|
|
if i < data_shards {
|
|
assert!(*bytes > 0, "data shard {i} must be read on the healthy path");
|
|
} else {
|
|
assert_eq!(
|
|
*bytes, 0,
|
|
"healthy GET must not read parity shard {i} (2x read amplification, backlog#923)"
|
|
);
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// Compatibility lock: with the gate off (default), the lockstep path
|
|
/// keeps the pre-backlog#923 behavior and reads every live shard —
|
|
/// including parity — on every stripe.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_lockstep_default_reads_all_shards_per_stripe() {
|
|
temp_env::async_with_vars([(ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, None::<&str>)], async {
|
|
let (_data_shards, shard_bytes) = healthy_lockstep_shard_bytes().await;
|
|
for (i, bytes) in shard_bytes.iter().enumerate() {
|
|
assert!(*bytes > 0, "default lockstep behavior must read shard {i} on every stripe");
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// Merge gate for backlog#923: reconstruction verification must stay
|
|
/// active when parity is engaged mid-object. When a data shard dies at
|
|
/// stripe k, the lockstep path engages one parity reader beyond the decode
|
|
/// quorum (`erasure.rs` only verifies when `available > data_shards`), so
|
|
/// a parity shard whose content is inconsistent with the surviving data —
|
|
/// while still passing its own bitrot hash — must be detected and fail the
|
|
/// read instead of silently corrupting the reconstructed output. Run with
|
|
/// the gate on and off: the rejection must hold in both modes.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_erasure_decode_rejects_inconsistent_parity_engaged_midstream() {
|
|
for enabled in [None, Some("true")] {
|
|
temp_env::async_with_vars([(ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, enabled)], async {
|
|
run_inconsistent_parity_midstream_case(if enabled.is_some() { "data-shards-only" } else { "default" }).await;
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn run_inconsistent_parity_midstream_case(context: &str) {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let total_data: Vec<u8> = (0..200u32).map(|i| i as u8).collect();
|
|
let total_len = total_data.len();
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
|
let shard_size = erasure.shard_size();
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
let hash_size = hash_algo.size();
|
|
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
let mut offset = 0;
|
|
let mut stripe = 0usize;
|
|
while offset < total_len {
|
|
let end = (offset + BLOCK_SIZE).min(total_len);
|
|
let shards = erasure.encode_data(&total_data[offset..end]).unwrap();
|
|
for (i, shard) in shards.iter().enumerate() {
|
|
// Corrupt the first parity shard's payload for every stripe
|
|
// after the first, *before* it is bitrot-hashed: the shard
|
|
// passes its own hash check but is inconsistent with the
|
|
// erasure-coded set.
|
|
if i == DATA_SHARDS && stripe >= 1 {
|
|
let mut corrupted = shard.to_vec();
|
|
corrupted[0] ^= 0x80;
|
|
shard_writers[i].write(&corrupted).await.unwrap();
|
|
} else {
|
|
shard_writers[i].write(shard).await.unwrap();
|
|
}
|
|
}
|
|
offset = end;
|
|
stripe += 1;
|
|
}
|
|
|
|
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
|
|
|
// Data shard 0 dies after stripe 0, forcing parity engagement at stripe 1.
|
|
let first_block_len = (hash_size + shard_size).min(shard_bufs[0].len());
|
|
let (readers, handles) =
|
|
readers_with_deferred_parity(&shard_bufs, DATA_SHARDS, shard_size, &hash_algo, &[(0, first_block_len)]);
|
|
|
|
let mut output = Vec::new();
|
|
let (_written, err) = erasure
|
|
.decode_with_stripe_handles(&mut output, readers, 0, total_len, total_len, None, handles)
|
|
.await;
|
|
|
|
let err = err.unwrap_or_else(|| panic!("{context}: inconsistent parity engaged mid-stream must fail the read"));
|
|
assert_eq!(err.kind(), ErrorKind::InvalidData, "{context}");
|
|
assert!(err.to_string().contains("inconsistent read source shards"), "{context}: {err}");
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[tokio::test]
|
|
async fn test_erasure_decode_preserves_compressed_stream_near_block_boundary() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 1024 * 1024;
|
|
|
|
use crate::io_support::rio::CompressReader;
|
|
use rustfs_utils::CompressionAlgorithm;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let plaintext_size = 8 * BLOCK_SIZE + 123;
|
|
let plaintext = (0..plaintext_size)
|
|
.scan(0x9e37_79b9_7f4a_7c15u64, |state, _| {
|
|
*state ^= *state << 7;
|
|
*state ^= *state >> 9;
|
|
*state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
|
Some((*state >> 32) as u8)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut compressor = CompressReader::new(Cursor::new(plaintext), CompressionAlgorithm::default());
|
|
let mut compressed = Vec::new();
|
|
compressor.read_to_end(&mut compressed).await.unwrap();
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
|
let shard_size = erasure.shard_size();
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
for block in compressed.chunks(BLOCK_SIZE) {
|
|
let shards = erasure.encode_data(block).unwrap();
|
|
for (i, shard) in shards.iter().enumerate() {
|
|
shard_writers[i].write(shard).await.unwrap();
|
|
}
|
|
}
|
|
|
|
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
|
let readers = shard_bufs
|
|
.iter()
|
|
.map(|buf| Some(BitrotReader::new(Cursor::new(buf.clone()), shard_size, hash_algo.clone(), false)))
|
|
.collect();
|
|
|
|
let mut decoded = Vec::new();
|
|
let (written, err) = erasure
|
|
.decode(&mut decoded, readers, 0, compressed.len(), compressed.len())
|
|
.await;
|
|
|
|
assert!(err.is_none(), "unexpected decode error: {err:?}");
|
|
assert_eq!(written, compressed.len());
|
|
assert_eq!(decoded, compressed);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_shard_locality_preference_gate_defaults_disabled() {
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, None::<&str>),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
|| {
|
|
assert_eq!(get_shard_locality_scheduling_mode(), ShardLocalitySchedulingMode::Off);
|
|
assert!(!get_shard_locality_preference_enabled());
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_shard_locality_scheduling_mode_parses_supported_values() {
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("observe")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
|| {
|
|
assert_eq!(get_shard_locality_scheduling_mode(), ShardLocalitySchedulingMode::Observe);
|
|
assert!(!get_shard_locality_preference_enabled());
|
|
},
|
|
);
|
|
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("on")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
|| {
|
|
assert_eq!(get_shard_locality_scheduling_mode(), ShardLocalitySchedulingMode::On);
|
|
assert!(get_shard_locality_preference_enabled());
|
|
assert!(should_collect_shard_read_costs());
|
|
},
|
|
);
|
|
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("unexpected")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, Some("true")),
|
|
],
|
|
|| {
|
|
assert_eq!(get_shard_locality_scheduling_mode(), ShardLocalitySchedulingMode::Off);
|
|
assert!(!get_shard_locality_preference_enabled());
|
|
},
|
|
);
|
|
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, None::<&str>),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, Some("true")),
|
|
],
|
|
|| {
|
|
assert_eq!(get_shard_locality_scheduling_mode(), ShardLocalitySchedulingMode::On);
|
|
assert!(get_shard_locality_preference_enabled());
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_shard_locality_scheduling_off_does_not_collect_without_metrics() {
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("off")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
|| {
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
|
assert!(!should_collect_shard_read_costs());
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_shard_locality_scheduling_observe_collects_only_with_stage_metrics() {
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("observe")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
|| {
|
|
// Without stage metrics there is no reporting channel, so
|
|
// observe mode must not pay the cost-collection overhead.
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
|
assert!(!should_collect_shard_read_costs());
|
|
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
|
assert!(should_collect_shard_read_costs());
|
|
assert!(!get_shard_locality_preference_enabled());
|
|
let read_costs = [ShardReadCost::Remote, ShardReadCost::Local, ShardReadCost::SameNode];
|
|
assert_eq!(shard_read_launch_order(&read_costs, read_costs.len(), false), vec![0, 1, 2]);
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_shard_locality_scheduling_on_enables_reordering() {
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("on")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
|| {
|
|
assert!(get_shard_locality_preference_enabled());
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_shard_locality_legacy_preference_gate_still_enables_on() {
|
|
temp_env::with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, None::<&str>),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, Some("true")),
|
|
],
|
|
|| {
|
|
assert!(get_shard_locality_preference_enabled());
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_shard_locality_read_launch_order_is_gated() {
|
|
let read_costs = [
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Unknown,
|
|
ShardReadCost::SameNode,
|
|
ShardReadCost::Remote,
|
|
];
|
|
|
|
assert_eq!(shard_read_launch_order(&read_costs, read_costs.len(), false), vec![0, 1, 2, 3, 4]);
|
|
assert_eq!(shard_read_launch_order(&read_costs, read_costs.len(), true), vec![1, 3, 0, 4, 2]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_shard_locality_remote_avoid_potential_is_observe_only() {
|
|
assert_eq!(shard_locality_remote_avoid_potential(2, 4, 4), 2);
|
|
assert_eq!(shard_locality_remote_avoid_potential(2, 2, 4), 0);
|
|
assert_eq!(shard_locality_remote_avoid_potential(3, 3, 4), 2);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn parallel_reader_drops_metrics_path_when_stage_metrics_disabled() {
|
|
let erasure = Erasure::new(2, 1, 32);
|
|
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = vec![None, None, None];
|
|
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
|
let reader = ParallelReader::new_with_metrics_path(readers, erasure.clone(), 0, 1, Some(GET_OBJECT_PATH_LEGACY_DUPLEX));
|
|
assert_eq!(reader.metrics_path, None);
|
|
|
|
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = vec![None, None, None];
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
|
let reader = ParallelReader::new_with_metrics_path(readers, erasure, 0, 1, Some(GET_OBJECT_PATH_LEGACY_DUPLEX));
|
|
assert_eq!(reader.metrics_path, Some(GET_OBJECT_PATH_LEGACY_DUPLEX));
|
|
|
|
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_parallel_reader_local_first_avoids_remote_when_local_quorum_exists() {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("on")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
async {
|
|
const NUM_SHARDS: usize = 1;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
let readers = make_test_readers(DATA_SHARDS + PARITY_SHARDS, SHARD_SIZE, NUM_SHARDS, &hash_algo, &[], &[]).await;
|
|
let read_costs = vec![
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::SameNode,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::SameNode,
|
|
];
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new_with_metrics_path_and_read_costs(
|
|
readers,
|
|
erasure,
|
|
0,
|
|
NUM_SHARDS * BLOCK_SIZE,
|
|
None,
|
|
read_costs,
|
|
);
|
|
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
assert!(bufs[0].is_none());
|
|
assert!(bufs[1].is_none());
|
|
for (index, buf) in bufs.iter().enumerate().take(DATA_SHARDS + PARITY_SHARDS).skip(2) {
|
|
assert_eq!(buf.as_deref(), Some(&[(index % 256) as u8; SHARD_SIZE][..]));
|
|
}
|
|
assert!(errs.iter().all(Option::is_none));
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_parallel_reader_local_missing_falls_back_to_remote() {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("on")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
async {
|
|
const NUM_SHARDS: usize = 1;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
let readers = make_test_readers(DATA_SHARDS + PARITY_SHARDS, SHARD_SIZE, NUM_SHARDS, &hash_algo, &[1], &[]).await;
|
|
let read_costs = vec![
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Remote,
|
|
];
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new_with_metrics_path_and_read_costs(
|
|
readers,
|
|
erasure,
|
|
0,
|
|
NUM_SHARDS * BLOCK_SIZE,
|
|
None,
|
|
read_costs,
|
|
);
|
|
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
assert!(matches!(errs[1], Some(Error::FileNotFound)));
|
|
assert_eq!(bufs[4].as_deref(), Some(&[4u8; SHARD_SIZE][..]));
|
|
assert!(bufs[5].is_none());
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_parallel_reader_local_corrupt_falls_back_to_remote() {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("on")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
async {
|
|
const NUM_SHARDS: usize = 1;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
let readers = make_test_readers(DATA_SHARDS + PARITY_SHARDS, SHARD_SIZE, NUM_SHARDS, &hash_algo, &[], &[1]).await;
|
|
let read_costs = vec![
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Remote,
|
|
];
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new_with_metrics_path_and_read_costs(
|
|
readers,
|
|
erasure,
|
|
0,
|
|
NUM_SHARDS * BLOCK_SIZE,
|
|
None,
|
|
read_costs,
|
|
);
|
|
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
assert!(
|
|
matches!(&errs[1], Some(DiskError::Io(err)) if err.kind() == ErrorKind::InvalidData && err.to_string().contains("bitrot"))
|
|
);
|
|
assert_eq!(bufs[4].as_deref(), Some(&[4u8; SHARD_SIZE][..]));
|
|
assert!(bufs[5].is_none());
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn test_erasure_decode_local_first_preserves_output_order() {
|
|
temp_env::async_with_vars(
|
|
[
|
|
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some("on")),
|
|
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
|
|
],
|
|
async {
|
|
const DATA_SHARDS: usize = 4;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let total_data: Vec<u8> = (0..BLOCK_SIZE as u32).map(|i| i as u8).collect();
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let shard_size = erasure.shard_size();
|
|
let hash_algo = HashAlgorithm::HighwayHash256;
|
|
let shard_bufs = encode_test_object(&erasure, &total_data, shard_size, &hash_algo).await;
|
|
let readers = shard_bufs
|
|
.iter()
|
|
.map(|buf| Some(BitrotReader::new(Cursor::new(buf.clone()), shard_size, hash_algo.clone(), false)))
|
|
.collect();
|
|
let read_costs = vec![
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Remote,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::Local,
|
|
ShardReadCost::SameNode,
|
|
ShardReadCost::SameNode,
|
|
];
|
|
|
|
let mut output = Vec::new();
|
|
let (written, err) = erasure
|
|
.decode_with_read_costs(&mut output, readers, 0, total_data.len(), total_data.len(), read_costs)
|
|
.await;
|
|
|
|
assert!(err.is_none(), "unexpected decode error: {err:?}");
|
|
assert_eq!(written, total_data.len());
|
|
assert_eq!(output, total_data);
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_normal() {
|
|
const BLOCK_SIZE: usize = 64;
|
|
const NUM_SHARDS: usize = 2;
|
|
const DATA_SHARDS: usize = 8;
|
|
const PARITY_SHARDS: usize = 4;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let reader_offset = 0;
|
|
let mut readers = vec![];
|
|
for i in 0..(DATA_SHARDS + PARITY_SHARDS) {
|
|
readers.push(Some(
|
|
create_reader(SHARD_SIZE, NUM_SHARDS, (i % 256) as u8, &HashAlgorithm::HighwayHash256, false).await,
|
|
));
|
|
}
|
|
|
|
let erausre = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new(readers, erausre, reader_offset, NUM_SHARDS * BLOCK_SIZE);
|
|
|
|
for _ in 0..NUM_SHARDS {
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
bufs.into_iter().enumerate().for_each(|(index, buf)| {
|
|
if index < DATA_SHARDS {
|
|
assert!(buf.is_some());
|
|
let buf = buf.unwrap();
|
|
assert_eq!(SHARD_SIZE, buf.len());
|
|
assert_eq!(index as u8, buf[0]);
|
|
} else {
|
|
assert!(buf.is_none());
|
|
}
|
|
});
|
|
|
|
assert!(errs.iter().filter(|err| err.is_some()).count() == 0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_with_offline_disks() {
|
|
const OFFLINE_DISKS: usize = 2;
|
|
const NUM_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 8;
|
|
const PARITY_SHARDS: usize = 4;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let reader_offset = 0;
|
|
let mut readers = vec![];
|
|
for i in 0..(DATA_SHARDS + PARITY_SHARDS) {
|
|
if i < OFFLINE_DISKS {
|
|
// Two disks are offline
|
|
readers.push(None);
|
|
} else {
|
|
readers.push(Some(
|
|
create_reader(SHARD_SIZE, NUM_SHARDS, (i % 256) as u8, &HashAlgorithm::HighwayHash256, false).await,
|
|
));
|
|
}
|
|
}
|
|
|
|
let erausre = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new(readers, erausre, reader_offset, NUM_SHARDS * BLOCK_SIZE);
|
|
|
|
for _ in 0..NUM_SHARDS {
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
assert_eq!(OFFLINE_DISKS, errs.iter().filter(|err| err.is_some()).count());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_with_bitrots() {
|
|
const BITROT_DISKS: usize = 2;
|
|
const NUM_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 8;
|
|
const PARITY_SHARDS: usize = 4;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let reader_offset = 0;
|
|
let mut readers = vec![];
|
|
for i in 0..(DATA_SHARDS + PARITY_SHARDS) {
|
|
readers.push(Some(
|
|
create_reader(SHARD_SIZE, NUM_SHARDS, (i % 256) as u8, &HashAlgorithm::HighwayHash256, i < BITROT_DISKS).await,
|
|
));
|
|
}
|
|
|
|
let erausre = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new(readers, erausre, reader_offset, NUM_SHARDS * BLOCK_SIZE);
|
|
|
|
for _ in 0..NUM_SHARDS {
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
assert_eq!(
|
|
BITROT_DISKS,
|
|
errs.iter()
|
|
.filter(|err| {
|
|
match err {
|
|
Some(DiskError::Io(err)) => {
|
|
err.kind() == std::io::ErrorKind::InvalidData && err.to_string().contains("bitrot")
|
|
}
|
|
_ => false,
|
|
}
|
|
})
|
|
.count()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_replaces_timed_out_shard_with_parity() {
|
|
const NUM_SHARDS: usize = 1;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 1;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::None;
|
|
let readers = vec![
|
|
Some(BitrotReader::new(TestShardReader::TimedOut, SHARD_SIZE, hash_algo.clone(), false)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo.clone(),
|
|
false,
|
|
)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo,
|
|
false,
|
|
)),
|
|
];
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader = ParallelReader::new(readers, erasure, 0, NUM_SHARDS * BLOCK_SIZE);
|
|
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
|
|
assert!(parallel_reader.readers[0].is_none());
|
|
assert!(bufs[0].is_none());
|
|
assert!(bufs[1].is_some());
|
|
assert!(bufs[2].is_some());
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_uses_parity_without_waiting_for_pending_shard() {
|
|
const NUM_SHARDS: usize = 1;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 1;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::None;
|
|
let readers = vec![
|
|
Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo.clone(),
|
|
false,
|
|
)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo,
|
|
false,
|
|
)),
|
|
];
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader =
|
|
ParallelReader::new_with_read_timeout(readers, erasure, 0, NUM_SHARDS * BLOCK_SIZE, Duration::from_secs(60));
|
|
|
|
let (bufs, errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read())
|
|
.await
|
|
.expect("parallel reader should use ready parity without waiting for pending data shard");
|
|
|
|
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
|
|
assert!(parallel_reader.readers[0].is_none());
|
|
assert!(bufs[0].is_none());
|
|
assert!(bufs[1].is_some());
|
|
assert!(bufs[2].is_some());
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_retires_partially_read_shard_after_quorum() {
|
|
const NUM_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 1;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::None;
|
|
let readers = vec![
|
|
Some(BitrotReader::new(
|
|
TestShardReader::PartialThenPending {
|
|
data: vec![9_u8; SHARD_SIZE / 2],
|
|
emitted: false,
|
|
},
|
|
SHARD_SIZE,
|
|
hash_algo.clone(),
|
|
false,
|
|
)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo.clone(),
|
|
false,
|
|
)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo,
|
|
false,
|
|
)),
|
|
];
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader =
|
|
ParallelReader::new_with_read_timeout(readers, erasure, 0, NUM_SHARDS * BLOCK_SIZE, Duration::from_secs(60));
|
|
|
|
let (bufs, errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read())
|
|
.await
|
|
.expect("parallel reader should use parity without waiting for a partially read shard");
|
|
|
|
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
|
|
assert!(parallel_reader.readers[0].is_none());
|
|
assert!(bufs[0].is_none());
|
|
assert!(bufs[1].is_some());
|
|
assert!(bufs[2].is_some());
|
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
|
|
|
let (next_bufs, next_errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read())
|
|
.await
|
|
.expect("retired partially read shard should not block the next stripe");
|
|
|
|
assert!(matches!(&next_errs[0], Some(DiskError::FileNotFound)));
|
|
assert!(next_bufs[0].is_none());
|
|
assert!(next_bufs[1].is_some());
|
|
assert!(next_bufs[2].is_some());
|
|
assert_eq!(DATA_SHARDS, next_bufs.iter().filter(|buf| buf.is_some()).count());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parallel_reader_retires_pending_shard_when_quorum_needs_it() {
|
|
const NUM_SHARDS: usize = 1;
|
|
const BLOCK_SIZE: usize = 64;
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 1;
|
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
|
|
|
let hash_algo = HashAlgorithm::None;
|
|
let readers = vec![
|
|
Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)),
|
|
Some(BitrotReader::new(
|
|
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])),
|
|
SHARD_SIZE,
|
|
hash_algo,
|
|
false,
|
|
)),
|
|
None,
|
|
];
|
|
|
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
|
let mut parallel_reader =
|
|
ParallelReader::new_with_read_timeout(readers, erasure, 0, NUM_SHARDS * BLOCK_SIZE, Duration::from_millis(20));
|
|
|
|
let started = std::time::Instant::now();
|
|
let (bufs, errs) = parallel_reader.read().await;
|
|
|
|
assert!(started.elapsed() < Duration::from_secs(1));
|
|
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
|
|
assert!(matches!(&errs[2], Some(DiskError::FileNotFound)));
|
|
assert!(parallel_reader.readers[0].is_none());
|
|
assert!(bufs[0].is_none());
|
|
assert!(bufs[1].is_some());
|
|
assert!(bufs[2].is_none());
|
|
assert!(bufs.iter().filter(|buf| buf.is_some()).count() < DATA_SHARDS);
|
|
}
|
|
|
|
#[test]
|
|
fn test_zero_read_timeout_disables_scheduled_hedging() {
|
|
assert_eq!(shard_read_hedge_delay(Duration::ZERO), None);
|
|
assert_eq!(shard_read_hedge_delay(Duration::from_millis(50)), Some(Duration::from_millis(50)));
|
|
assert_eq!(shard_read_hedge_delay(Duration::from_secs(60)), Some(Duration::from_millis(100)));
|
|
}
|
|
|
|
async fn create_reader(
|
|
shard_size: usize,
|
|
num_shards: usize,
|
|
value: u8,
|
|
hash_algo: &HashAlgorithm,
|
|
bitrot: bool,
|
|
) -> BitrotReader<Cursor<Vec<u8>>> {
|
|
let len = (hash_algo.size() + shard_size) * num_shards;
|
|
let buf = Cursor::new(vec![0u8; len]);
|
|
|
|
let mut writer = BitrotWriter::new(buf, shard_size, hash_algo.clone());
|
|
for _ in 0..num_shards {
|
|
writer.write(vec![value; shard_size].as_slice()).await.unwrap();
|
|
}
|
|
|
|
let mut buf = writer.into_inner().into_inner();
|
|
|
|
if bitrot {
|
|
for i in 0..num_shards {
|
|
// Rot one bit for each shard
|
|
buf[i * (hash_algo.size() + shard_size)] ^= 1;
|
|
}
|
|
}
|
|
|
|
let reader_cursor = Cursor::new(buf);
|
|
BitrotReader::new(reader_cursor, shard_size, hash_algo.clone(), false)
|
|
}
|
|
|
|
async fn make_test_readers(
|
|
total_shards: usize,
|
|
shard_size: usize,
|
|
num_shards: usize,
|
|
hash_algo: &HashAlgorithm,
|
|
missing: &[usize],
|
|
corrupt: &[usize],
|
|
) -> Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> {
|
|
let mut readers = Vec::with_capacity(total_shards);
|
|
for index in 0..total_shards {
|
|
if missing.contains(&index) {
|
|
readers.push(None);
|
|
} else {
|
|
readers.push(Some(
|
|
create_reader(shard_size, num_shards, (index % 256) as u8, hash_algo, corrupt.contains(&index)).await,
|
|
));
|
|
}
|
|
}
|
|
readers
|
|
}
|
|
|
|
async fn encode_test_object(erasure: &Erasure, data: &[u8], shard_size: usize, hash_algo: &HashAlgorithm) -> Vec<Vec<u8>> {
|
|
let total_shards = erasure.data_shards + erasure.parity_shards;
|
|
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
|
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
|
.collect();
|
|
|
|
let shards = erasure.encode_data(data).unwrap();
|
|
for (index, shard) in shards.iter().enumerate() {
|
|
shard_writers[index].write(shard).await.unwrap();
|
|
}
|
|
|
|
shard_writers
|
|
.into_iter()
|
|
.map(|writer| writer.into_inner().into_inner())
|
|
.collect()
|
|
}
|
|
}
|