test(ecstore): complete EC validation coverage gate

* test(ecstore): complete EC validation coverage gate

* test(ecstore): stabilize validation suite after rebase

* test(ecstore): fix rio-v2 clippy lint
This commit is contained in:
Zhengchao An
2026-07-09 03:12:48 +08:00
committed by GitHub
parent 7c701d9f2c
commit ed81d2f6b8
18 changed files with 4916 additions and 264 deletions
+164 -5
View File
@@ -533,10 +533,9 @@ impl BitrotWriterWrapper {
#[cfg(test)]
mod tests {
use super::BitrotReader;
use super::BitrotWriter;
use super::bitrot_shard_file_size;
use super::{
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, bitrot_shard_file_size, bitrot_verify, write_all_vectored,
};
use rustfs_utils::HashAlgorithm;
use std::io::{Cursor, IoSlice};
use std::sync::{
@@ -544,7 +543,7 @@ mod tests {
atomic::{AtomicUsize, Ordering},
};
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
use tokio::io::{AsyncWrite, AsyncWriteExt};
#[derive(Default)]
struct VectoredCountingWriter {
@@ -607,6 +606,78 @@ mod tests {
}
}
#[derive(Default)]
struct LimitedVectoredWriter {
max_write: usize,
writes: Vec<u8>,
}
impl AsyncWrite for LimitedVectoredWriter {
fn poll_write(mut self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
let len = buf.len().min(self.max_write);
self.writes.extend_from_slice(&buf[..len]);
Poll::Ready(Ok(len))
}
fn poll_flush(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_write_vectored(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<std::io::Result<usize>> {
let mut remaining = self.max_write;
let mut written = 0;
for buf in bufs {
if remaining == 0 {
break;
}
let len = buf.len().min(remaining);
self.writes.extend_from_slice(&buf[..len]);
remaining -= len;
written += len;
}
Poll::Ready(Ok(written))
}
fn is_write_vectored(&self) -> bool {
true
}
}
#[tokio::test]
async fn vectored_test_writers_cover_fallback_flush_and_shutdown_paths() {
let mut counting = VectoredCountingWriter::default();
assert!(counting.is_write_vectored());
let err = counting
.write(b"plain write")
.await
.expect_err("plain writes should be rejected by vectored-only test writer");
assert_eq!(err.to_string(), "poll_write should not be used");
counting.flush().await.expect("flush should succeed");
counting.shutdown().await.expect("shutdown should succeed");
let mut limited = LimitedVectoredWriter {
max_write: 2,
writes: Vec::new(),
};
assert!(limited.is_write_vectored());
let written = limited
.write(b"plain")
.await
.expect("limited writer should accept partial plain write");
assert_eq!(written, 2);
assert_eq!(limited.writes, b"pl");
limited.flush().await.expect("flush should succeed");
limited.shutdown().await.expect("shutdown should succeed");
}
#[tokio::test]
async fn test_bitrot_read_write_ok() {
let data = b"hello world! this is a test shard.";
@@ -647,6 +718,94 @@ mod tests {
assert_eq!(data, &out[..]);
}
#[tokio::test]
async fn bitrot_verify_accepts_valid_shard_file_and_rejects_size_or_hash_mismatch() {
let data = b"bitrot verify covers every shard";
let shard_size = 8;
let algo = HashAlgorithm::HighwayHash256S;
let writer = Cursor::new(Vec::new());
let mut bitrot_writer = BitrotWriter::new(writer, shard_size, algo.clone());
for chunk in data.chunks(shard_size) {
bitrot_writer.write(chunk).await.unwrap();
}
let written = bitrot_writer.into_inner().into_inner();
bitrot_verify(Cursor::new(written.clone()), written.len(), data.len(), algo.clone(), shard_size)
.await
.expect("valid bitrot shard file should verify");
let err = bitrot_verify(Cursor::new(written.clone()), written.len() - 1, data.len(), algo.clone(), shard_size)
.await
.expect_err("wrong file size must be rejected before reading data");
assert!(err.to_string().contains("size mismatch"));
let mut corrupt = written;
let last = corrupt.len() - 1;
corrupt[last] ^= 0x80;
let err = bitrot_verify(
Cursor::new(corrupt),
super::bitrot_shard_file_size(data.len(), shard_size, algo.clone()),
data.len(),
algo,
shard_size,
)
.await
.expect_err("hash mismatch must reject corrupted data");
assert!(err.to_string().contains("hash mismatch"));
}
#[tokio::test]
async fn write_all_vectored_retries_partial_hash_and_data_writes_and_rejects_zero_write() {
let mut writer = LimitedVectoredWriter {
max_write: 2,
writes: Vec::new(),
};
write_all_vectored(&mut writer, b"hash", b"payload").await.unwrap();
assert_eq!(writer.writes, b"hashpayload");
let mut zero_writer = LimitedVectoredWriter {
max_write: 0,
writes: Vec::new(),
};
let err = write_all_vectored(&mut zero_writer, b"hash", b"payload")
.await
.expect_err("zero-byte vectored writes must fail");
assert_eq!(err.kind(), std::io::ErrorKind::WriteZero);
}
#[tokio::test]
async fn bitrot_reader_rejects_output_buffers_larger_than_shard_size() {
let mut reader = BitrotReader::new(Cursor::new(Vec::<u8>::new()), 4, HashAlgorithm::None, false);
let mut out = [0u8; 5];
let err = reader
.read(&mut out)
.await
.expect_err("oversized output buffers must be rejected before reading");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(err.to_string().contains("exceeds shard size"));
}
#[tokio::test]
async fn custom_writer_other_forwards_io_and_wrapper_reports_non_inline_state() {
let writer = CountingWriter::default();
let mut custom = CustomWriter::new_tokio_writer(writer);
assert!(custom.get_inline_data().is_none());
assert!(!custom.is_write_vectored());
custom.write_all(b"abc").await.unwrap();
custom.flush().await.unwrap();
custom.shutdown().await.unwrap();
assert!(custom.into_inline_data().is_none());
let other = BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(CountingWriter::default()), 8, HashAlgorithm::None);
assert!(format!("{other:?}").contains("Other"));
assert!(other.into_inline_data().is_none());
let inline = BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), 8, HashAlgorithm::None);
assert!(format!("{inline:?}").contains("InlineBuffer"));
}
#[tokio::test]
async fn test_bitrot_read_hash_mismatch() {
let data = b"test data for bitrot";
+363 -76
View File
@@ -755,12 +755,6 @@ where
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();
@@ -808,8 +802,6 @@ where
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) {
@@ -888,9 +880,6 @@ where
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() {
@@ -929,73 +918,11 @@ where
}
}
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 {
if success >= self.data_shards {
break;
}
if success >= self.data_shards && (!needs_reconstruction_verification || success + pending < target_success) {
break;
}
if success + pending < target_success {
if success + pending < self.data_shards {
break;
}
}
@@ -1869,6 +1796,7 @@ mod tests {
erasure::coding::{BitrotReader, BitrotWriter},
};
use rustfs_utils::HashAlgorithm;
use std::future::Future;
use std::io::Cursor;
use std::pin::Pin;
use std::sync::{
@@ -1877,6 +1805,7 @@ mod tests {
};
use std::task::{Context, Poll};
use tokio::io::ReadBuf;
use tokio::time::{Instant as TokioInstant, Sleep};
type BoxedShardReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
@@ -1949,8 +1878,16 @@ mod tests {
enum TestShardReader {
Ready(Cursor<Vec<u8>>),
ReadyAt {
cursor: Cursor<Vec<u8>>,
ready_at: TokioInstant,
sleep: Option<Pin<Box<Sleep>>>,
},
Pending,
PartialThenPending { data: Vec<u8>, emitted: bool },
PartialThenPending {
data: Vec<u8>,
emitted: bool,
},
TimedOut,
}
@@ -1958,6 +1895,15 @@ mod tests {
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::ReadyAt { cursor, ready_at, sleep } => {
if TokioInstant::now() < *ready_at {
let sleeper = sleep.get_or_insert_with(|| Box::pin(tokio::time::sleep_until(*ready_at)));
if sleeper.as_mut().poll(cx).is_pending() {
return Poll::Pending;
}
}
Pin::new(cursor).poll_read(cx, buf)
}
TestShardReader::Pending => {
cx.waker().wake_by_ref();
Poll::Pending
@@ -1978,6 +1924,49 @@ mod tests {
}
}
struct FailingEmitWriter;
impl AsyncWrite for FailingEmitWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<io::Result<usize>> {
Poll::Ready(Err(io::Error::new(ErrorKind::BrokenPipe, "injected emit failure")))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[test]
fn parallel_reader_constructor_variants_preserve_read_cost_and_verification_flags() {
let erasure = Erasure::new(2, 1, 64);
let readers = vec![None, None, None];
let read_costs = vec![ShardReadCost::Local, ShardReadCost::Remote, ShardReadCost::Unknown];
let reader: ParallelReader<Cursor<Vec<u8>>> =
ParallelReader::new_with_metrics_path_read_costs_and_reconstruction_verification(
readers,
erasure.clone(),
64,
17,
Some("constructor-test"),
read_costs.clone(),
);
assert_eq!(reader.offset, reader.shard_size);
assert_eq!(reader.read_costs, read_costs);
assert!(reader.verify_reconstruction);
let defaulted: ParallelReader<Cursor<Vec<u8>>> =
ParallelReader::new_with_metrics_path_and_reconstruction_verification(vec![None, None, None], erasure, 0, 64, None);
assert_eq!(defaulted.read_costs, vec![ShardReadCost::Unknown; 3]);
assert!(defaulted.verify_reconstruction);
}
#[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])];
@@ -2011,6 +2000,111 @@ mod tests {
assert!(out.is_empty());
}
#[tokio::test]
async fn test_write_data_blocks_rejects_offset_length_overflow() {
let blocks = vec![Some(vec![1, 2, 3, 4])];
let mut out = Vec::new();
let err = write_data_blocks(&mut out, &blocks, 1, usize::MAX, 1).await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(out.is_empty());
}
#[tokio::test]
async fn test_write_data_blocks_rejects_missing_data_shard_even_when_total_bytes_are_available() {
let blocks = vec![None, 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::UnexpectedEof);
assert!(out.is_empty());
}
#[tokio::test]
async fn test_write_data_blocks_propagates_writer_emit_failure() {
let blocks = vec![Some(vec![1, 2, 3, 4])];
let mut writer = FailingEmitWriter;
let err = write_data_blocks(&mut writer, &blocks, 1, 0, 4)
.await
.expect_err("writer failure must fail the decoded emit path");
assert_eq!(err.kind(), ErrorKind::BrokenPipe);
assert_eq!(err.to_string(), "injected emit failure");
}
#[tokio::test]
async fn test_erasure_decode_rejects_reader_count_and_range_overflow() {
let erasure = Erasure::new(2, 1, 64);
let mut output = Vec::new();
let (written, err) = erasure
.decode(&mut output, Vec::<Option<BitrotReader<Cursor<Vec<u8>>>>>::new(), 0, 1, 1)
.await;
assert_eq!(written, 0);
assert_eq!(err.expect("reader count mismatch should fail").kind(), ErrorKind::InvalidInput);
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = vec![None, None, None];
let (written, err) = erasure.decode(&mut output, readers, usize::MAX, 1, usize::MAX).await;
assert_eq!(written, 0);
assert_eq!(err.expect("offset overflow should fail").kind(), ErrorKind::InvalidInput);
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = vec![None, None, None];
let (written, err) = erasure.decode(&mut output, readers, 2, 8, 9).await;
assert_eq!(written, 0);
assert_eq!(err.expect("range beyond total length should fail").kind(), ErrorKind::InvalidInput);
}
#[tokio::test]
async fn test_erasure_decode_with_read_costs_restores_missing_data_shard_range() {
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 readers = vec![
None,
Some(BitrotReader::new(
Cursor::new(encoded[1].to_vec()),
shard_size,
HashAlgorithm::None,
false,
)),
Some(BitrotReader::new(
Cursor::new(encoded[DATA_SHARDS].to_vec()),
shard_size,
HashAlgorithm::None,
false,
)),
Some(BitrotReader::new(
Cursor::new(encoded[DATA_SHARDS + 1].to_vec()),
shard_size,
HashAlgorithm::None,
false,
)),
];
let read_costs = vec![
ShardReadCost::Local,
ShardReadCost::SameNode,
ShardReadCost::Remote,
ShardReadCost::Unknown,
];
let mut output = Vec::new();
let (written, err) = erasure
.decode_with_read_costs(&mut output, readers, 5, 37, data.len(), read_costs)
.await;
assert!(err.is_none(), "missing data shard should reconstruct with parity: {err:?}");
assert_eq!(written, 37);
assert_eq!(output, data[5..42]);
}
/// 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
@@ -3007,6 +3101,58 @@ mod tests {
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
}
#[tokio::test]
#[serial_test::serial]
async fn parallel_reader_records_metrics_for_observe_and_locality_policy_modes() {
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;
for (mode, expected_data_slots) in [("off", vec![0, 1, 2, 3]), ("on", vec![2, 3, 4, 5])] {
temp_env::async_with_vars(
[
(ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING, Some(mode)),
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
],
async {
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
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,
Some(GET_OBJECT_PATH_LEGACY_DUPLEX),
read_costs,
);
let (bufs, errs) = parallel_reader.read().await;
assert_eq!(parallel_reader.metrics_path, Some(GET_OBJECT_PATH_LEGACY_DUPLEX));
assert!(errs.iter().all(Option::is_none));
for index in expected_data_slots {
assert_eq!(bufs[index].as_deref(), Some(&[(index % 256) as u8; SHARD_SIZE][..]));
}
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
},
)
.await;
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_parallel_reader_local_first_avoids_remote_when_local_quorum_exists() {
@@ -3342,6 +3488,101 @@ mod tests {
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
}
#[tokio::test]
async fn test_parallel_reader_schedules_extra_parity_for_reconstruction_verification() {
const BLOCK_SIZE: usize = 64;
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
let hash_algo = HashAlgorithm::None;
let readers = vec![
None,
Some(BitrotReader::new(
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE])),
SHARD_SIZE,
hash_algo.clone(),
false,
)),
Some(BitrotReader::new(
TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE])),
SHARD_SIZE,
hash_algo.clone(),
false,
)),
Some(BitrotReader::new(
TestShardReader::Ready(Cursor::new(vec![3_u8; SHARD_SIZE])),
SHARD_SIZE,
hash_algo,
false,
)),
];
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
let mut parallel_reader =
ParallelReader::new_with_metrics_path_and_reconstruction_verification(readers, erasure, 0, BLOCK_SIZE, None);
let (bufs, errs) = parallel_reader.read().await;
assert!(errs[0].is_none() || matches!(&errs[0], Some(DiskError::FileNotFound)));
assert!(bufs[0].is_none());
assert_eq!(3, bufs.iter().filter(|buf| buf.is_some()).count());
assert!(bufs[1].is_some());
assert!(bufs[2].is_some());
assert!(bufs[3].is_some());
}
#[tokio::test]
#[serial_test::serial]
async fn test_parallel_reader_records_metrics_for_success_missing_error_and_timeout() {
const NUM_SHARDS: usize = 1;
const BLOCK_SIZE: usize = 64;
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let hash_algo = HashAlgorithm::None;
let readers = vec![
Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)),
Some(BitrotReader::new(TestShardReader::TimedOut, 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,
)),
None,
];
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
let mut parallel_reader = ParallelReader::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
readers,
erasure,
0,
NUM_SHARDS * BLOCK_SIZE,
Some(GET_OBJECT_PATH_LEGACY_DUPLEX),
vec![
ShardReadCost::Local,
ShardReadCost::SameNode,
ShardReadCost::Remote,
ShardReadCost::Unknown,
],
Duration::from_millis(20),
false,
);
let started = std::time::Instant::now();
let (bufs, errs) = parallel_reader.read().await;
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
assert!(started.elapsed() < Duration::from_secs(1));
assert_eq!(parallel_reader.metrics_path, Some(GET_OBJECT_PATH_LEGACY_DUPLEX));
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
assert!(matches!(&errs[1], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
assert!(matches!(&errs[3], Some(Error::FileNotFound)));
assert_eq!(bufs[2].as_deref(), Some(&[2_u8; SHARD_SIZE][..]));
}
#[tokio::test]
async fn test_parallel_reader_uses_parity_without_waiting_for_pending_shard() {
const NUM_SHARDS: usize = 1;
@@ -3383,6 +3624,39 @@ mod tests {
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
}
#[tokio::test]
async fn test_parallel_reader_drains_completed_shards_after_quorum() {
const NUM_SHARDS: usize = 1;
const BLOCK_SIZE: usize = 64;
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 3;
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
let ready_at = TokioInstant::now() + Duration::from_millis(250);
let readers = (0..DATA_SHARDS + PARITY_SHARDS)
.map(|index| {
Some(BitrotReader::new(
TestShardReader::ReadyAt {
cursor: Cursor::new(vec![index as u8; SHARD_SIZE * NUM_SHARDS]),
ready_at,
sleep: None,
},
SHARD_SIZE,
HashAlgorithm::None,
false,
))
})
.collect();
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(2));
let (bufs, errs) = parallel_reader.read().await;
assert!(errs.iter().all(Option::is_none));
assert!(bufs.iter().filter(|buf| buf.is_some()).count() >= DATA_SHARDS);
}
#[tokio::test]
async fn test_parallel_reader_retires_partially_read_shard_after_quorum() {
const NUM_SHARDS: usize = 2;
@@ -3484,6 +3758,19 @@ mod tests {
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)));
temp_env::with_var(ENV_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT, None::<&str>, || {
assert_eq!(get_decode_stripe_prefetch_count(), DEFAULT_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT);
});
temp_env::with_var(ENV_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT, Some("3"), || {
assert_eq!(get_decode_stripe_prefetch_count(), 3);
});
temp_env::with_var(ENV_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE, None::<&str>, || {
assert_eq!(is_bitrot_decode_overlap_enabled(), DEFAULT_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE);
});
temp_env::with_var(ENV_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE, Some("true"), || {
assert!(is_bitrot_decode_overlap_enabled());
});
}
async fn create_reader(
@@ -789,7 +789,7 @@ fn emit_data_shards(state: &StripeReadState, data_shards: usize, block_size: usi
fn reserve_output_capacity(output: &mut Vec<u8>, target_capacity: usize) {
if output.capacity() < target_capacity {
output.reserve(target_capacity - output.capacity());
output.reserve(target_capacity.saturating_sub(output.len()));
}
}
@@ -822,14 +822,14 @@ fn emit_data_shards_into(
mod tests {
use super::*;
use crate::erasure::codec::bridge::{
CodecStreamingDecodeEngine, ErasureDecodeEngine, LegacyEcDecodeEngine, RustfsCodecDecodeEngine,
CodecStreamingDecodeEngine, DecodeWorkspace, ErasureDecodeEngine, LegacyEcDecodeEngine, RustfsCodecDecodeEngine,
};
use crate::erasure::coding::decode::ParallelReader;
use crate::erasure::coding::{BitrotReader, BitrotWriter, Erasure};
use crate::set_disk::shard_source::{ShardSlot, StripeReadState};
use rustfs_utils::HashAlgorithm;
use std::collections::VecDeque;
use std::future::pending;
use std::future::{pending, poll_fn};
use std::io::Cursor;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -855,6 +855,42 @@ mod tests {
dropped: Arc<AtomicUsize>,
}
enum PollStep {
Data(Vec<u8>),
Empty,
Error,
Pending,
}
struct ScriptedAsyncReader {
steps: VecDeque<PollStep>,
}
impl ScriptedAsyncReader {
fn new(steps: Vec<PollStep>) -> Self {
Self { steps: steps.into() }
}
}
impl AsyncRead for ScriptedAsyncReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
match self.steps.pop_front() {
Some(PollStep::Data(data)) => {
let copy_len = data.len().min(buf.remaining());
buf.put_slice(&data[..copy_len]);
Poll::Ready(Ok(()))
}
Some(PollStep::Empty) => Poll::Ready(Ok(())),
Some(PollStep::Error) => Poll::Ready(Err(io::Error::other("scripted read failure"))),
Some(PollStep::Pending) => {
cx.waker().wake_by_ref();
Poll::Pending
}
None => Poll::Ready(Ok(())),
}
}
}
impl Drop for BlockingSourceDropGuard {
fn drop(&mut self) {
self.dropped.fetch_add(1, Ordering::SeqCst);
@@ -885,6 +921,62 @@ mod tests {
}
}
#[derive(Clone)]
struct NoopDecodeEngine {
data_shards: usize,
block_size: usize,
}
struct NoopDecodeWorkspace {
shard_len: usize,
}
impl DecodeWorkspace for NoopDecodeWorkspace {
fn shard_len(&self) -> usize {
self.shard_len
}
}
impl ErasureDecodeEngine for NoopDecodeEngine {
type Workspace = NoopDecodeWorkspace;
fn data_shards(&self) -> usize {
self.data_shards
}
fn parity_shards(&self) -> usize {
0
}
fn block_size(&self) -> usize {
self.block_size
}
fn engine_name(&self) -> &'static str {
"noop"
}
fn supports_progressive_decode(&self) -> bool {
false
}
fn supports_aligned_shards(&self) -> bool {
false
}
fn prepare_workspace(&self, shard_len: usize) -> io::Result<Self::Workspace> {
Ok(NoopDecodeWorkspace { shard_len })
}
fn reconstruct_into(
&self,
_shards: &mut [Option<Vec<u8>>],
_workspace: &mut Self::Workspace,
) -> io::Result<&'static str> {
Ok("noop_called")
}
}
fn source_from_data(erasure: &Erasure, data: &[u8], missing_indexes: &[usize]) -> VecStripeSource {
let read_quorum = erasure.data_shards;
let stripes = data
@@ -998,6 +1090,309 @@ mod tests {
});
}
#[test]
fn erasure_decode_reader_rejects_invalid_engine_shape() {
let source = VecStripeSource {
stripes: VecDeque::new(),
read_quorum: 1,
read_count: None,
};
let err = match ErasureDecodeReader::new(
source,
NoopDecodeEngine {
data_shards: 0,
block_size: 16,
},
1,
) {
Ok(_) => panic!("zero data shard engine must be rejected"),
Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::InvalidInput);
let source = VecStripeSource {
stripes: VecDeque::new(),
read_quorum: 1,
read_count: None,
};
let err = match ErasureDecodeReader::new_with_metrics_path(
source,
NoopDecodeEngine {
data_shards: 1,
block_size: 0,
},
1,
GET_OBJECT_PATH_CODEC_STREAMING,
) {
Ok(_) => panic!("zero block size engine must be rejected"),
Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::InvalidInput);
}
#[test]
fn erasure_decode_reader_reusable_buffer_bounds_and_missing_worker_parts_fail_closed() {
let erasure = Erasure::new(2, 1, 16);
let source = source_from_data(&erasure, b"fill worker missing fields", &[]);
let engine = LegacyEcDecodeEngine::new(erasure.clone());
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
1,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::SingleInFlight,
)
.expect("reader should be constructed");
reader.push_reusable_output_buf(Vec::new());
assert!(reader.reusable_output_bufs.is_empty());
for _ in 0..reader.max_reusable_output_bufs() + 2 {
reader.push_reusable_output_buf(Vec::with_capacity(8));
}
assert_eq!(reader.reusable_output_bufs.len(), reader.max_reusable_output_bufs());
reader.source = None;
let err = match reader.fill_worker_tx() {
Ok(_) => panic!("missing source must fail closed"),
Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::BrokenPipe);
let source = source_from_data(&erasure, b"fill worker missing engine", &[]);
let engine = LegacyEcDecodeEngine::new(erasure.clone());
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
1,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::SingleInFlight,
)
.expect("reader should be constructed");
reader.engine = None;
let err = match reader.fill_worker_tx() {
Ok(_) => panic!("missing engine must fail closed"),
Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::BrokenPipe);
assert!(reader.source.is_some());
let source = source_from_data(&erasure, b"fill worker missing workspace", &[]);
let engine = LegacyEcDecodeEngine::new(erasure);
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
1,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::SingleInFlight,
)
.expect("reader should be constructed");
reader.workspace = None;
let err = match reader.fill_worker_tx() {
Ok(_) => panic!("missing workspace must fail closed"),
Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::BrokenPipe);
assert!(reader.source.is_some());
assert!(reader.engine.is_some());
}
#[tokio::test]
async fn erasure_decode_reader_poll_fill_result_rejects_cancelled_and_empty_fill() {
let erasure = Erasure::new(2, 1, 16);
let source = source_from_data(&erasure, b"cancelled fill", &[]);
let engine = LegacyEcDecodeEngine::new(erasure.clone());
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
1,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::SingleInFlight,
)
.expect("reader should be constructed");
let (_sender, receiver) = oneshot::channel();
drop(_sender);
reader.fill = Some(receiver);
let err = poll_fn(|cx| reader.poll_fill_result(cx))
.await
.expect_err("cancelled fill result must fail");
assert_eq!(err.kind(), ErrorKind::Other);
let source = source_from_data(&erasure, b"empty fill", &[]);
let engine = LegacyEcDecodeEngine::new(erasure);
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
1,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::SingleInFlight,
)
.expect("reader should be constructed");
let (sender, receiver) = oneshot::channel();
assert!(
sender
.send(FillResult {
result: Ok(Some(Vec::new())),
queued_buffers: VecDeque::new(),
reusable_buffers: Vec::new(),
deferred_error: None,
})
.is_ok(),
"test fill result should send"
);
reader.fill = Some(receiver);
let err = poll_fn(|cx| reader.poll_fill_result(cx))
.await
.expect_err("empty buffer with remaining bytes must fail");
assert_eq!(err.kind(), ErrorKind::Other);
}
#[tokio::test]
async fn erasure_decode_reader_poll_fill_result_fails_when_request_queue_is_full() {
let erasure = Erasure::new(2, 1, 16);
let source = source_from_data(&erasure, b"queue full", &[]);
let engine = LegacyEcDecodeEngine::new(erasure);
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
1,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::SingleInFlight,
)
.expect("reader should be constructed");
let (tx, rx) = mpsc::channel(1);
let (response, _receiver) = oneshot::channel();
assert!(
tx.try_send(FillRequest {
remaining: 1,
reusable_buffers: Vec::new(),
response,
})
.is_ok(),
"test fill request should occupy the bounded queue"
);
reader.worker = Some(FillWorker {
tx,
task: tokio::spawn(async move {
pending::<()>().await;
drop(rx);
}),
});
reader.reusable_output_bufs.push(Vec::with_capacity(8));
let err = poll_fn(|cx| reader.poll_fill_result(cx))
.await
.expect_err("full fill request queue must fail closed");
assert_eq!(err.kind(), ErrorKind::BrokenPipe);
assert_eq!(reader.reusable_output_bufs.len(), 1);
}
#[tokio::test]
async fn erasure_decode_reader_prefetch_queues_fill_when_output_is_not_drained() {
let erasure = Erasure::new(2, 1, 16);
let source = source_from_data(&erasure, b"queued prefetch", &[]);
let engine = LegacyEcDecodeEngine::new(erasure);
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
4,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::DualInFlight,
)
.expect("reader should be constructed");
let (sender, receiver) = oneshot::channel();
assert!(
sender
.send(FillResult {
result: Ok(Some(vec![3, 5, 8])),
queued_buffers: VecDeque::new(),
reusable_buffers: Vec::new(),
deferred_error: None,
})
.is_ok(),
"ready fill result should send"
);
reader.fill = Some(receiver);
reader.output_buf = vec![1, 2];
reader.output_pos = 1;
poll_fn(|cx| reader.poll_prefetch(cx))
.await
.expect("ready prefetch should be queued while output remains");
assert_eq!(reader.output_buf, vec![1, 2]);
assert_eq!(reader.output_pos, 1);
assert_eq!(reader.prefetched_bufs.pop_front(), Some(vec![3, 5, 8]));
reader.prefetched_bufs.push_back(vec![3, 5, 8]);
reader.output_pos = reader.output_buf.len();
let mut output = [0u8; 3];
reader
.read_exact(&mut output)
.await
.expect("queued prefetch should become reader output after the old buffer drains");
assert_eq!(output, [3, 5, 8]);
}
#[tokio::test]
async fn erasure_decode_reader_prefetch_defers_error_until_output_drains() {
let erasure = Erasure::new(2, 1, 16);
let source = source_from_data(&erasure, b"deferred prefetch error", &[]);
let engine = LegacyEcDecodeEngine::new(erasure);
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
4,
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::DualInFlight,
)
.expect("reader should be constructed");
let (sender, receiver) = oneshot::channel();
assert!(
sender
.send(FillResult {
result: Err(io::Error::new(ErrorKind::UnexpectedEof, "deferred fill error")),
queued_buffers: VecDeque::new(),
reusable_buffers: Vec::new(),
deferred_error: None,
})
.is_ok(),
"ready fill error should send"
);
reader.fill = Some(receiver);
reader.output_buf = vec![1, 2];
reader.output_pos = 1;
poll_fn(|cx| reader.poll_prefetch(cx))
.await
.expect("prefetch error should be deferred while output remains");
assert_eq!(
reader
.prefetch_error
.as_ref()
.expect("prefetch error should be retained")
.kind(),
ErrorKind::UnexpectedEof
);
}
#[test]
fn noop_decode_engine_test_methods_report_static_shape() {
let engine = NoopDecodeEngine {
data_shards: 3,
block_size: 96,
};
let workspace = engine.prepare_workspace(32).expect("workspace should be created");
assert_eq!(workspace.shard_len(), 32);
assert_eq!(engine.parity_shards(), 0);
assert!(!engine.supports_progressive_decode());
assert!(!engine.supports_aligned_shards());
}
#[test]
#[serial_test::serial]
fn erasure_decode_reader_caches_stage_metrics_enabled_at_construction() {
@@ -1033,6 +1428,102 @@ mod tests {
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
}
#[tokio::test]
#[serial_test::serial]
async fn erasure_decode_reader_records_metrics_while_copying_output() {
let erasure = Erasure::new(4, 2, 16);
let data = (0..48u8).collect::<Vec<_>>();
let read_count = Arc::new(AtomicUsize::new(0));
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let mut source = source_from_data(&erasure, &data, &[]);
source.read_count = Some(Arc::clone(&read_count));
let engine = LegacyEcDecodeEngine::new(erasure);
let mut reader = ErasureDecodeReader::new_with_fill_policy(
source,
engine,
data.len(),
GET_OBJECT_PATH_CODEC_STREAMING,
FillPolicy::DualInFlight,
)
.expect("reader should be constructed");
assert!(reader.stage_metrics_enabled);
let mut first = [0u8; 1];
let read = reader
.read(&mut first)
.await
.expect("metrics-enabled reader should produce first byte");
timeout(Duration::from_secs(1), async {
while read_count.load(Ordering::SeqCst) < 3 {
yield_now().await;
}
})
.await
.expect("metrics-enabled dual inflight reader should prefetch future stripes");
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
assert_eq!(read, 1);
assert_eq!(first[0], data[0]);
}
#[tokio::test]
#[serial_test::serial]
async fn sync_erasure_decode_reader_records_metric_poll_outcomes() {
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let mut reader = SyncErasureDecodeReader::new_with_metrics_path(
ScriptedAsyncReader::new(vec![
PollStep::Pending,
PollStep::Data(vec![3, 5]),
PollStep::Empty,
PollStep::Error,
]),
GET_OBJECT_PATH_CODEC_STREAMING,
);
assert!(reader.stage_metrics_enabled);
let mut output = [0u8; 4];
let read = reader
.read(&mut output)
.await
.expect("pending reader should wake and then return data");
assert_eq!(read, 2);
assert_eq!(&output[..read], &[3, 5]);
let read = reader.read(&mut output).await.expect("empty ready poll should return EOF");
assert_eq!(read, 0);
let err = reader
.read(&mut output)
.await
.expect_err("scripted read error should surface");
assert_eq!(err.kind(), ErrorKind::Other);
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
}
#[tokio::test]
#[serial_test::serial]
async fn sync_erasure_decode_reader_fails_closed_on_poisoned_lock() {
let mut reader = SyncErasureDecodeReader::new(Cursor::new(vec![1u8]));
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = reader.inner.lock().expect("lock should be acquired before poison");
panic!("poison sync reader lock");
}));
std::panic::set_hook(previous_hook);
assert!(poison_result.is_err(), "test setup should poison the reader lock");
let mut output = [0u8; 1];
let err = reader
.read(&mut output)
.await
.expect_err("poisoned sync reader lock must fail closed");
assert_eq!(err.kind(), ErrorKind::Other);
assert!(err.to_string().contains("lock poisoned"));
}
#[tokio::test]
async fn erasure_decode_reader_reads_single_stripe() {
let erasure = Erasure::new(4, 2, 64);
@@ -1189,6 +1680,84 @@ mod tests {
assert_eq!(single, dual);
}
#[tokio::test]
async fn run_fill_request_dual_inflight_returns_deferred_eof_and_decode_errors() {
let erasure = Erasure::new(4, 2, 16);
let first = (0..16u8).collect::<Vec<_>>();
let first_state = source_from_data(&erasure, &first, &[])
.stripes
.pop_front()
.expect("first stripe should exist");
let mut source = VecStripeSource {
stripes: VecDeque::from([first_state, StripeReadState::new(Vec::new(), erasure.data_shards)]),
read_quorum: erasure.data_shards,
read_count: None,
};
let engine = LegacyEcDecodeEngine::new(erasure.clone());
let mut workspace = engine
.prepare_workspace(erasure.shard_size())
.expect("workspace should be prepared");
let result = run_fill_request(FillRequestWork {
source: &mut source,
engine: &engine,
workspace: &mut workspace,
fill_policy: FillPolicy::DualInFlight,
metrics_path: GET_OBJECT_PATH_CODEC_STREAMING,
stage_metrics_enabled: false,
remaining: first.len() + 1,
reusable_buffers: vec![Vec::new(), Vec::new()],
})
.await;
assert_eq!(result.result.as_ref().expect("first stripe should decode").as_deref(), Some(&first[..]));
assert_eq!(
result
.deferred_error
.as_ref()
.expect("second empty stripe should defer LessData")
.kind(),
ErrorKind::Other
);
let first_state = source_from_data(&erasure, &first, &[])
.stripes
.pop_front()
.expect("first stripe should exist");
let mut source = VecStripeSource {
stripes: VecDeque::from([
first_state,
StripeReadState::new(vec![ShardSlot::data(0, vec![1])], erasure.data_shards),
]),
read_quorum: erasure.data_shards,
read_count: None,
};
let engine = LegacyEcDecodeEngine::new(erasure);
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
let result = run_fill_request(FillRequestWork {
source: &mut source,
engine: &engine,
workspace: &mut workspace,
fill_policy: FillPolicy::DualInFlight,
metrics_path: GET_OBJECT_PATH_CODEC_STREAMING,
stage_metrics_enabled: false,
remaining: first.len() + 1,
reusable_buffers: vec![Vec::new(), Vec::new()],
})
.await;
assert!(result.result.expect("first stripe should decode").is_some());
assert_eq!(
result
.deferred_error
.as_ref()
.expect("second quorum failure should be deferred")
.kind(),
ErrorKind::Other
);
}
#[tokio::test]
async fn erasure_decode_reader_defers_short_read_error_until_buffer_drains() {
let erasure = Erasure::new(4, 2, 32);
@@ -1474,6 +2043,45 @@ mod tests {
assert_eq!(output, b"abcde");
}
#[test]
fn decode_stripe_into_rejects_missing_reconstructed_data_shards() {
let engine = NoopDecodeEngine {
data_shards: 2,
block_size: 8,
};
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
let mut output = Vec::with_capacity(1);
let short_state = StripeReadState::new(vec![ShardSlot::data(0, vec![1, 2, 3, 4])], 1);
let err = decode_stripe_into(
GET_OBJECT_PATH_CODEC_STREAMING,
false,
&engine,
&mut workspace,
short_state,
8,
&mut output,
)
.expect_err("decoded stripe shorter than data shard count must fail");
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
let missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1);
let err = decode_stripe_into(
GET_OBJECT_PATH_CODEC_STREAMING,
false,
&engine,
&mut workspace,
missing_state,
8,
&mut output,
)
.expect_err("missing reconstructed data shard must fail");
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
reserve_output_capacity(&mut output, 32);
assert!(output.capacity() >= 32);
}
#[tokio::test]
async fn erasure_decode_reader_reports_short_source() {
let erasure = Erasure::new(4, 2, 32);
+177 -57
View File
@@ -42,6 +42,7 @@ const DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST: bool = false;
/// Read once at first use via `OnceLock` to avoid per-encode syscall.
static CACHED_MAX_INFLIGHT_BYTES: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
static CACHED_BATCH_BLOCKS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
#[cfg(not(test))]
static CACHED_BYTESMUT_INGEST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
#[inline(always)]
@@ -83,6 +84,12 @@ fn erasure_encode_max_inflight_bytes() -> usize {
}
fn use_bytesmut_ingest() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST, DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST)
}
#[cfg(not(test))]
*CACHED_BYTESMUT_INGEST.get_or_init(|| {
rustfs_utils::get_env_bool(ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST, DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST)
})
@@ -230,35 +237,24 @@ impl<'a> MultiWriter<'a> {
return Ok(());
}
if let Some(write_err) = reduce_write_quorum_errs(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum) {
let summary = build_write_quorum_failure_summary(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum);
let summary_text = format_write_quorum_failure(&summary);
runtime_sources::record_erasure_write_quorum_failure("write", quorum_dominant_error_metric_label(&summary));
error!(
required = summary.required,
achieved = summary.achieved,
failed = summary.failed,
total = summary.total,
offline_disks = summary.offline_disks,
retryable_failures = summary.retryable_failures,
dominant_error = summary.dominant_error_label,
returned_error = %write_err,
errs = ?self.errs,
"Erasure encode write quorum unavailable: {summary_text}"
);
return Err(std::io::Error::other(format!("Failed to write data: {summary_text}")));
}
let write_err =
reduce_write_quorum_errs(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum).unwrap_or(Error::ErasureWriteQuorum);
let summary = build_write_quorum_failure_summary(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum);
Err(std::io::Error::other(format!(
"Failed to write data: {}: {}",
format_write_quorum_failure(&summary),
self.errs
.iter()
.map(|e| e.as_ref().map_or_else(|| "<nil>".to_string(), |e| e.to_string()))
.collect::<Vec<_>>()
.join(", ")
)))
let summary_text = format_write_quorum_failure(&summary);
runtime_sources::record_erasure_write_quorum_failure("write", quorum_dominant_error_metric_label(&summary));
error!(
required = summary.required,
achieved = summary.achieved,
failed = summary.failed,
total = summary.total,
offline_disks = summary.offline_disks,
retryable_failures = summary.retryable_failures,
dominant_error = summary.dominant_error_label,
returned_error = %write_err,
errs = ?self.errs,
"Erasure encode write quorum unavailable: {summary_text}"
);
Err(std::io::Error::other(format!("Failed to write data: {summary_text}")))
}
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
@@ -296,35 +292,24 @@ impl<'a> MultiWriter<'a> {
return Ok(());
}
if let Some(write_err) = reduce_write_quorum_errs(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum) {
let summary = build_write_quorum_failure_summary(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum);
let summary_text = format_write_quorum_failure(&summary);
runtime_sources::record_erasure_write_quorum_failure("shutdown", quorum_dominant_error_metric_label(&summary));
error!(
required = summary.required,
achieved = summary.achieved,
failed = summary.failed,
total = summary.total,
offline_disks = summary.offline_disks,
retryable_failures = summary.retryable_failures,
dominant_error = summary.dominant_error_label,
returned_error = %write_err,
errs = ?self.errs,
"Erasure encode shutdown quorum unavailable: {summary_text}"
);
return Err(std::io::Error::other(format!("Failed to shutdown writers: {summary_text}")));
}
let write_err =
reduce_write_quorum_errs(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum).unwrap_or(Error::ErasureWriteQuorum);
let summary = build_write_quorum_failure_summary(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum);
Err(std::io::Error::other(format!(
"Failed to shutdown writers: {}: {}",
format_write_quorum_failure(&summary),
self.errs
.iter()
.map(|e| e.as_ref().map_or_else(|| "<nil>".to_string(), |e| e.to_string()))
.collect::<Vec<_>>()
.join(", ")
)))
let summary_text = format_write_quorum_failure(&summary);
runtime_sources::record_erasure_write_quorum_failure("shutdown", quorum_dominant_error_metric_label(&summary));
error!(
required = summary.required,
achieved = summary.achieved,
failed = summary.failed,
total = summary.total,
offline_disks = summary.offline_disks,
retryable_failures = summary.retryable_failures,
dominant_error = summary.dominant_error_label,
returned_error = %write_err,
errs = ?self.errs,
"Erasure encode shutdown quorum unavailable: {summary_text}"
);
Err(std::io::Error::other(format!("Failed to shutdown writers: {summary_text}")))
}
}
@@ -753,7 +738,7 @@ mod tests {
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
use tokio::io::{AsyncWrite, AsyncWriteExt};
#[derive(Clone, Default)]
struct DeferredCommitWriter {
@@ -849,6 +834,30 @@ mod tests {
BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(writer), shard_size, HashAlgorithm::HighwayHash256S)
}
#[tokio::test]
async fn helper_writers_cover_flush_and_shutdown_paths() {
let mut failing_write = FailingWriteWriter;
failing_write.flush().await.expect("failing-write flush should succeed");
failing_write.shutdown().await.expect("failing-write shutdown should succeed");
let mut short_write = ShortWriteWriter;
let written = short_write
.write(b"short")
.await
.expect("short-write helper should report a partial write");
assert_eq!(written, 4);
short_write.flush().await.expect("short-write flush should succeed");
short_write.shutdown().await.expect("short-write shutdown should succeed");
let mut shutdown_fail = ShutdownFailWriter::default();
shutdown_fail.flush().await.expect("shutdown-fail flush should succeed");
let err = shutdown_fail
.shutdown()
.await
.expect_err("shutdown-fail writer should reject shutdown");
assert_eq!(err.to_string(), "injected shutdown failure");
}
#[tokio::test]
async fn multi_writer_short_write_fails_before_shutdown() {
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 16))];
@@ -864,6 +873,61 @@ mod tests {
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
}
#[tokio::test]
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
let mut writers = vec![None, None];
let err = {
let mut writer = MultiWriter::new(&mut writers, 1);
writer
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
.await
.expect_err("offline writers cannot satisfy write quorum")
};
let err = err.to_string();
assert!(err.contains("Failed to write data"));
assert!(err.contains("offline-disks=2/2"));
assert!(err.contains("required=1"));
let shutdown_err = {
let mut writer = MultiWriter::new(&mut writers, 1);
writer
.shutdown()
.await
.expect_err("offline writers cannot satisfy shutdown quorum")
};
let shutdown_err = shutdown_err.to_string();
assert!(shutdown_err.contains("Failed to shutdown writers"));
assert!(shutdown_err.contains("offline-disks=2/2"));
assert!(shutdown_err.contains("required=1"));
}
#[tokio::test]
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
let committed = Arc::new(Mutex::new(Vec::new()));
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 16))];
let mut writer = MultiWriter::new(&mut writers, 2);
let err = writer
.write(vec![Bytes::from_static(b"quorum impossible")])
.await
.expect_err("write quorum above writer count must fail");
let err = err.to_string();
assert!(err.contains("Failed to write data"));
assert!(err.contains("required=2"));
assert!(err.contains("erasure write quorum"));
let shutdown_err = writer
.shutdown()
.await
.expect_err("shutdown quorum above writer count must fail");
let shutdown_err = shutdown_err.to_string();
assert!(shutdown_err.contains("Failed to shutdown writers"));
assert!(shutdown_err.contains("required=2"));
assert!(shutdown_err.contains("erasure write quorum"));
}
#[tokio::test]
async fn drain_queued_inflight_bytes_consumes_pending_blocks() {
let (tx, mut rx) = mpsc::channel(2);
@@ -900,6 +964,40 @@ mod tests {
assert!(!committed.lock().unwrap().is_empty());
}
#[tokio::test]
#[serial_test::serial]
async fn encode_bytesmut_ingest_streaming_path_writes_and_shutdowns_writers() {
temp_env::async_with_vars([(ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST, Some("true"))], async {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
const BLOCK_SIZE: usize = 32;
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
.iter()
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
.collect();
let payload = vec![0x5a; BLOCK_SIZE * 2 + 7];
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
let (_reader, written) = erasure
.encode(reader, &mut writers, DATA_SHARDS)
.await
.expect("BytesMut ingest path should encode the streaming payload");
assert_eq!(written, payload.len());
for (index, committed) in committed.iter().enumerate() {
assert!(
!committed.lock().expect("committed buffer should be lockable").is_empty(),
"shard {index} should receive bytesmut-ingest data"
);
}
})
.await;
}
#[tokio::test]
async fn encode_streaming_write_quorum_failure_aborts_and_reports_error() {
const DATA_SHARDS: usize = 2;
@@ -1089,6 +1187,28 @@ mod tests {
}
}
#[tokio::test(flavor = "current_thread")]
async fn encode_block_bytes_mut_works_on_current_thread_runtime() {
let erasure = Arc::new(Erasure::new(2, 2, 64));
let payload = b"bytesmut current-thread payload";
let shards = erasure
.clone()
.encode_block_bytes_mut(bytes::BytesMut::from(&payload[..]), payload.len())
.await
.expect("bytesmut encode should succeed on current-thread runtime");
let expected_shard_size = payload.len().div_ceil(erasure.data_shards);
assert_eq!(shards.len(), erasure.total_shard_count());
assert!(shards.iter().all(|shard| shard.len() == expected_shard_size));
let mut restored = Vec::new();
for shard in shards.iter().take(erasure.data_shards) {
restored.extend_from_slice(shard);
}
restored.truncate(payload.len());
assert_eq!(restored, payload);
}
#[tokio::test]
async fn encode_batched_writes_full_and_tail_batches() {
const DATA_SHARDS: usize = 2;
+179 -64
View File
@@ -942,6 +942,9 @@ mod tests {
use super::*;
use proptest::collection::{btree_set, vec};
use proptest::prelude::*;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::ReadBuf;
fn optional_shards(shards: &[Bytes]) -> Vec<Option<Vec<u8>>> {
shards.iter().map(|shard| Some(shard.to_vec())).collect()
@@ -984,6 +987,29 @@ mod tests {
assert_eq!(owned, borrowed);
}
struct ErrorAfterPartialReader {
emitted: bool,
}
impl AsyncRead for ErrorAfterPartialReader {
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if !self.emitted {
self.emitted = true;
buf.put_slice(&[1]);
return Poll::Ready(Ok(()));
}
Poll::Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, "partial read failure")))
}
}
struct ImmediateErrorReader;
impl AsyncRead for ImmediateErrorReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("immediate read failure")))
}
}
#[test]
fn has_valid_dimensions_rejects_zero_block_size_or_data_shards() {
// Well-formed erasure metadata is accepted.
@@ -999,6 +1025,130 @@ mod tests {
assert!(!Erasure::new(0, 0, 0).has_valid_dimensions());
}
#[tokio::test]
async fn encode_stream_callback_async_stops_on_reader_errors() {
let erasure = std::sync::Arc::new(Erasure::new(2, 1, 4));
let mut partial_error = ErrorAfterPartialReader { emitted: false };
let mut partial_callbacks = Vec::new();
let total = erasure
.clone()
.encode_stream_callback_async(&mut partial_error, |result| {
partial_callbacks.push(result.map(|blocks| blocks.len()).map_err(|err| err.kind()));
async { Ok::<(), io::Error>(()) }
})
.await
.expect("partial read error should not make callback fail");
assert_eq!(total, 0);
assert!(
partial_callbacks.is_empty(),
"unexpected EOF after a partial read should stop without emitting a block"
);
let mut immediate_error = ImmediateErrorReader;
let mut immediate_callbacks = Vec::new();
let total = erasure
.encode_stream_callback_async(&mut immediate_error, |result| {
immediate_callbacks.push(result.map(|blocks| blocks.len()).map_err(|err| err.kind()));
async { Ok::<(), io::Error>(()) }
})
.await
.expect("immediate read error should be delivered to callback");
assert_eq!(total, 0);
assert_eq!(immediate_callbacks, vec![Err(io::ErrorKind::Other)]);
}
#[test]
fn default_and_legacy_clone_preserve_safe_zero_state_and_restore_data() {
let default = Erasure::default();
assert_eq!(default.total_shard_count(), 0);
assert!(!default.has_valid_dimensions());
assert_eq!(default.shard_file_size(0), 0);
assert_eq!(default.shard_file_size(-7), -7);
let legacy = Erasure::new_with_options(2, 2, 64, true);
let cloned = legacy.clone();
assert_eq!(cloned.data_shards, legacy.data_shards);
assert_eq!(cloned.parity_shards, legacy.parity_shards);
assert_eq!(cloned.block_size, legacy.block_size);
assert!(cloned.uses_legacy);
let data = b"legacy clone should keep independent SIMD caches";
let encoded = cloned.encode_data(data).expect("legacy clone should encode");
let mut shards = optional_shards(&encoded);
shards[0] = None;
cloned.decode_data(&mut shards).expect("legacy clone should decode");
assert_eq!(recover_data(&shards, cloned.data_shards, data.len()), data);
}
#[test]
fn legacy_verify_reports_invalid_empty_valid_and_corrupt_parity_sets() {
let legacy = LegacyReedSolomonEncoder::new(2, 2).expect("legacy encoder should construct");
let wrong_count: [&[u8]; 1] = [&[]];
let err = legacy.verify(&wrong_count).expect_err("wrong shard count must be rejected");
assert!(err.to_string().contains("invalid shard count"));
let empty: [&[u8]; 4] = [&[], &[], &[], &[]];
assert!(
legacy
.verify(&empty)
.expect("all-empty legacy shards are internally consistent")
);
let erasure = Erasure::new_with_options(2, 2, 64, true);
let encoded = erasure
.encode_data(b"legacy verify should compare regenerated parity")
.expect("legacy encode should succeed");
let refs = encoded.iter().map(Bytes::as_ref).collect::<Vec<_>>();
assert!(legacy.verify(&refs).expect("valid legacy shards should verify"));
let mut corrupt = encoded.iter().map(|shard| shard.to_vec()).collect::<Vec<_>>();
corrupt[erasure.data_shards][0] ^= 0x80;
let corrupt_refs = corrupt.iter().map(Vec::as_slice).collect::<Vec<_>>();
assert!(!legacy.verify(&corrupt_refs).expect("corrupt parity should be detected"));
}
#[test]
fn parity_helper_and_empty_payload_recovery_fail_closed_on_malformed_shards() {
let mut wrong_count = vec![Some(vec![1])];
let err = encode_parity_shards(&mut wrong_count, 2, 1, |_| panic!("wrong count must fail before encode"))
.expect_err("wrong shard count must be rejected");
assert!(err.to_string().contains("invalid shard count"));
let mut inconsistent_empty = vec![Some(Vec::new()), Some(vec![1])];
let err = encode_parity_shards(&mut inconsistent_empty, 1, 1, |_| panic!("zero-length mismatch must fail before encode"))
.expect_err("mixed empty and non-empty shards must be rejected");
assert!(err.to_string().contains("inconsistent shard length"));
let mut inconsistent_non_empty = vec![Some(vec![1]), Some(vec![2, 3])];
let err = encode_parity_shards(&mut inconsistent_non_empty, 1, 1, |_| {
panic!("non-empty length mismatch must fail before encode")
})
.expect_err("mismatched non-empty shards must be rejected");
assert!(err.to_string().contains("inconsistent shard length"));
let mut no_present_empty_payload = vec![None, None, None];
assert!(
!recover_empty_payload_data_shards(&mut no_present_empty_payload, 2, 1)
.expect("all-missing empty payload marker should not be synthesized")
);
}
#[test]
fn verify_data_and_parity_handles_zero_parity_and_rejects_wrong_count() {
let no_parity = Erasure::new(2, 0, 64);
assert!(
no_parity
.verify_data_and_parity(&[Some(vec![1]), Some(vec![2, 3])])
.expect("zero parity requires no parity verification")
);
let erasure = Erasure::new(2, 2, 64);
let err = erasure
.verify_data_and_parity(&[Some(Vec::new())])
.expect_err("wrong shard count must fail verification");
assert!(err.to_string().contains("invalid shard count"));
}
#[test]
fn encode_data_owned_matches_borrowed_path() {
for uses_legacy in [false, true] {
@@ -1753,37 +1903,22 @@ mod tests {
// Create data that will result in 64+ byte shards
let data = vec![0x42u8; 200]; // 200 bytes, should create ~50 byte shards per data shard
let result = erasure.encode_data(&data);
let shards = erasure.encode_data(&data).expect("minimum shard size data should encode");
println!("SIMD encoding succeeded with shard size: {}", shards[0].len());
// This might fail due to SIMD shard size requirements
match result {
Ok(shards) => {
println!("SIMD encoding succeeded with shard size: {}", shards[0].len());
// Test decoding
let mut shards_opt: Vec<Option<Vec<u8>>> = shards.iter().map(|b| Some(b.to_vec())).collect();
shards_opt[1] = None;
// Test decoding
let mut shards_opt: Vec<Option<Vec<u8>>> = shards.iter().map(|b| Some(b.to_vec())).collect();
shards_opt[1] = None;
let decode_result = erasure.decode_data(&mut shards_opt);
match decode_result {
Ok(_) => {
let mut recovered = Vec::new();
for shard in shards_opt.iter().take(data_shards) {
recovered.extend_from_slice(shard.as_ref().expect("operation should succeed"));
}
recovered.truncate(data.len());
assert_eq!(&recovered, &data);
}
Err(e) => {
println!("SIMD decoding failed with shard size {}: {}", shards[0].len(), e);
}
}
}
Err(e) => {
println!("SIMD encoding failed with small shard size: {e}");
// This is expected for very small shard sizes
}
erasure
.decode_data(&mut shards_opt)
.expect("minimum shard size data should decode");
let mut recovered = Vec::new();
for shard in shards_opt.iter().take(data_shards) {
recovered.extend_from_slice(shard.as_ref().expect("operation should succeed"));
}
recovered.truncate(data.len());
assert_eq!(&recovered, &data);
}
#[test]
@@ -1877,46 +2012,26 @@ mod tests {
let small_data = b"tiny!123".to_vec(); // 8 bytes data
// Test encoding with small data
let result = erasure.encode_data(&small_data);
match result {
Ok(shards) => {
println!("✅ SIMD encoding succeeded: {} bytes into {} shards", small_data.len(), shards.len());
assert_eq!(shards.len(), data_shards + parity_shards);
let shards = erasure.encode_data(&small_data).expect("small data should encode");
println!("SIMD encoding succeeded: {} bytes into {} shards", small_data.len(), shards.len());
assert_eq!(shards.len(), data_shards + parity_shards);
// Test decoding
let mut shards_opt: Vec<Option<Vec<u8>>> = shards.iter().map(|shard| Some(shard.to_vec())).collect();
// Test decoding
let mut shards_opt: Vec<Option<Vec<u8>>> = shards.iter().map(|shard| Some(shard.to_vec())).collect();
// Lose some shards to test recovery
shards_opt[1] = None; // Lose one data shard
shards_opt[4] = None; // Lose one parity shard
// Lose some shards to test recovery
shards_opt[1] = None; // Lose one data shard
shards_opt[4] = None; // Lose one parity shard
let decode_result = erasure.decode_data(&mut shards_opt);
match decode_result {
Ok(()) => {
println!("✅ SIMD decode worked");
// Verify recovered data
let mut recovered = Vec::new();
for shard in shards_opt.iter().take(data_shards) {
recovered.extend_from_slice(shard.as_ref().expect("operation should succeed"));
}
recovered.truncate(small_data.len());
println!("recovered: {recovered:?}");
println!("small_data: {small_data:?}");
assert_eq!(&recovered, &small_data);
println!("✅ Data recovery successful with SIMD");
}
Err(e) => {
println!("❌ SIMD decode failed: {e}");
// For very small data, decode failure might be acceptable
}
}
}
Err(e) => {
println!("❌ SIMD encode failed: {e}");
// For very small data or configuration issues, encoding might fail
}
erasure.decode_data(&mut shards_opt).expect("small data should decode");
let mut recovered = Vec::new();
for shard in shards_opt.iter().take(data_shards) {
recovered.extend_from_slice(shard.as_ref().expect("operation should succeed"));
}
recovered.truncate(small_data.len());
println!("recovered: {recovered:?}");
println!("small_data: {small_data:?}");
assert_eq!(&recovered, &small_data);
}
#[test]
+90 -17
View File
@@ -208,10 +208,10 @@ mod tests {
use super::*;
use crate::erasure::coding::{CustomWriter, Erasure};
use rustfs_utils::HashAlgorithm;
use std::io::Cursor;
use std::io::{self, Cursor};
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
use tokio::io::{AsyncWrite, ReadBuf};
/// An `AsyncWrite` that accepts writes until `fail_at` bytes have been
/// written, then fails every subsequent write. Used to simulate a
@@ -239,6 +239,91 @@ mod tests {
}
}
struct PendingReader;
impl AsyncRead for PendingReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Pending
}
}
struct FailingReader;
impl AsyncRead for FailingReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("synthetic heal read failure")))
}
}
fn inline_writer(shard_size: usize) -> BitrotWriterWrapper {
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), shard_size, HashAlgorithm::None)
}
#[tokio::test]
async fn read_heal_shards_returns_empty_slots_for_zero_sized_shards() {
let mut readers = vec![
Some(BitrotReader::new(Cursor::new(vec![1, 2, 3]), 0, HashAlgorithm::None, false)),
None,
];
let (shards, errs) = read_heal_shards(&mut readers, 0, Duration::ZERO).await;
assert_eq!(shards, vec![None, None]);
assert_eq!(errs, vec![None, None]);
}
#[tokio::test]
async fn read_heal_shards_records_reader_errors_and_retiring_timeouts() {
let mut readers = vec![Some(BitrotReader::new(FailingReader, 4, HashAlgorithm::None, false))];
let (shards, errs) = read_heal_shards(&mut readers, 4, Duration::ZERO).await;
assert!(shards[0].is_none());
assert!(
errs[0]
.as_ref()
.is_some_and(|err| err.to_string().contains("synthetic heal read failure"))
);
assert!(readers[0].is_some(), "ordinary read errors must not retire readers");
let mut timeout_readers = vec![Some(BitrotReader::new(PendingReader, 4, HashAlgorithm::None, false))];
let (shards, errs) = read_heal_shards(&mut timeout_readers, 4, Duration::from_millis(1)).await;
assert_eq!(shards, vec![None]);
assert!(errs[0].as_ref().is_some_and(|err| err.to_string().contains("timed out")));
assert!(timeout_readers[0].is_none(), "timed-out readers are retired");
}
#[tokio::test]
async fn heal_rejects_invalid_writer_shape_before_reading() {
let erasure = Erasure::new(2, 1, 64);
let mut writers = vec![Some(inline_writer(erasure.shard_size()))];
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = Vec::new();
let err = erasure
.heal(&mut writers, readers, 0, &[])
.await
.expect_err("invalid writer count must fail");
assert!(err.to_string().contains("invalid argument"));
}
#[tokio::test]
async fn heal_empty_object_only_shuts_down_available_writers() {
let erasure = Erasure::new(2, 1, 64);
let mut writers = (0..erasure.total_shard_count())
.map(|_| Some(inline_writer(erasure.shard_size())))
.collect::<Vec<_>>();
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = Vec::new();
erasure
.heal(&mut writers, readers, 0, &[])
.await
.expect("empty object heal should only close writers");
assert!(writers.iter().all(Option::is_some));
}
#[tokio::test]
async fn heal_reconstructs_missing_parity_shard() {
let erasure = Erasure::new(2, 2, 64);
@@ -266,11 +351,7 @@ mod tests {
let mut writers = (0..erasure.total_shard_count())
.map(|index| {
if index == missing_parity {
Some(BitrotWriterWrapper::new(
CustomWriter::new_inline_buffer(),
erasure.shard_size(),
HashAlgorithm::None,
))
Some(inline_writer(erasure.shard_size()))
} else {
None
}
@@ -319,11 +400,7 @@ mod tests {
let mut writers = (0..erasure.total_shard_count())
.map(|index| {
if index == missing_data {
Some(BitrotWriterWrapper::new(
CustomWriter::new_inline_buffer(),
erasure.shard_size(),
HashAlgorithm::None,
))
Some(inline_writer(erasure.shard_size()))
} else {
None
}
@@ -403,11 +480,7 @@ mod tests {
let mut writers = (0..erasure.total_shard_count())
.map(|index| {
if index == missing_data {
Some(BitrotWriterWrapper::new(
CustomWriter::new_inline_buffer(),
erasure.shard_size(),
HashAlgorithm::None,
))
Some(inline_writer(erasure.shard_size()))
} else {
None
}