obs(get): add GET read pipeline metrics (#3868)

This commit is contained in:
houseme
2026-06-26 04:02:02 +08:00
committed by GitHub
parent 33b7f48f37
commit 7bf411f465
16 changed files with 3268 additions and 82 deletions
+663 -6
View File
@@ -13,8 +13,13 @@
// limitations under the License.
use super::*;
use crate::get_diagnostics::{
GET_OBJECT_PATH_CODEC_STREAMING, GET_STAGE_DECODE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP, GetObjectFailureReason,
classify_disk_error, record_get_object_pipeline_failure, record_get_object_pipeline_failure_for_path,
};
use rustfs_config::{DEFAULT_OBJECT_ZERO_COPY_ENABLE, ENV_OBJECT_ZERO_COPY_ENABLE};
use std::future::Future;
use tokio::io::AsyncRead;
use tokio::task::JoinSet;
async fn collect_read_multiple_results<F>(
@@ -98,6 +103,55 @@ where
}
impl SetDisks {
async fn is_get_object_metadata_cache_enabled(&self, bucket: &str, opts: &ObjectOptions, read_data: bool) -> bool {
is_get_object_metadata_cache_request_eligible(bucket, opts, read_data) && !runtime_sources::setup_is_dist_erasure().await
}
async fn cached_get_object_fileinfo(&self, bucket: &str, object: &str) -> Option<GetObjectMetadataCacheEntry> {
let key = GetObjectMetadataCacheKey::new(bucket, object);
let cache = self.get_object_metadata_cache.read().await;
cache
.get(&key)
.filter(|entry| {
entry.is_fresh() && entry.online_disks.iter().filter(|disk| disk.is_some()).count() >= entry.read_quorum
})
.cloned()
}
async fn cache_get_object_fileinfo(
&self,
bucket: &str,
object: &str,
fi: &FileInfo,
parts_metadata: &[FileInfo],
online_disks: &[Option<DiskStore>],
read_quorum: usize,
) {
if fi.deleted || !fi.is_valid() {
return;
}
let key = GetObjectMetadataCacheKey::new(bucket, object);
let mut cache = self.get_object_metadata_cache.write().await;
if cache.len() >= GET_OBJECT_METADATA_CACHE_MAX_ENTRIES {
cache.retain(|_, entry| entry.is_fresh());
if cache.len() >= GET_OBJECT_METADATA_CACHE_MAX_ENTRIES {
cache.clear();
}
}
cache.insert(
key,
GetObjectMetadataCacheEntry {
created_at: Instant::now(),
fi: fi.clone(),
parts_metadata: parts_metadata.to_vec(),
online_disks: online_disks.to_vec(),
read_quorum,
},
);
}
pub(super) async fn read_parts(
disks: &[Option<DiskStore>],
bucket: &str,
@@ -617,6 +671,11 @@ impl SetDisks {
opts: &ObjectOptions,
read_data: bool,
) -> Result<(FileInfo, Vec<FileInfo>, Vec<Option<DiskStore>>)> {
let use_metadata_cache = self.is_get_object_metadata_cache_enabled(bucket, opts, read_data).await;
if use_metadata_cache && let Some(cached) = self.cached_get_object_fileinfo(bucket, object).await {
return Ok((cached.fi, cached.parts_metadata, cached.online_disks));
}
let disks = self.disks.read().await;
let disks = disks.clone();
@@ -660,6 +719,9 @@ impl SetDisks {
Some(self.set_index), // set_index
))
.await;
} else if use_metadata_cache {
self.cache_get_object_fileinfo(bucket, object, &fi, &parts_metadata, &op_online_disks, read_quorum as usize)
.await;
}
// debug!("get_object_fileinfo pick fi {:?}", &fi);
@@ -733,19 +795,56 @@ impl SetDisks {
let total_size = fi.size as usize;
if offset > total_size {
error!("get_object_with_fileinfo offset out of range: {}, total_size: {}", offset, total_size);
let reason = GetObjectFailureReason::RangeOrLengthInvalid;
record_get_object_pipeline_failure(GET_STAGE_RANGE, reason);
error!(
bucket,
object,
offset,
total_size,
requested_length = length,
stage = GET_STAGE_RANGE,
reason = reason.as_str(),
state = "range_or_length_invalid",
"GetObject range validation failed"
);
return Err(Error::other("offset out of range"));
}
let length = if length < 0 { total_size - offset } else { length as usize };
let Some(end_offset_exclusive) = offset.checked_add(length) else {
error!("get_object_with_fileinfo offset overflow: {}, length: {}", offset, length);
let reason = GetObjectFailureReason::RangeOrLengthInvalid;
record_get_object_pipeline_failure(GET_STAGE_RANGE, reason);
error!(
bucket,
object,
offset,
total_size,
requested_length = length,
stage = GET_STAGE_RANGE,
reason = reason.as_str(),
state = "range_or_length_invalid",
"GetObject range validation overflow"
);
return Err(Error::other("offset out of range"));
};
if end_offset_exclusive > total_size {
error!("get_object_with_fileinfo offset out of range: {}, total_size: {}", offset, total_size);
let reason = GetObjectFailureReason::RangeOrLengthInvalid;
record_get_object_pipeline_failure(GET_STAGE_RANGE, reason);
error!(
bucket,
object,
offset,
total_size,
requested_length = length,
end_offset_exclusive,
stage = GET_STAGE_RANGE,
reason = reason.as_str(),
state = "range_or_length_invalid",
"GetObject range validation failed"
);
return Err(Error::other("offset out of range"));
}
@@ -825,6 +924,7 @@ impl SetDisks {
// Default: enabled (true) for performance
let use_zero_copy = rustfs_utils::get_env_bool(ENV_OBJECT_ZERO_COPY_ENABLE, DEFAULT_OBJECT_ZERO_COPY_ENABLE);
let reader_setup_stage_start = Instant::now();
let mut readers = Vec::with_capacity(disks.len());
let mut errors = Vec::with_capacity(disks.len());
for (idx, disk_op) in disks.iter().enumerate() {
@@ -856,14 +956,51 @@ impl SetDisks {
}
}
}
rustfs_io_metrics::record_get_object_shard_reader_setup_duration(reader_setup_stage_start.elapsed().as_secs_f64());
let nil_count = errors.iter().filter(|&e| e.is_none()).count();
if nil_count < erasure.data_shards {
if let Some(read_err) = reduce_read_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, erasure.data_shards) {
error!("create_bitrot_reader reduce_read_quorum_errs {:?}", &errors);
let reason = classify_disk_error(&read_err);
error!(
bucket,
object,
part_index = current_part,
part_number,
part_offset,
part_length,
read_offset,
till_offset,
total_shards = erasure.data_shards + erasure.parity_shards,
available_shards = nil_count,
data_shards = erasure.data_shards,
stage = GET_STAGE_READER_SETUP,
reason = reason.as_str(),
errors = ?errors,
"Create bitrot reader failed read quorum"
);
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
return Err(to_object_err(read_err.into(), vec![bucket, object]));
}
error!("create_bitrot_reader not enough disks to read: {:?}", &errors);
let reason = GetObjectFailureReason::ReadQuorum;
error!(
bucket,
object,
part_index = current_part,
part_number,
part_offset,
part_length,
read_offset,
till_offset,
total_shards = erasure.data_shards + erasure.parity_shards,
available_shards = nil_count,
data_shards = erasure.data_shards,
stage = GET_STAGE_READER_SETUP,
reason = reason.as_str(),
errors = ?errors,
"Create bitrot reader did not have enough disks"
);
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
return Err(Error::other(format!("not enough disks to read: {errors:?}")));
}
@@ -924,7 +1061,9 @@ impl SetDisks {
// "read part {} part_offset {},part_length {},part_size {} ",
// part_number, part_offset, part_length, part_size
// );
let decode_stage_start = Instant::now();
let (written, err) = erasure.decode(writer, readers, part_offset, part_length, part_size).await;
rustfs_io_metrics::record_get_object_decode_duration(decode_stage_start.elapsed().as_secs_f64());
debug!(
bucket,
object,
@@ -968,7 +1107,21 @@ impl SetDisks {
}
if has_err {
error!("erasure.decode err {} {:?}", written, &de_err);
let reason = classify_disk_error(&de_err);
error!(
bucket,
object,
part_index = current_part,
part_number,
part_offset,
part_length,
bytes_written = written,
stage = GET_STAGE_DECODE,
reason = reason.as_str(),
error = ?de_err,
"Erasure decode failed during GetObject"
);
record_get_object_pipeline_failure(GET_STAGE_DECODE, reason);
return Err(de_err.into());
}
}
@@ -985,12 +1138,516 @@ impl SetDisks {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn get_object_decode_reader_with_fileinfo(
bucket: &str,
object: &str,
fi: FileInfo,
files: Vec<FileInfo>,
disks: &[Option<DiskStore>],
set_index: usize,
pool_index: usize,
skip_verify_bitrot: bool,
) -> Result<Box<dyn AsyncRead + Unpin + Send + Sync>> {
let (disks, files) = Self::shuffle_disks_and_parts_metadata_by_index(disks, &files, &fi);
if fi.parts.len() != 1 {
return Err(Error::other("codec streaming reader only supports single-part plain objects"));
}
let erasure = erasure_coding::Erasure::new_with_options(
fi.erasure.data_blocks,
fi.erasure.parity_blocks,
fi.erasure.block_size,
fi.uses_legacy_checksum,
);
let part = &fi.parts[0];
let part_number = part.number;
let part_size = part.size;
let part_length = usize::try_from(fi.size).map_err(|_| Error::other("codec streaming reader object size is invalid"))?;
if part_length > part_size {
return Err(Error::other("codec streaming reader part length exceeds part size"));
}
let checksum_info = fi.erasure.get_checksum_info(part_number);
let checksum_algo = if fi.uses_legacy_checksum && checksum_info.algorithm == rustfs_utils::HashAlgorithm::HighwayHash256S
{
rustfs_utils::HashAlgorithm::HighwayHash256SLegacy
} else {
checksum_info.algorithm
};
let use_zero_copy = rustfs_utils::get_env_bool(ENV_OBJECT_ZERO_COPY_ENABLE, DEFAULT_OBJECT_ZERO_COPY_ENABLE);
let till_offset = erasure.shard_file_offset(0, part_length, part_size);
let reader_setup_stage_start = Instant::now();
let mut readers = Vec::with_capacity(disks.len());
let mut errors = Vec::with_capacity(disks.len());
for (idx, disk_op) in disks.iter().enumerate() {
match create_bitrot_reader(
files[idx].data.as_deref(),
disk_op.as_ref(),
bucket,
&format!("{}/{}/part.{}", object, files[idx].data_dir.unwrap_or_default(), part_number),
0,
till_offset,
erasure.shard_size(),
checksum_algo.clone(),
skip_verify_bitrot,
use_zero_copy,
)
.await
{
Ok(Some(reader)) => {
readers.push(Some(reader));
errors.push(None);
}
Ok(None) => {
readers.push(None);
errors.push(Some(DiskError::DiskNotFound));
}
Err(e) => {
readers.push(None);
errors.push(Some(e));
}
}
}
rustfs_io_metrics::record_get_object_stage_duration(
GET_OBJECT_PATH_CODEC_STREAMING,
GET_STAGE_READER_SETUP,
reader_setup_stage_start.elapsed().as_secs_f64(),
);
let available_shards = errors.iter().filter(|err| err.is_none()).count();
if available_shards < erasure.data_shards {
if let Some(read_err) = reduce_read_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, erasure.data_shards) {
let reason = classify_disk_error(&read_err);
record_get_object_pipeline_failure_for_path(GET_OBJECT_PATH_CODEC_STREAMING, GET_STAGE_READER_SETUP, reason);
return Err(to_object_err(read_err.into(), vec![bucket, object]));
}
record_get_object_pipeline_failure_for_path(
GET_OBJECT_PATH_CODEC_STREAMING,
GET_STAGE_READER_SETUP,
GetObjectFailureReason::ReadQuorum,
);
return Err(Error::other(format!("not enough disks to read: {errors:?}")));
}
let total_shards = erasure.data_shards + erasure.parity_shards;
let missing_shards = total_shards.saturating_sub(available_shards);
if missing_shards > 0
&& let Err(e) =
rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
Some(HealChannelPriority::Normal),
Some(pool_index),
Some(set_index),
))
.await
{
warn!(
bucket,
object,
part_number,
error = %e,
"Failed to enqueue heal request for missing shards"
);
}
let source = erasure_coding::decode::ParallelReader::new_with_metrics_path(
readers,
erasure.clone(),
0,
part_size,
Some(GET_OBJECT_PATH_CODEC_STREAMING),
);
let engine = crate::erasure_codec::bridge::LegacyEcDecodeEngine::new(erasure);
let reader = erasure_coding::decode_reader::ErasureDecodeReader::new(source, engine, part_length)?;
Ok(Box::new(erasure_coding::decode_reader::SyncErasureDecodeReader::new(reader)))
}
}
fn is_get_object_metadata_cache_request_eligible(bucket: &str, opts: &ObjectOptions, read_data: bool) -> bool {
read_data
&& !opts.no_lock
&& opts.version_id.is_none()
&& !opts.versioned
&& !opts.version_suspended
&& !opts.incl_free_versions
&& !opts.delete_marker
&& opts.part_number.is_none()
&& !opts.data_movement
&& !opts.raw_data_movement_read
&& !bucket.starts_with(RUSTFS_META_BUCKET)
}
#[cfg(test)]
mod metadata_cache_tests {
use super::*;
async fn new_metadata_cache_test_set() -> Arc<SetDisks> {
SetDisks::new(
"metadata-cache-test".to_string(),
Arc::new(RwLock::new(Vec::new())),
4,
2,
0,
0,
Vec::new(),
FormatV3::new(1, 4),
Vec::new(),
)
.await
}
fn valid_test_fileinfo(object: &str) -> FileInfo {
let mut fi = FileInfo::new(object, 2, 2);
fi.volume = "bucket".to_string();
fi.name = object.to_string();
fi.size = 1;
fi.erasure.index = 1;
fi.metadata.insert("etag".to_string(), "etag-1".to_string());
fi
}
#[test]
fn get_object_metadata_cache_request_eligibility_is_conservative() {
let opts = ObjectOptions::default();
assert!(is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, false));
assert!(!is_get_object_metadata_cache_request_eligible(RUSTFS_META_BUCKET, &opts, true));
let mut opts = ObjectOptions {
no_lock: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
version_id: Some("version".to_string()),
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
versioned: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
version_suspended: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
incl_free_versions: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
delete_marker: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
part_number: Some(1),
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
data_movement: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
opts = ObjectOptions {
raw_data_movement_read: true,
..Default::default()
};
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
}
#[tokio::test]
async fn get_object_metadata_cache_hit_returns_stored_metadata() {
let set = new_metadata_cache_test_set().await;
let fi = valid_test_fileinfo("object");
let parts_metadata = vec![fi.clone()];
let online_disks = Vec::new();
set.cache_get_object_fileinfo("bucket", "object", &fi, &parts_metadata, &online_disks, 0)
.await;
let cached = set
.cached_get_object_fileinfo("bucket", "object")
.await
.expect("fresh cache entry should be returned");
assert_eq!(cached.fi.name, "object");
assert_eq!(cached.parts_metadata.len(), 1);
assert_eq!(cached.online_disks.len(), 0);
assert_eq!(cached.read_quorum, 0);
}
#[tokio::test]
async fn get_object_metadata_cache_rejects_deleted_and_invalid_fileinfo() {
let set = new_metadata_cache_test_set().await;
let mut deleted = valid_test_fileinfo("deleted-object");
deleted.deleted = true;
set.cache_get_object_fileinfo("bucket", "deleted-object", &deleted, &[deleted.clone()], &[], 0)
.await;
assert!(
set.cached_get_object_fileinfo("bucket", "deleted-object").await.is_none(),
"deleted metadata must not be cached"
);
let invalid = FileInfo::default();
set.cache_get_object_fileinfo("bucket", "invalid-object", &invalid, std::slice::from_ref(&invalid), &[], 0)
.await;
assert!(
set.cached_get_object_fileinfo("bucket", "invalid-object").await.is_none(),
"invalid metadata must not be cached"
);
}
#[tokio::test]
async fn get_object_metadata_cache_requires_cached_read_quorum() {
let set = new_metadata_cache_test_set().await;
let fi = valid_test_fileinfo("object");
set.get_object_metadata_cache.write().await.insert(
GetObjectMetadataCacheKey::new("bucket", "object"),
GetObjectMetadataCacheEntry {
created_at: Instant::now(),
fi: fi.clone(),
parts_metadata: vec![fi],
online_disks: vec![None],
read_quorum: 1,
},
);
assert!(
set.cached_get_object_fileinfo("bucket", "object").await.is_none(),
"cache hit must be rejected when cached online disks cannot satisfy read quorum"
);
}
#[tokio::test]
async fn get_object_metadata_cache_rejects_stale_entries() {
let set = new_metadata_cache_test_set().await;
let fi = valid_test_fileinfo("object");
set.get_object_metadata_cache.write().await.insert(
GetObjectMetadataCacheKey::new("bucket", "object"),
GetObjectMetadataCacheEntry {
created_at: Instant::now() - GET_OBJECT_METADATA_CACHE_TTL - Duration::from_millis(1),
fi: fi.clone(),
parts_metadata: vec![fi],
online_disks: Vec::new(),
read_quorum: 0,
},
);
assert!(
set.cached_get_object_fileinfo("bucket", "object").await.is_none(),
"stale cache entry must not be returned"
);
}
#[tokio::test]
async fn get_object_metadata_cache_invalidation_removes_object_entry() {
let set = new_metadata_cache_test_set().await;
let fi = valid_test_fileinfo("object");
set.cache_get_object_fileinfo("bucket", "object", &fi, std::slice::from_ref(&fi), &[], 0)
.await;
assert!(set.cached_get_object_fileinfo("bucket", "object").await.is_some());
set.invalidate_get_object_metadata_cache("bucket", "object").await;
assert!(
set.cached_get_object_fileinfo("bucket", "object").await.is_none(),
"explicit invalidation must remove the cached object metadata"
);
}
#[tokio::test]
async fn get_object_metadata_cache_prunes_when_capacity_is_reached() {
let set = new_metadata_cache_test_set().await;
let stale_fi = valid_test_fileinfo("stale-object");
let fresh_fi = valid_test_fileinfo("fresh-object");
{
let mut cache = set.get_object_metadata_cache.write().await;
for idx in 0..GET_OBJECT_METADATA_CACHE_MAX_ENTRIES {
cache.insert(
GetObjectMetadataCacheKey::new("bucket", &format!("stale-object-{idx}")),
GetObjectMetadataCacheEntry {
created_at: Instant::now() - GET_OBJECT_METADATA_CACHE_TTL - Duration::from_millis(1),
fi: stale_fi.clone(),
parts_metadata: vec![stale_fi.clone()],
online_disks: Vec::new(),
read_quorum: 0,
},
);
}
}
set.cache_get_object_fileinfo("bucket", "fresh-object", &fresh_fi, std::slice::from_ref(&fresh_fi), &[], 0)
.await;
let cache = set.get_object_metadata_cache.read().await;
assert_eq!(cache.len(), 1);
assert!(cache.contains_key(&GetObjectMetadataCacheKey::new("bucket", "fresh-object")));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn codec_streaming_test_fileinfo(size: i64, part_count: usize) -> FileInfo {
let mut fi = FileInfo::new("object", 4, 2);
fi.volume = "bucket".to_string();
fi.name = "object".to_string();
fi.size = size;
fi.metadata.insert("etag".to_string(), "etag-1".to_string());
let size = usize::try_from(size).expect("test object size should fit usize");
let part_count = part_count.max(1);
let part_size = size.div_ceil(part_count);
for index in 0..part_count {
let remaining = size.saturating_sub(index * part_size);
let current_part_size = remaining.min(part_size);
fi.add_object_part(
index + 1,
format!("etag-{index}"),
current_part_size,
None,
i64::try_from(current_part_size).expect("test part size should fit i64"),
None,
None,
);
}
fi
}
fn codec_streaming_test_object_info(fi: &FileInfo) -> ObjectInfo {
ObjectInfo::from_file_info(fi, "bucket", "object", false)
}
#[test]
fn codec_streaming_reader_gate_is_conservative() {
temp_env::with_vars(
[
(ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, Some("true")),
(ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE, Some("1")),
],
|| {
let fi = codec_streaming_test_fileinfo(1024, 1);
let object_info = codec_streaming_test_object_info(&fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &object_info, &fi, true),
GetCodecStreamingDecision::Use
);
assert_eq!(
get_codec_streaming_reader_decision(&None, &object_info, &fi, false),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::LockOptimizationDisabled)
);
let range = Some(HTTPRangeSpec {
is_suffix_length: false,
start: 0,
end: 1,
});
assert_eq!(
get_codec_streaming_reader_decision(&range, &object_info, &fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Range)
);
let multipart_fi = codec_streaming_test_fileinfo(1024, 2);
let multipart_object_info = codec_streaming_test_object_info(&multipart_fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &multipart_object_info, &multipart_fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Multipart)
);
let mut encrypted_fi = fi.clone();
encrypted_fi
.metadata
.insert("x-amz-server-side-encryption".to_string(), "AES256".to_string());
let encrypted = codec_streaming_test_object_info(&encrypted_fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &encrypted, &fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Encrypted)
);
let mut compressed_fi = fi.clone();
insert_str(&mut compressed_fi.metadata, SUFFIX_COMPRESSION, "lz4".to_string());
let compressed = codec_streaming_test_object_info(&compressed_fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &compressed, &fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Compressed)
);
let small_fi = codec_streaming_test_fileinfo(0, 1);
let small_object_info = codec_streaming_test_object_info(&small_fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &small_object_info, &small_fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::BelowMinSize)
);
let mut remote_fi = fi;
remote_fi.transition_status = crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE.to_string();
let remote = codec_streaming_test_object_info(&remote_fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &remote, &remote_fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Remote)
);
},
);
}
#[test]
fn codec_streaming_fallback_metric_labels_are_stable() {
assert_eq!(GetCodecStreamingFallbackReason::Disabled.as_str(), "disabled");
assert_eq!(
GetCodecStreamingFallbackReason::LockOptimizationDisabled.as_str(),
"lock_optimization_disabled"
);
assert_eq!(GetCodecStreamingFallbackReason::Range.as_str(), "range");
assert_eq!(GetCodecStreamingFallbackReason::BelowMinSize.as_str(), "below_min_size");
assert_eq!(GetCodecStreamingFallbackReason::Encrypted.as_str(), "encrypted");
assert_eq!(GetCodecStreamingFallbackReason::Compressed.as_str(), "compressed");
assert_eq!(GetCodecStreamingFallbackReason::Remote.as_str(), "remote");
assert_eq!(GetCodecStreamingFallbackReason::Multipart.as_str(), "multipart");
assert_eq!(GetCodecStreamingFallbackReason::InvalidMinSize.as_str(), "invalid_min_size");
}
#[test]
fn codec_streaming_reader_gate_defaults_to_disabled() {
temp_env::with_vars(
[
(ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, None::<&str>),
(ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE, Some("1")),
],
|| {
let fi = codec_streaming_test_fileinfo(1024, 1);
let object_info = codec_streaming_test_object_info(&fi);
assert_eq!(
get_codec_streaming_reader_decision(&None, &object_info, &fi, true),
GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Disabled)
);
},
);
}
#[tokio::test]
async fn collect_read_multiple_results_fails_early_when_quorum_is_impossible() {
let started = std::time::Instant::now();
+183
View File
@@ -0,0 +1,183 @@
// 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::disk::error::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShardSlot {
index: usize,
data: Option<Vec<u8>>,
error: Option<Error>,
}
impl ShardSlot {
pub(crate) fn new(index: usize, data: Option<Vec<u8>>, error: Option<Error>) -> Self {
Self { index, data, error }
}
pub(crate) fn data(index: usize, data: Vec<u8>) -> Self {
Self::new(index, Some(data), None)
}
pub(crate) fn missing(index: usize, error: Error) -> Self {
Self::new(index, None, Some(error))
}
pub(crate) fn index(&self) -> usize {
self.index
}
pub(crate) fn has_data(&self) -> bool {
self.data.is_some()
}
pub(crate) fn data_bytes(&self) -> Option<&[u8]> {
self.data.as_deref()
}
pub(crate) fn error(&self) -> Option<&Error> {
self.error.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StripeReadState {
slots: Vec<ShardSlot>,
read_quorum: usize,
}
impl StripeReadState {
pub(crate) fn new(slots: Vec<ShardSlot>, read_quorum: usize) -> Self {
Self { slots, read_quorum }
}
pub(crate) fn from_parts(shards: Vec<Option<Vec<u8>>>, errors: Vec<Option<Error>>, read_quorum: usize) -> Self {
let slot_count = shards.len().max(errors.len());
let mut slots = Vec::with_capacity(slot_count);
let mut shards = shards.into_iter();
let mut errors = errors.into_iter();
for index in 0..slot_count {
slots.push(ShardSlot::new(index, shards.next().flatten(), errors.next().flatten()));
}
Self::new(slots, read_quorum)
}
pub(crate) fn available_shards(&self) -> usize {
self.slots.iter().filter(|slot| slot.has_data()).count()
}
pub(crate) fn can_decode(&self) -> bool {
self.available_shards() >= self.read_quorum
}
pub(crate) fn slots(&self) -> &[ShardSlot] {
&self.slots
}
pub(crate) fn slot_by_index(&self, index: usize) -> Option<&ShardSlot> {
if let Some(slot) = self.slots.get(index)
&& slot.index == index
{
return Some(slot);
}
self.slots.iter().find(|slot| slot.index == index)
}
pub(crate) fn data_shards_complete(&self, data_shards: usize) -> bool {
(0..data_shards).all(|index| self.slot_by_index(index).is_some_and(ShardSlot::has_data))
}
pub(crate) fn into_parts(self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
let part_count = self.slots.iter().map(|slot| slot.index).max().map_or(0, |index| index + 1);
let mut shards = Vec::with_capacity(part_count);
shards.resize_with(part_count, || None);
let mut errors = Vec::with_capacity(part_count);
errors.resize_with(part_count, || None);
for slot in self.slots {
shards[slot.index] = slot.data;
errors[slot.index] = slot.error;
}
(shards, errors)
}
}
#[async_trait::async_trait]
pub(crate) trait ShardStripeSource: Send {
async fn read_next_stripe(&mut self) -> StripeReadState;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stripe_read_state_tracks_decode_quorum() {
let state = StripeReadState::new(
vec![
ShardSlot::data(0, vec![1]),
ShardSlot::missing(1, Error::FileNotFound),
ShardSlot::data(2, vec![2]),
],
2,
);
assert_eq!(state.available_shards(), 2);
assert!(state.can_decode());
assert_eq!(state.slots()[1].index(), 1);
}
#[test]
fn stripe_read_state_preserves_shards_and_errors() {
let state = StripeReadState::new(vec![ShardSlot::missing(1, Error::FileCorrupt), ShardSlot::data(0, vec![1, 2, 3])], 2);
assert!(!state.can_decode());
let (shards, errors) = state.into_parts();
assert_eq!(shards, vec![Some(vec![1, 2, 3]), None]);
assert_eq!(errors, vec![None, Some(Error::FileCorrupt)]);
}
#[test]
fn stripe_read_state_builds_slots_from_parallel_reader_parts() {
let state =
StripeReadState::from_parts(vec![Some(vec![1]), None, Some(vec![3])], vec![None, Some(Error::FileNotFound)], 2);
assert!(state.can_decode());
assert_eq!(state.slots()[1].index(), 1);
assert_eq!(state.slots()[1].error(), Some(&Error::FileNotFound));
}
#[test]
fn stripe_read_state_reports_complete_data_shards_without_parity() {
let state = StripeReadState::from_parts(vec![Some(vec![1]), Some(vec![2]), None], Vec::new(), 2);
assert!(state.data_shards_complete(2));
assert_eq!(state.slots()[0].data_bytes(), Some(&[1][..]));
assert_eq!(state.slot_by_index(1).and_then(ShardSlot::data_bytes), Some(&[2][..]));
}
#[test]
fn stripe_read_state_rejects_missing_data_shard_for_complete_fast_path() {
let state = StripeReadState::from_parts(vec![Some(vec![1]), None, Some(vec![3])], Vec::new(), 2);
assert!(!state.data_shards_complete(2));
}
#[test]
fn stripe_read_state_finds_out_of_order_slots_by_index() {
let state = StripeReadState::new(vec![ShardSlot::data(2, vec![3]), ShardSlot::data(0, vec![1])], 2);
assert_eq!(state.slot_by_index(0).and_then(ShardSlot::data_bytes), Some(&[1][..]));
assert!(state.slot_by_index(1).is_none());
}
}
+4
View File
@@ -500,6 +500,8 @@ impl SetDisks {
return Ok(());
}
self.invalidate_get_object_metadata_cache(bucket, object).await;
let mut futures = Vec::with_capacity(disks.len());
let mut errs = Vec::with_capacity(disks.len());
@@ -531,6 +533,8 @@ impl SetDisks {
return Err(err);
}
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(())
}