perf(ecstore): guard inline data-read metadata early-stop (#6069)

Add a default-off inline-only data-read metadata early-stop gate that verifies inline plaintext before cancelling pending metadata tasks.

Keep non-inline, prepared, and request-shape-sensitive reads on full fanout, and record scheduled/completed/cancelled ReadVersion lifecycle metrics for normal fanout completion.

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-08-13 21:40:14 +08:00
committed by GitHub
parent f704d015d6
commit aa4d3317ed
11 changed files with 1814 additions and 113 deletions
File diff suppressed because it is too large Load Diff
+406 -29
View File
@@ -880,12 +880,44 @@ mod prepared_get_object_metadata_tests {
use super::*;
use crate::ecstore_validation_blackbox::make_local_set_disks;
use crate::object_api::{BLOCK_SIZE_V2, PutObjReader};
use crate::set_disk::core::io_primitives::disk_call_counters;
use crate::set_disk::core::io_primitives::{bounded_metadata_fanout_order, disk_call_counters, rename_fanout_barrier};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use crate::test_metrics::CapturingRecorder;
use http::HeaderMap;
use tokio::io::AsyncReadExt;
const READ_VERSION_BARRIER_GUARD: std::time::Duration = std::time::Duration::from_secs(10);
fn object_with_initial_data_shards(bucket: &str, prefix: &str) -> String {
(0..1000)
.map(|index| format!("{prefix}-{index}.bin"))
.find(|name| {
let order = bounded_metadata_fanout_order(bucket, name, 4, 2);
let distribution = FileInfo::new(&[bucket, name].join("/"), 2, 2).erasure.distribution;
let mut seen = [false; 2];
for disk_index in order.into_iter().take(3) {
if let Some(block_index @ 1..=2) = distribution.get(disk_index).copied() {
seen[block_index - 1] = true;
}
}
seen.into_iter().all(|seen| seen)
})
.expect("test should find an object whose initial fanout covers both data shards")
}
fn bounded_spare_disk_index(bucket: &str, object: &str) -> usize {
*bounded_metadata_fanout_order(bucket, object, 4, 2)
.get(3)
.expect("4-disk test geometry should leave one bounded spare disk")
}
fn bounded_slow_initial_disk_index(bucket: &str, object: &str) -> usize {
*bounded_metadata_fanout_order(bucket, object, 4, 2)
.get(2)
.expect("4-disk test geometry should include a third initial metadata disk")
}
#[tokio::test]
async fn prepared_metadata_is_consumed_exactly_once() {
let snapshot = GetObjectFileInfo::owned(FileInfo::default(), Vec::new(), Vec::new());
@@ -1002,6 +1034,307 @@ mod prepared_get_object_metadata_tests {
);
}
#[test]
#[serial_test::serial(body_cache_hook)]
fn inline_data_read_early_stop_reader_returns_exact_body() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
let bucket = "inline-data-read-early-stop-reader";
let object = object_with_initial_data_shards(bucket, "inline-data-read-early-stop-reader-object");
let payload = b"inline early-stop reader payload".repeat(256);
let recorder = CapturingRecorder::default();
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let (restored, object_size, calls_total) = metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut put_reader = PutObjReader::from_vec(payload.clone());
set_disks
.put_object(bucket, &object, &mut put_reader, &opts)
.await
.expect("inline object should be written");
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let slow_initial_disk = bounded_slow_initial_disk_index(bucket, &object);
let barrier =
rename_fanout_barrier::arm(&object, slow_initial_disk, rename_fanout_barrier::PHASE_READ_VERSION);
let calls = disk_call_counters::observe(&object);
let set_disks_for_read = Arc::clone(&set_disks);
let opts_for_read = opts.clone();
let object_for_read = object.clone();
let mut open_reader = tokio::spawn(async move {
set_disks_for_read
.get_object_reader(bucket, &object_for_read, None, HeaderMap::new(), &opts_for_read)
.await
});
tokio::time::timeout(READ_VERSION_BARRIER_GUARD, barrier.wait_until_paused())
.await
.expect("bounded inline GET should pause a slow initial metadata read");
let mut reader = tokio::time::timeout(READ_VERSION_BARRIER_GUARD, &mut open_reader)
.await
.expect("production inline GET should return before the paused metadata response")
.expect("inline GET reader task should not panic")
.expect("inline GET reader should open");
let object_size = reader.object_info.size;
let mut restored = Vec::new();
reader
.stream
.read_to_end(&mut restored)
.await
.expect("inline GET body should stream");
(restored, object_size, calls.total(disk_call_counters::KIND_READ_VERSION))
},
)
.await
})
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous_gate);
assert_eq!(object_size, payload.len() as i64);
assert_eq!(restored, payload);
assert_eq!(calls_total, 4, "bounded production GET should schedule the initial quorum plus one spare");
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_scheduled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![4.0],
"bounded production GET should record all scheduled metadata tasks"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_completed",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![3.0],
"bounded production GET should record only observed metadata responses as completed"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_cancelled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![1.0],
"bounded production GET should record the aborted slow metadata task"
);
}
#[test]
#[serial_test::serial(body_cache_hook)]
fn prepared_metadata_uses_full_fanout_even_when_data_read_early_stop_is_enabled() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
let bucket = "prepared-metadata-early-stop-enabled";
let object = object_with_initial_data_shards(bucket, "prepared-metadata-early-stop-enabled-object");
let payload = b"prepared metadata early-stop enabled payload".repeat(16);
let recorder = CapturingRecorder::default();
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let (restored, calls_total) = metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut put_reader = PutObjReader::from_vec(payload.clone());
set_disks
.put_object(bucket, &object, &mut put_reader, &opts)
.await
.expect("object should be written");
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let calls = disk_call_counters::observe(&object);
let metadata = set_disks
.prepare_get_object_metadata(bucket, &object, &opts)
.await
.expect("prepared metadata should resolve");
let calls_total = calls.total(disk_call_counters::KIND_READ_VERSION);
let mut reader = set_disks
.get_object_reader_with_prepared_metadata(bucket, &object, None, HeaderMap::new(), &opts, metadata)
.await
.expect("prepared body reader should open");
let mut restored = Vec::new();
reader
.stream
.read_to_end(&mut restored)
.await
.expect("prepared body should stream");
(restored, calls_total)
},
)
.await
})
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous_gate);
assert_eq!(restored, payload);
assert_eq!(
calls_total, 4,
"prepared metadata must opt out of data-read early-stop until the read shape is known"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_scheduled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![4.0],
"prepared metadata should schedule the full metadata fanout"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_completed",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![4.0],
"prepared metadata must wait for every scheduled metadata response"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_cancelled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![0.0],
"prepared metadata must not cancel metadata responses"
);
}
#[test]
#[serial_test::serial(body_cache_hook)]
fn data_read_early_stop_request_shapes_full_wait_in_production_reader() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
let bucket = "data-read-early-stop-shape-reader";
let payload = b"shape-gated inline reader payload".repeat(256);
for (object_prefix, range, configure_opts, expected_body) in [
(
"data-read-early-stop-range-reader-object",
Some(HTTPRangeSpec {
start: 0,
end: 3,
is_suffix_length: false,
}),
None,
payload[..4].to_vec(),
),
("data-read-early-stop-part-reader-object", None, Some(1), payload.clone()),
] {
let recorder = CapturingRecorder::default();
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let (restored, calls_total) = metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let object = object_with_initial_data_shards(bucket, object_prefix);
let mut opts = ObjectOptions {
no_lock: true,
..Default::default()
};
opts.part_number = configure_opts;
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut put_reader = PutObjReader::from_vec(payload.clone());
set_disks
.put_object(bucket, &object, &mut put_reader, &opts)
.await
.expect("inline object should be written");
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let calls = disk_call_counters::observe(&object);
let mut reader = set_disks
.get_object_reader(bucket, &object, range, HeaderMap::new(), &opts)
.await
.expect("shape-gated GET reader should open");
let mut restored = Vec::new();
reader
.stream
.read_to_end(&mut restored)
.await
.expect("shape-gated GET body should stream");
(restored, calls.total(disk_call_counters::KIND_READ_VERSION))
},
)
.await
})
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous_gate);
assert_eq!(restored, expected_body);
assert_eq!(calls_total, 4, "shape-gated production GET should keep full metadata fanout");
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_scheduled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![4.0],
"shape-gated production GET should schedule the full metadata fanout"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_completed",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![4.0],
"shape-gated production GET must wait for every scheduled metadata response"
);
assert_eq!(
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_cancelled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
),
vec![0.0],
"shape-gated production GET must not cancel metadata responses"
);
}
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn prepared_reader_rebuilds_object_info_when_precomputed_value_is_absent() {
@@ -1105,7 +1438,7 @@ impl SetDisks {
object: &str,
opts: &ObjectOptions,
) -> Result<PreparedGetObjectMetadata> {
let snapshot = self.get_object_fileinfo(bucket, object, opts, true, true).await?;
let snapshot = self.get_object_fileinfo(bucket, object, opts, true, false).await?;
let object_info = build_get_object_info(snapshot.fi(), bucket, object, opts.versioned || opts.version_suspended);
Ok(PreparedGetObjectMetadata {
snapshot,
@@ -3411,9 +3744,15 @@ fn collect_inline_data_shard_fileinfos_by_index<'a>(
if block_index == 0 || block_index > data_shards {
continue;
}
if file_info.erasure.index != block_index {
continue;
}
if !file_info.has_valid_erasure_geometry() {
continue;
}
if !core::io_primitives::metadata_early_stop_candidate_matches(file_info, fi) {
continue;
}
if file_info.data.as_ref().is_none_or(|data| data.is_empty()) {
continue;
}
@@ -9221,6 +9560,9 @@ mod tests {
HashAlgorithm::HighwayHash256S
};
let shards = erasure.encode_data(payload).expect("payload should encode");
let version_id = Some(Uuid::new_v4());
let data_dir = Some(Uuid::new_v4());
let mod_time = Some(OffsetDateTime::now_utc());
let mut files = Vec::with_capacity(shards.len());
for shard in shards {
@@ -9233,6 +9575,16 @@ mod tests {
writer.shutdown().await.expect("inline writer should shutdown");
let data = writer.into_inline_data().expect("inline data should be retained");
let mut file = FileInfo::new("bucket/object", erasure.data_shards, erasure.parity_shards);
file.volume = "bucket".to_string();
file.name = "object".to_string();
file.size = i64::try_from(payload.len()).expect("test payload should fit i64");
file.is_latest = true;
file.version_id = version_id;
file.data_dir = data_dir;
file.mod_time = mod_time;
file.metadata.insert("etag".to_string(), "etag-inline".to_string());
file.add_object_part(1, "part-etag-inline".to_string(), payload.len(), file.mod_time, file.size, None, None);
file.set_inline_data();
file.erasure.index = files.len() + 1;
file.data = Some(Bytes::from(data));
files.push(file);
@@ -9245,16 +9597,40 @@ mod tests {
inline_bitrot_files_for_payload_with_mode(payload, false).await
}
fn disk_ordered_fileinfos(files: &[FileInfo]) -> Vec<FileInfo> {
let distribution = &files
.first()
.expect("inline data shard fixture should include metadata")
.erasure
.distribution;
distribution
.iter()
.map(|block_index| {
files
.get(block_index.checked_sub(1).expect("erasure block indexes are one-based"))
.expect("inline data shard fixture should include every distributed shard")
.clone()
})
.collect()
}
fn inline_data_shard_fileinfo(
name: &str,
data_blocks: usize,
parity_blocks: usize,
erasure_index: usize,
distribution: &[usize],
data: Option<&'static [u8]>,
) -> FileInfo {
let mut fi = FileInfo::new(name, data_blocks, parity_blocks);
fi.name = name.to_string();
let mut fi = FileInfo::new("object", data_blocks, parity_blocks);
fi.name = "object".to_string();
fi.volume = "bucket".to_string();
fi.size = 4;
fi.is_latest = true;
fi.data_dir = Some(Uuid::nil());
fi.mod_time = Some(OffsetDateTime::UNIX_EPOCH);
fi.metadata.insert("etag".to_string(), "etag-inline".to_string());
fi.add_object_part(1, "part-etag-inline".to_string(), 4, fi.mod_time, 4, None, None);
fi.set_inline_data();
fi.erasure.index = erasure_index;
fi.erasure.distribution = distribution.to_vec();
fi.data = data.map(Bytes::from_static);
@@ -9264,36 +9640,41 @@ mod tests {
#[test]
fn collect_inline_data_shards_by_index_uses_distribution_order() {
let distribution = vec![3, 1, 5, 2, 4, 6];
let mut fi = FileInfo::new("object", 4, 2);
let mut fi = inline_data_shard_fileinfo(4, 2, 1, &distribution, Some(b"x"));
fi.erasure.index = 1;
fi.erasure.distribution = distribution.clone();
let files = vec![
inline_data_shard_fileinfo("block-3", 4, 2, 3, &distribution, Some(b"c")),
inline_data_shard_fileinfo("block-1", 4, 2, 1, &distribution, Some(b"a")),
inline_data_shard_fileinfo("parity-5", 4, 2, 5, &distribution, Some(b"p")),
inline_data_shard_fileinfo("block-2", 4, 2, 2, &distribution, Some(b"b")),
inline_data_shard_fileinfo("block-4", 4, 2, 4, &distribution, Some(b"d")),
inline_data_shard_fileinfo("parity-6", 4, 2, 6, &distribution, Some(b"q")),
inline_data_shard_fileinfo(4, 2, 3, &distribution, Some(b"c")),
inline_data_shard_fileinfo(4, 2, 1, &distribution, Some(b"a")),
inline_data_shard_fileinfo(4, 2, 5, &distribution, Some(b"p")),
inline_data_shard_fileinfo(4, 2, 2, &distribution, Some(b"b")),
inline_data_shard_fileinfo(4, 2, 4, &distribution, Some(b"d")),
inline_data_shard_fileinfo(4, 2, 6, &distribution, Some(b"q")),
];
let data_files =
collect_inline_data_shard_fileinfos_by_index(&files, &fi, 4, |_| true).expect("all data shards should be collected");
assert_eq!(
data_files.iter().map(|file| file.name.as_str()).collect::<Vec<_>>(),
["block-1", "block-2", "block-3", "block-4"]
data_files
.iter()
.map(|file| file.data.as_deref().expect("fixture carries inline bytes"))
.collect::<Vec<_>>(),
[b"a".as_slice(), b"b".as_slice(), b"c".as_slice(), b"d".as_slice()]
);
}
#[test]
fn collect_inline_data_shards_by_index_rejects_missing_data_shard() {
let distribution = vec![1, 2, 3, 4];
let mut fi = FileInfo::new("object", 2, 2);
let mut fi = inline_data_shard_fileinfo(2, 2, 1, &distribution, Some(b"x"));
fi.erasure.index = 1;
fi.erasure.distribution = distribution.clone();
let files = vec![
inline_data_shard_fileinfo("block-1", 2, 2, 1, &distribution, Some(b"a")),
inline_data_shard_fileinfo("block-2", 2, 2, 2, &distribution, None),
inline_data_shard_fileinfo("parity-3", 2, 2, 3, &distribution, Some(b"p")),
inline_data_shard_fileinfo("parity-4", 2, 2, 4, &distribution, Some(b"q")),
inline_data_shard_fileinfo(2, 2, 1, &distribution, Some(b"a")),
inline_data_shard_fileinfo(2, 2, 2, &distribution, None),
inline_data_shard_fileinfo(2, 2, 3, &distribution, Some(b"p")),
inline_data_shard_fileinfo(2, 2, 4, &distribution, Some(b"q")),
];
assert!(collect_inline_data_shard_fileinfos_by_index(&files, &fi, 2, |_| true).is_none());
@@ -9426,10 +9807,8 @@ mod tests {
let payload = vec![b'i'; 192 * 1024];
let (erasure, files, _read_length, _checksum_algo) = inline_bitrot_files_for_payload(&payload).await;
let mut fi = FileInfo::new("bucket/object", erasure.data_shards, erasure.parity_shards);
fi.size = payload.len() as i64;
fi.data = files[0].data.clone();
fi.add_object_part(1, String::new(), payload.len(), None, payload.len() as i64, None, None);
let fi = files[0].clone();
let disk_files = disk_ordered_fileinfos(&files);
let disks = vec![Some(disk); erasure.total_shard_count()];
let metrics_size_bucket = rustfs_io_metrics::get_object_size_bucket(fi.size);
@@ -9438,7 +9817,7 @@ mod tests {
"bucket",
"object",
&fi,
&files,
&disk_files,
&disks,
true,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
@@ -9469,10 +9848,8 @@ mod tests {
let payload = vec![b'v'; 64 * 1024];
let payload_size = i64::try_from(payload.len()).expect("test payload size should fit i64");
let (erasure, files, _read_length, _checksum_algo) = inline_bitrot_files_for_payload(&payload).await;
let mut fi = FileInfo::new("bucket/object", erasure.data_shards, erasure.parity_shards);
fi.size = payload_size;
fi.data = files[0].data.clone();
fi.add_object_part(1, String::new(), payload.len(), None, payload_size, None, None);
let fi = files[0].clone();
let disk_files = disk_ordered_fileinfos(&files);
let mut object_info = ObjectInfo {
size: payload_size,
@@ -9503,7 +9880,7 @@ mod tests {
"bucket",
"object",
&fi,
&files,
&disk_files,
&vec![Some(disk); erasure.total_shard_count()],
true,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
+190 -3
View File
@@ -188,6 +188,74 @@ async fn get_object_reader_with_context(
GetObjectReader::new_with_resolver(reader, range, object_info, opts, headers, ctx.object_encryption_resolver()).await
}
fn data_read_metadata_early_stop_request_shape_allowed(range: &Option<HTTPRangeSpec>, opts: &ObjectOptions) -> bool {
range.is_none()
&& opts.part_number.is_none()
&& opts.version_id.is_none()
&& !opts.incl_free_versions
&& !opts.skip_free_version
&& !opts.raw_data_movement_read
&& !opts.data_movement
&& !crate::object_api::restore_request_active(opts)
}
#[cfg(test)]
mod data_read_metadata_early_stop_request_shape_tests {
use super::*;
#[test]
fn data_read_metadata_early_stop_only_allows_whole_latest_plain_get_shape() {
assert!(data_read_metadata_early_stop_request_shape_allowed(&None, &ObjectOptions::default()));
let range = Some(HTTPRangeSpec {
is_suffix_length: false,
start: 0,
end: 0,
});
assert!(!data_read_metadata_early_stop_request_shape_allowed(&range, &ObjectOptions::default()));
let part_opts = ObjectOptions {
part_number: Some(1),
..Default::default()
};
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &part_opts));
let version_opts = ObjectOptions {
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &version_opts));
let incl_free_opts = ObjectOptions {
incl_free_versions: true,
..Default::default()
};
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &incl_free_opts));
let skip_free_opts = ObjectOptions {
skip_free_version: true,
..Default::default()
};
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &skip_free_opts));
let data_movement_opts = ObjectOptions {
data_movement: true,
..Default::default()
};
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &data_movement_opts));
let raw_data_movement_opts = ObjectOptions {
raw_data_movement_read: true,
..Default::default()
};
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &raw_data_movement_opts));
let mut restore_opts = ObjectOptions::default();
restore_opts.transition.restore_request.days = Some(1);
assert!(!data_read_metadata_early_stop_request_shape_allowed(&None, &restore_opts));
}
}
/// Length of the full plaintext body when — and only when — this read's output
/// is exactly the object's complete plaintext, so the app-layer body cache may
/// serve it in place of the erasure read.
@@ -431,7 +499,16 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
let (snapshot, prepared_object_info) = if let Some(prepared) = take_prepared_get_object_metadata() {
(prepared.snapshot, prepared.object_info)
} else {
match self.get_object_fileinfo(bucket, object, opts, true, true).await {
match self
.get_object_fileinfo(
bucket,
object,
opts,
true,
data_read_metadata_early_stop_request_shape_allowed(&range, opts),
)
.await
{
Ok(snapshot) => (snapshot, None),
Err(err) => {
rustfs_io_metrics::record_get_object_metadata_phase_duration(metadata_stage_start.elapsed().as_secs_f64());
@@ -6102,7 +6179,10 @@ mod inline_put_commit_path_tests {
mod get_object_downstream_close_accounting_tests {
use super::hermetic_set_disks_support::hermetic_set_disks;
use super::*;
use crate::diagnostics::get::{GET_OBJECT_PATH_INTERNAL_META, GET_STAGE_DECODE, GET_STAGE_EMIT, GetObjectFailureReason};
use crate::diagnostics::get::{
GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST, GET_OBJECT_PATH_INTERNAL_META, GET_STAGE_DECODE, GET_STAGE_EMIT,
GetObjectFailureReason,
};
use crate::disk::RUSTFS_META_BUCKET;
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
@@ -6221,7 +6301,22 @@ mod get_object_downstream_close_accounting_tests {
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let (internal_missing, legacy_unknown, internal_fanout, legacy_fanout) = metrics::with_local_recorder(&recorder, || {
let (
internal_missing,
legacy_unknown,
internal_fanout,
legacy_fanout,
internal_scheduled,
legacy_scheduled,
internal_completed,
legacy_completed,
internal_cancelled,
legacy_cancelled,
internal_unsafe_miss,
legacy_unsafe_miss,
internal_saved,
legacy_saved,
) = metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
let (_temp_dirs, _disk_stores, set_disks) = hermetic_set_disks(4).await;
let options = ObjectOptions {
@@ -6265,6 +6360,54 @@ mod get_object_downstream_close_accounting_tests {
"rustfs_io_get_object_metadata_fanout_error_responses",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_scheduled",
&[("path", GET_OBJECT_PATH_INTERNAL_META)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_scheduled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_completed",
&[("path", GET_OBJECT_PATH_INTERNAL_META)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_completed",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_cancelled",
&[("path", GET_OBJECT_PATH_INTERNAL_META)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_cancelled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)],
),
recorder.counter_value(
"rustfs_io_get_object_metadata_early_stop_total",
&[
("path", GET_OBJECT_PATH_INTERNAL_META),
("decision", "miss"),
("reason", GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST),
],
),
recorder.counter_value(
"rustfs_io_get_object_metadata_early_stop_total",
&[
("path", GET_OBJECT_PATH_LEGACY_DUPLEX),
("decision", "miss"),
("reason", GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST),
],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_early_stop_saved_responses",
&[("path", GET_OBJECT_PATH_INTERNAL_META)],
),
recorder.histogram_values(
"rustfs_io_get_object_metadata_early_stop_saved_responses",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)],
),
)
})
});
@@ -6277,6 +6420,50 @@ mod get_object_downstream_close_accounting_tests {
);
assert_eq!(internal_fanout, vec![4.0], "internal metadata fanout must retain its path label");
assert!(legacy_fanout.is_empty(), "internal metadata fanout must not leak into legacy_duplex");
assert_eq!(
internal_scheduled,
vec![4.0],
"internal metadata lifecycle scheduled count must retain its path label"
);
assert!(
legacy_scheduled.is_empty(),
"internal metadata lifecycle scheduled count must not leak into legacy_duplex"
);
assert_eq!(
internal_completed,
vec![4.0],
"internal metadata lifecycle completed count must retain its path label"
);
assert!(
legacy_completed.is_empty(),
"internal metadata lifecycle completed count must not leak into legacy_duplex"
);
assert_eq!(
internal_cancelled,
vec![0.0],
"internal metadata full-wait lifecycle must record zero cancellations"
);
assert!(
legacy_cancelled.is_empty(),
"internal metadata lifecycle cancelled count must not leak into legacy_duplex"
);
assert_eq!(
internal_unsafe_miss, 1,
"internal metadata unsafe early-stop miss must retain its path label"
);
assert_eq!(
legacy_unsafe_miss, 0,
"internal metadata unsafe early-stop miss must not leak into legacy_duplex"
);
assert_eq!(
internal_saved,
vec![0.0],
"internal metadata unsafe miss must record zero saved responses on internal_meta"
);
assert!(
legacy_saved.is_empty(),
"internal metadata unsafe miss saved responses must not leak into legacy_duplex"
);
}
}
+2
View File
@@ -14,6 +14,8 @@
//! test endpoint index settings
#![recursion_limit = "256"]
use std::net::SocketAddr;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
@@ -22,6 +22,8 @@
//! bucket-metadata-sys OnceCell) — under `cargo nextest` each test runs
//! in its own process so the OnceCell never collides.
#![recursion_limit = "256"]
use http::HeaderMap;
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
use rustfs_heal::heal::{
@@ -21,6 +21,8 @@
//! These drive the REAL `ECStoreHealStorage` + `ECStore` against real disks.
//! Every test is `#[serial]`; under `cargo nextest` each runs in its own process.
#![recursion_limit = "256"]
use http::HeaderMap;
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
use rustfs_heal::heal::storage::{
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#![recursion_limit = "256"]
use http::HeaderMap;
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
use rustfs_heal::heal::{
+44
View File
@@ -812,6 +812,17 @@ pub fn record_get_object_metadata_fanout_shape(path: &'static str, total: usize,
.record(metadata_fanout_count_to_f64(non_valid));
}
/// Record task lifecycle shape for one GetObject metadata fanout.
#[inline(always)]
pub fn record_get_object_metadata_fanout_lifecycle(path: &'static str, scheduled: usize, completed: usize, cancelled: usize) {
if !get_stage_metrics_enabled() {
return;
}
histogram!("rustfs_io_get_object_metadata_fanout_scheduled", "path" => path).record(metadata_fanout_count_to_f64(scheduled));
histogram!("rustfs_io_get_object_metadata_fanout_completed", "path" => path).record(metadata_fanout_count_to_f64(completed));
histogram!("rustfs_io_get_object_metadata_fanout_cancelled", "path" => path).record(metadata_fanout_count_to_f64(cancelled));
}
/// Record a guarded metadata early-stop hit for GetObject.
#[inline(always)]
pub fn record_get_object_metadata_early_stop_hit(path: &'static str, reason: &'static str) {
@@ -2698,6 +2709,7 @@ mod tests {
record_get_object_quorum_reached_latency("legacy_duplex", 0.002);
record_get_object_metadata_response("legacy_duplex", "valid");
record_get_object_metadata_fanout_shape("legacy_duplex", 4, 3, 1, 1);
record_get_object_metadata_fanout_lifecycle("legacy_duplex", 4, 3, 1);
record_get_object_metadata_early_stop_hit("legacy_duplex", "valid_quorum");
record_get_object_metadata_early_stop_miss("legacy_duplex", "insufficient_quorum");
record_get_object_metadata_early_stop_saved_responses("legacy_duplex", 1);
@@ -2768,6 +2780,38 @@ mod tests {
assert!(remote_scheduled >= remote_avoid_potential);
}
#[test]
fn metadata_fanout_lifecycle_records_named_histograms() {
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, || {
set_get_stage_metrics_enabled(true);
record_get_object_metadata_fanout_lifecycle("legacy_duplex", 4, 3, 1);
set_get_stage_metrics_enabled(false);
});
let metrics = snapshotter.snapshot().into_vec();
for (name, expected) in [
("rustfs_io_get_object_metadata_fanout_scheduled", 4.0),
("rustfs_io_get_object_metadata_fanout_completed", 3.0),
("rustfs_io_get_object_metadata_fanout_cancelled", 1.0),
] {
let value = metrics.iter().find_map(|(composite, _, _, value)| {
let has_path = composite
.key()
.labels()
.any(|label| label.key() == "path" && label.value() == "legacy_duplex");
(composite.kind() == MetricKind::Histogram && composite.key().name() == name && has_path).then_some(value)
});
assert!(
matches!(value, Some(DebugValue::Histogram(values)) if values.len() == 1 && values[0].0 == expected),
"{name} must record the exact fanout lifecycle sample"
);
}
}
#[test]
fn test_record_get_object_fill_metrics() {
record_get_object_fill_queued("codec_streaming", "single_inflight", 1);
@@ -23,6 +23,7 @@
//! two are tested together because a reload is the only way to tell a real
//! merge from one that happened to look right in the cache.
#![recursion_limit = "256"]
#![cfg(feature = "swift")]
use std::collections::HashMap;
+1
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#![recursion_limit = "256"]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![warn(
// missing_docs,
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#![recursion_limit = "256"]
use futures::FutureExt;
use rustfs_config::ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT;
use rustfs_scanner::scanner_folder::ScannerItem;