mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-15 17:43:13 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user