Compare commits

..

1 Commits

Author SHA1 Message Date
houseme b8c45fc9e3 fix(get-object): harden GET fast path against mid-stream regressions (#2472) 2026-04-10 21:38:29 +08:00
8 changed files with 579 additions and 26 deletions
+18
View File
@@ -17,6 +17,24 @@
//! This module defines environment variables and default values for zero-copy
//! read operations, which use memory mapping (mmap) to avoid data copying.
// =============================================================================
// GET Fast Path Configuration
// =============================================================================
/// Environment variable for the GetObject chunk fast path master switch.
///
/// When disabled, `GetObject` bypasses the chunk-streaming fast path entirely and
/// always uses the legacy reader path. This provides an operational stopgap for
/// regressions in the streaming data plane while keeping zero-copy internals
/// configurable independently for future opt-in validation.
pub const ENV_OBJECT_GET_CHUNK_FAST_PATH_ENABLE: &str = "RUSTFS_OBJECT_GET_CHUNK_FAST_PATH_ENABLE";
/// Default: GetObject chunk fast path is disabled.
///
/// The legacy reader path remains the safe default until the chunk-streaming
/// path has sufficient regression coverage for full-body delivery semantics.
pub const DEFAULT_OBJECT_GET_CHUNK_FAST_PATH_ENABLE: bool = false;
// =============================================================================
// Zero-Copy Configuration
// =============================================================================
@@ -14,7 +14,7 @@
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client, rustfs_binary_path};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use http::header::{CONTENT_TYPE, HOST};
@@ -28,6 +28,8 @@ mod tests {
use std::io::{Cursor, Write};
use std::process::Command;
use time::OffsetDateTime;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
const ARCHIVE_TEST_BUCKET: &str = "archive-download-integrity";
@@ -116,6 +118,75 @@ mod tests {
.await?)
}
fn find_header_terminator(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|window| window == b"\r\n\r\n")
}
async fn read_proxy_request(stream: &mut tokio::net::TcpStream) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err("proxy request ended before headers were fully received".into());
}
buffer.extend_from_slice(&chunk[..read]);
if find_header_terminator(&buffer).is_some() {
return Ok(());
}
}
}
async fn spawn_reverse_proxy_to_presigned_url(
target_url: String,
) -> Result<(String, tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>), Box<dyn Error + Send + Sync>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let proxy_url = format!("http://{address}/");
let handle = tokio::spawn(async move {
let (mut downstream, _) = listener.accept().await?;
read_proxy_request(&mut downstream).await?;
let upstream_response: Result<reqwest::Response, reqwest::Error> = local_http_client().get(&target_url).send().await;
let (status, body, content_type) = match upstream_response {
Ok(response) => {
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
match response.bytes().await {
Ok(body) => (status, body.to_vec(), content_type),
Err(err) => {
let body = format!("upstream body read failed: {err}").into_bytes();
(StatusCode::BAD_GATEWAY, body, Some("text/plain".to_string()))
}
}
}
Err(err) => {
let body = format!("upstream request failed: {err}").into_bytes();
(StatusCode::BAD_GATEWAY, body, Some("text/plain".to_string()))
}
};
let mut response_head = format!("HTTP/1.1 {}\r\ncontent-length: {}\r\nconnection: close\r\n", status, body.len());
if let Some(content_type) = content_type {
response_head.push_str(&format!("content-type: {content_type}\r\n"));
}
response_head.push_str("\r\n");
downstream.write_all(response_head.as_bytes()).await?;
downstream.write_all(&body).await?;
downstream.shutdown().await?;
Ok(())
});
Ok((proxy_url, handle))
}
async fn signed_put_request_with_headers(
url: &str,
access_key: &str,
@@ -326,4 +397,130 @@ mod tests {
env.stop_server();
Ok(())
}
#[tokio::test]
#[serial]
async fn test_presigned_get_and_reverse_proxy_preserve_multipart_bytes_with_fast_path()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_server_with_env(&mut env, &[("RUSTFS_OBJECT_GET_CHUNK_FAST_PATH_ENABLE", "true")]).await?;
env.create_test_bucket(MULTIPART_ARCHIVE_TEST_BUCKET).await?;
let client = env.create_s3_client();
let payload = random_bytes(MULTIPART_PART_SIZE + 768 * 1024);
let zip_bytes = build_zip_bytes(&[("payload.bin", payload.as_slice())])?;
assert!(zip_bytes.len() > MULTIPART_PART_SIZE, "zip payload must exceed multipart threshold");
let create_output = client
.create_multipart_upload()
.bucket(MULTIPART_ARCHIVE_TEST_BUCKET)
.key("presigned-multipart-bundle.zip")
.content_type("application/zip")
.send()
.await?;
let upload_id = create_output.upload_id().expect("multipart upload id");
let first_part = zip_bytes[..MULTIPART_PART_SIZE].to_vec();
let second_part = zip_bytes[MULTIPART_PART_SIZE..].to_vec();
let upload_part_1 = client
.upload_part()
.bucket(MULTIPART_ARCHIVE_TEST_BUCKET)
.key("presigned-multipart-bundle.zip")
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_part))
.send()
.await?;
let upload_part_2 = client
.upload_part()
.bucket(MULTIPART_ARCHIVE_TEST_BUCKET)
.key("presigned-multipart-bundle.zip")
.upload_id(upload_id)
.part_number(2)
.body(ByteStream::from(second_part))
.send()
.await?;
let completed_upload = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(upload_part_1.e_tag().unwrap_or_default())
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(upload_part_2.e_tag().unwrap_or_default())
.build(),
)
.build();
client
.complete_multipart_upload()
.bucket(MULTIPART_ARCHIVE_TEST_BUCKET)
.key("presigned-multipart-bundle.zip")
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await?;
let object_url = format!("{}/{}/{}", env.url, MULTIPART_ARCHIVE_TEST_BUCKET, "presigned-multipart-bundle.zip");
let direct_response =
presigned_get_request_with_accept_encoding(&object_url, &env.access_key, &env.secret_key, "identity").await?;
assert_eq!(direct_response.status(), StatusCode::OK);
assert_eq!(
direct_response
.headers()
.get("content-length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok()),
Some(zip_bytes.len())
);
let direct_body = direct_response.bytes().await?;
assert_eq!(direct_body.len(), zip_bytes.len());
assert_eq!(direct_body.as_ref(), zip_bytes.as_slice());
let signed = pre_sign_v4(
http::Request::builder()
.method(http::Method::GET)
.uri(object_url.parse::<http::Uri>()?)
.header(
HOST,
object_url
.parse::<http::Uri>()?
.authority()
.ok_or("request URL missing authority")?
.to_string(),
)
.body(Body::empty())?,
&env.access_key,
&env.secret_key,
"",
"us-east-1",
600,
OffsetDateTime::now_utc(),
);
let (proxy_url, proxy_handle) = spawn_reverse_proxy_to_presigned_url(signed.uri().to_string()).await?;
let proxied_response: reqwest::Response = local_http_client().get(&proxy_url).send().await?;
assert_eq!(proxied_response.status(), StatusCode::OK);
assert_eq!(
proxied_response
.headers()
.get("content-length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok()),
Some(zip_bytes.len())
);
let proxied_body: bytes::Bytes = proxied_response.bytes().await?;
assert_eq!(proxied_body.len(), zip_bytes.len());
assert_eq!(proxied_body.as_ref(), zip_bytes.as_slice());
proxy_handle.await??;
env.stop_server();
Ok(())
}
}
+66
View File
@@ -193,6 +193,8 @@ impl IoStage {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackReason {
Unknown,
FeatureDisabled,
ProbeFailed,
MmapDisabled,
MmapUnavailable,
SmallObject,
@@ -213,6 +215,8 @@ impl FallbackReason {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::FeatureDisabled => "feature_disabled",
Self::ProbeFailed => "probe_failed",
Self::MmapDisabled => "mmap_disabled",
Self::MmapUnavailable => "mmap_unavailable",
Self::SmallObject => "small_object",
@@ -347,6 +351,59 @@ pub fn record_io_fallback(stage: IoStage, reason: FallbackReason) {
.increment(1);
}
/// Record a selected GET chunk fast path.
#[inline(always)]
pub fn record_get_object_fast_path_selected(path: &'static str, copy_mode: CopyMode, promised_bytes: i64) {
counter!(
metric_names::data_plane::GET_FAST_PATH_SELECTED_TOTAL,
"path" => path.to_string(),
"copy_mode" => copy_mode.as_str().to_string()
)
.increment(1);
if promised_bytes >= 0 {
histogram!(metric_names::data_plane::GET_FAST_PATH_PROMISED_BYTES).record(promised_bytes as f64);
}
}
/// Record a failed GET chunk fast path probe before the response is committed.
#[inline(always)]
pub fn record_get_object_fast_path_probe_failed(path: &'static str, copy_mode: CopyMode, promised_bytes: i64) {
counter!(
metric_names::data_plane::GET_FAST_PATH_PROBE_FAILED_TOTAL,
"path" => path.to_string(),
"copy_mode" => copy_mode.as_str().to_string()
)
.increment(1);
if promised_bytes >= 0 {
histogram!(metric_names::data_plane::GET_FAST_PATH_PROMISED_BYTES).record(promised_bytes as f64);
}
}
/// Record a GET chunk fast path mid-stream error after headers have already been committed.
#[inline(always)]
pub fn record_get_object_fast_path_midstream_error(
path: &'static str,
copy_mode: CopyMode,
error_kind: &'static str,
sent_bytes: usize,
promised_bytes: i64,
) {
counter!(
metric_names::data_plane::GET_FAST_PATH_MIDSTREAM_ERROR_TOTAL,
"path" => path.to_string(),
"copy_mode" => copy_mode.as_str().to_string(),
"error_kind" => error_kind.to_string()
)
.increment(1);
histogram!(metric_names::data_plane::GET_FAST_PATH_MIDSTREAM_SENT_BYTES).record(sent_bytes as f64);
if promised_bytes >= 0 {
histogram!(metric_names::data_plane::GET_FAST_PATH_PROMISED_BYTES).record(promised_bytes as f64);
}
}
/// Record the currently active mmap bytes held by LocalDisk chunk streams.
#[inline(always)]
pub fn record_local_disk_active_mmap_bytes(active_bytes: usize) {
@@ -875,6 +932,8 @@ mod tests {
#[test]
fn test_fallback_reason_as_str_values_stable() {
assert_eq!(FallbackReason::Unknown.as_str(), "unknown");
assert_eq!(FallbackReason::FeatureDisabled.as_str(), "feature_disabled");
assert_eq!(FallbackReason::ProbeFailed.as_str(), "probe_failed");
assert_eq!(FallbackReason::MmapDisabled.as_str(), "mmap_disabled");
assert_eq!(FallbackReason::MmapUnavailable.as_str(), "mmap_unavailable");
assert_eq!(FallbackReason::SmallObject.as_str(), "small_object");
@@ -928,6 +987,13 @@ mod tests {
record_local_disk_compat_collect(3, 16384);
}
#[test]
fn test_record_get_object_fast_path_metrics() {
record_get_object_fast_path_selected("direct", CopyMode::TrueZeroCopy, 8192);
record_get_object_fast_path_probe_failed("bridge", CopyMode::SingleCopy, 4096);
record_get_object_fast_path_midstream_error("direct", CopyMode::Reconstructed, "unexpected_eof", 2048, 8192);
}
#[test]
fn test_record_put_object_attempted_fast_path() {
record_put_object_attempted_fast_path(1024 * 1024);
+15
View File
@@ -54,4 +54,19 @@ pub mod data_plane {
/// Size distribution for transformed PUT selections.
pub const PUT_TRANSFORM_SIZE_BYTES: &str = "rustfs.io.put.transform.size.bytes";
/// Total number of selected GET chunk fast paths.
pub const GET_FAST_PATH_SELECTED_TOTAL: &str = "rustfs.io.get.fast_path.selected_total";
/// Total number of GET chunk fast path probe failures before response commit.
pub const GET_FAST_PATH_PROBE_FAILED_TOTAL: &str = "rustfs.io.get.fast_path.probe_failed_total";
/// Total number of GET chunk fast path mid-stream errors after response commit.
pub const GET_FAST_PATH_MIDSTREAM_ERROR_TOTAL: &str = "rustfs.io.get.fast_path.midstream_error_total";
/// Byte distribution promised by GET chunk fast path selections or failures.
pub const GET_FAST_PATH_PROMISED_BYTES: &str = "rustfs.io.get.fast_path.promised.bytes";
/// Byte distribution already sent when a GET chunk fast path fails mid-stream.
pub const GET_FAST_PATH_MIDSTREAM_SENT_BYTES: &str = "rustfs.io.get.fast_path.midstream_sent.bytes";
}
+8
View File
@@ -143,6 +143,14 @@ pub fn chunk_body_data_plane_labels(
)
}
#[must_use]
pub const fn get_object_chunk_path_label(path: GetObjectChunkPath) -> &'static str {
match path {
GetObjectChunkPath::Direct => "direct",
GetObjectChunkPath::Bridge => "bridge",
}
}
pub fn get_object_chunk_fast_path_guard(
has_sse_customer_key: bool,
has_sse_customer_key_md5: bool,
@@ -19,9 +19,11 @@ use crate::error::ApiError;
use crate::storage::concurrency::{ConcurrencyManager, GetObjectGuard, get_buffer_size_opt_in};
use crate::storage::options::filter_object_metadata;
use crate::storage::timeout_wrapper::{RequestTimeoutWrapper, TimeoutConfig};
use futures_util::StreamExt;
use rustfs_ecstore::bucket::versioning_sys::BucketVersioningSys;
use rustfs_ecstore::error::StorageError;
use rustfs_ecstore::store_api::{HTTPRangeSpec, ObjectInfo};
use rustfs_ecstore::store_api::{GetObjectChunkPath, HTTPRangeSpec, ObjectInfo};
use rustfs_io_core::BoxChunkStream;
use rustfs_object_io::get::{
GetObjectBodyPlan as ObjectIoGetObjectBodyPlan, GetObjectBodyPlanningInputs as ObjectIoGetObjectBodyPlanningInputs,
GetObjectBodySource, GetObjectDataPlaneMetricContract as ObjectIoGetObjectDataPlaneMetricContract, GetObjectFlowResult,
@@ -31,6 +33,7 @@ use rustfs_object_io::get::{
build_get_object_checksums as object_io_build_get_object_checksums,
build_get_object_output_context as object_io_build_get_object_output_context,
chunk_body_data_plane_labels as object_io_chunk_body_data_plane_labels,
get_object_chunk_path_label as object_io_get_object_chunk_path_label,
materialize_get_object_body as object_io_materialize_get_object_body, plan_get_object_body as object_io_plan_get_object_body,
plan_get_object_strategy_layout as object_io_plan_get_object_strategy_layout,
};
@@ -48,6 +51,65 @@ pub(super) struct GetObjectBootstrap {
pub(super) _deadlock_request_guard: DeadlockRequestGuard,
}
fn classify_get_object_midstream_error(err: &std::io::Error) -> &'static str {
let lower = err.to_string().to_ascii_lowercase();
if lower.contains("bitrot") {
"bitrot"
} else if lower.contains("decode") {
"decode"
} else {
match err.kind() {
std::io::ErrorKind::UnexpectedEof => "unexpected_eof",
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionAborted => "channel_closed",
_ => "io_other",
}
}
}
fn instrument_get_object_chunk_stream(
request_context: &GetObjectRequestContext,
chunk_stream: BoxChunkStream,
path: GetObjectChunkPath,
copy_mode: rustfs_io_metrics::CopyMode,
response_content_length: i64,
) -> BoxChunkStream {
let bucket = request_context.bucket.clone();
let key = request_context.key.clone();
let version_id = request_context.opts.version_id.clone();
let path_label = object_io_get_object_chunk_path_label(path);
let mut sent_bytes = 0usize;
Box::pin(chunk_stream.map(move |result| match result {
Ok(chunk) => {
sent_bytes = sent_bytes.saturating_add(chunk.len());
Ok(chunk)
}
Err(err) => {
let error_kind = classify_get_object_midstream_error(&err);
rustfs_io_metrics::record_get_object_fast_path_midstream_error(
path_label,
copy_mode,
error_kind,
sent_bytes,
response_content_length,
);
warn!(
bucket = %bucket,
key = %key,
version_id = ?version_id,
path = path_label,
copy_mode = copy_mode.as_str(),
promised_bytes = response_content_length,
sent_bytes,
error_kind,
error = %err,
"GetObject chunk fast path failed mid-stream after response commit"
);
Err(err)
}
}))
}
async fn build_get_object_body_adapter<R>(
final_stream: R,
bucket: &str,
@@ -300,6 +362,8 @@ pub(super) async fn build_get_object_output_context(
copy_mode,
} => {
let (io_path, copy_mode) = object_io_chunk_body_data_plane_labels(path, copy_mode);
let chunk_stream =
instrument_get_object_chunk_stream(request_context, chunk_stream, path, copy_mode, response_content_length);
(
object_io_build_chunk_blob(chunk_stream),
ObjectIoGetObjectDataPlaneMetricContract::disk(io_path, copy_mode),
@@ -374,6 +438,7 @@ mod tests {
use super::*;
use http::HeaderMap;
use rustfs_ecstore::store_api::ObjectOptions;
use rustfs_io_core::IoChunk;
fn sample_range(start: i64, end: i64) -> HTTPRangeSpec {
HTTPRangeSpec {
@@ -417,4 +482,48 @@ mod tests {
assert_eq!(strategy_range.start, 0);
assert_eq!(strategy_range.end, 511);
}
#[test]
fn classify_get_object_midstream_error_maps_expected_variants() {
assert_eq!(
classify_get_object_midstream_error(&std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "eof")),
"unexpected_eof"
);
assert_eq!(
classify_get_object_midstream_error(&std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed")),
"channel_closed"
);
assert_eq!(
classify_get_object_midstream_error(&std::io::Error::other("bitrot verification failed")),
"bitrot"
);
assert_eq!(
classify_get_object_midstream_error(&std::io::Error::other("decode chunk failed")),
"decode"
);
}
#[tokio::test]
async fn instrument_get_object_chunk_stream_preserves_payload() {
let request_context = sample_request_context();
let chunk_stream: BoxChunkStream = Box::pin(futures_util::stream::iter(vec![
Ok(IoChunk::Shared(bytes::Bytes::from_static(b"hello"))),
Ok(IoChunk::Shared(bytes::Bytes::from_static(b" world"))),
]));
let mut instrumented = instrument_get_object_chunk_stream(
&request_context,
chunk_stream,
GetObjectChunkPath::Direct,
rustfs_io_metrics::CopyMode::SharedBytes,
11,
);
let mut collected = Vec::new();
while let Some(chunk) = instrumented.next().await {
collected.extend_from_slice(chunk.unwrap().as_bytes().as_ref());
}
assert_eq!(collected, b"hello world");
}
}
@@ -20,21 +20,56 @@ use crate::storage::{
DecryptionRequest, check_preconditions, get_validated_store, sse_decryption, validate_sse_headers_for_read,
validate_ssec_for_read,
};
use futures_util::{StreamExt, stream};
use http::HeaderMap;
use rustfs_concurrency::GetObjectQueueSnapshot;
use rustfs_ecstore::store_api::{ObjectIO, ObjectOperations};
use rustfs_io_core::{BoxChunkStream, IoChunk};
use rustfs_object_io::get::{
ChunkReadDecision, ChunkReadPlanError, GetObjectEncryptionState as ObjectIoGetObjectEncryptionState, GetObjectReadSetup,
build_reader_read_setup as object_io_build_reader_read_setup,
finalize_chunk_read_setup as object_io_finalize_chunk_read_setup,
get_object_chunk_fast_path_guard as object_io_get_object_chunk_fast_path_guard, plan_chunk_read as object_io_plan_chunk_read,
plan_legacy_read as object_io_plan_legacy_read,
get_object_chunk_fast_path_guard as object_io_get_object_chunk_fast_path_guard,
get_object_chunk_path_label as object_io_get_object_chunk_path_label, map_chunk_copy_mode as object_io_map_chunk_copy_mode,
plan_chunk_read as object_io_plan_chunk_read, plan_legacy_read as object_io_plan_legacy_read,
};
use rustfs_rio::{Reader, WarpReader};
use s3s::{S3Error, S3ErrorCode, S3Result, s3_error};
use std::time::Duration;
use tracing::{debug, warn};
fn get_object_chunk_fast_path_enabled() -> bool {
rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_GET_CHUNK_FAST_PATH_ENABLE,
rustfs_config::DEFAULT_OBJECT_GET_CHUNK_FAST_PATH_ENABLE,
)
}
async fn probe_chunk_stream_before_commit(
mut chunk_stream: BoxChunkStream,
response_content_length: i64,
) -> Result<BoxChunkStream, rustfs_io_metrics::FallbackReason> {
if response_content_length <= 0 {
return Ok(chunk_stream);
}
let mut prefetched = Vec::new();
loop {
match chunk_stream.next().await {
Some(Ok(chunk)) => {
let chunk_len = chunk.len();
prefetched.push(chunk);
if chunk_len > 0 {
let prefix = stream::iter(prefetched.into_iter().map(Ok::<IoChunk, std::io::Error>));
return Ok(Box::pin(prefix.chain(chunk_stream)));
}
}
Some(Err(_)) | None => return Err(rustfs_io_metrics::FallbackReason::ProbeFailed),
}
}
}
pub(super) struct GetObjectIoPlanning<'a> {
pub(super) _disk_permit: tokio::sync::SemaphorePermit<'a>,
pub(super) permit_wait_duration: Duration,
@@ -229,17 +264,25 @@ pub(super) async fn prepare_get_object_read_execution<'a>(
let store = get_validated_store(&request_context.bucket).await?;
let read_start = std::time::Instant::now();
let read_setup = match object_io_get_object_chunk_fast_path_guard(
request_context.sse_customer_key.is_some(),
request_context.sse_customer_key_md5.is_some(),
) {
Ok(()) => match prepare_get_object_chunk_read(request_context, &store, manager, read_start).await? {
Some(read_setup) => read_setup,
None => prepare_get_object_read(request_context, &store, manager, read_start).await?,
},
Err(fallback) => {
rustfs_io_metrics::record_io_fallback(fallback.stage, fallback.reason);
prepare_get_object_read(request_context, &store, manager, read_start).await?
let read_setup = if !get_object_chunk_fast_path_enabled() {
rustfs_io_metrics::record_io_fallback(
rustfs_io_metrics::IoStage::ReadSetup,
rustfs_io_metrics::FallbackReason::FeatureDisabled,
);
prepare_get_object_read(request_context, &store, manager, read_start).await?
} else {
match object_io_get_object_chunk_fast_path_guard(
request_context.sse_customer_key.is_some(),
request_context.sse_customer_key_md5.is_some(),
) {
Ok(()) => match prepare_get_object_chunk_read(request_context, &store, manager, read_start).await? {
Some(read_setup) => read_setup,
None => prepare_get_object_read(request_context, &store, manager, read_start).await?,
},
Err(fallback) => {
rustfs_io_metrics::record_io_fallback(fallback.stage, fallback.reason);
prepare_get_object_read(request_context, &store, manager, read_start).await?
}
}
};
@@ -293,6 +336,7 @@ pub(super) async fn prepare_get_object_chunk_read(
Err(ChunkReadPlanError::Io(err)) => return Err(ApiError::from(err).into()),
};
let rs = plan.rs.clone();
let response_content_length = plan.response_content_length;
let read_duration = read_start.elapsed();
manager.record_disk_operation(info.size as u64, read_duration, true).await;
@@ -318,8 +362,94 @@ pub(super) async fn prepare_get_object_chunk_read(
return Ok(None);
}
};
let path_label = object_io_get_object_chunk_path_label(chunk_result.path);
let copy_mode = object_io_map_chunk_copy_mode(chunk_result.copy_mode);
let chunk_result = match probe_chunk_stream_before_commit(chunk_result.stream, response_content_length).await {
Ok(stream) => rustfs_ecstore::store_api::GetObjectChunkResult {
stream,
path: chunk_result.path,
copy_mode: chunk_result.copy_mode,
},
Err(reason) => {
rustfs_io_metrics::record_io_fallback(rustfs_io_metrics::IoStage::ReadSetup, reason);
rustfs_io_metrics::record_get_object_fast_path_probe_failed(path_label, copy_mode, response_content_length);
warn!(
bucket = %request_context.bucket,
key = %request_context.key,
version_id = ?request_context.opts.version_id,
path = path_label,
copy_mode = copy_mode.as_str(),
promised_bytes = response_content_length,
fallback_reason = reason.as_str(),
"GetObject chunk fast path probe failed before response commit"
);
return Ok(None);
}
};
let setup_result = object_io_finalize_chunk_read_setup(info, event_info, chunk_result, plan);
rustfs_io_metrics::record_get_object_fast_path_selected(path_label, copy_mode, response_content_length);
rustfs_io_metrics::record_io_path_selected("get", setup_result.io_path);
Ok(Some(setup_result.read_setup))
}
#[cfg(test)]
mod tests {
use super::{get_object_chunk_fast_path_enabled, probe_chunk_stream_before_commit};
use bytes::Bytes;
use futures_util::{StreamExt, stream};
use rustfs_io_core::{BoxChunkStream, IoChunk};
#[test]
fn get_object_chunk_fast_path_defaults_to_disabled() {
temp_env::with_var_unset(rustfs_config::ENV_OBJECT_GET_CHUNK_FAST_PATH_ENABLE, || {
assert!(!get_object_chunk_fast_path_enabled());
});
}
#[test]
fn get_object_chunk_fast_path_can_be_explicitly_enabled() {
temp_env::with_var(rustfs_config::ENV_OBJECT_GET_CHUNK_FAST_PATH_ENABLE, Some("true"), || {
assert!(get_object_chunk_fast_path_enabled());
});
}
#[tokio::test]
async fn probe_chunk_stream_before_commit_preserves_prefetched_payload() {
let stream: BoxChunkStream = Box::pin(stream::iter(vec![
Ok(IoChunk::Shared(Bytes::from_static(b"hello "))),
Ok(IoChunk::Shared(Bytes::from_static(b"world"))),
]));
let mut probed = probe_chunk_stream_before_commit(stream, 11).await.unwrap();
let mut collected = Vec::new();
while let Some(chunk) = probed.next().await {
collected.extend_from_slice(chunk.unwrap().as_bytes().as_ref());
}
assert_eq!(collected, b"hello world");
}
#[tokio::test]
async fn probe_chunk_stream_before_commit_rejects_midstream_failure_before_first_chunk() {
let stream: BoxChunkStream = Box::pin(stream::iter(vec![Err(std::io::Error::other("probe failed"))]));
let err = match probe_chunk_stream_before_commit(stream, 1).await {
Ok(_) => panic!("expected probe failure"),
Err(err) => err,
};
assert_eq!(err, rustfs_io_metrics::FallbackReason::ProbeFailed);
}
#[tokio::test]
async fn probe_chunk_stream_before_commit_rejects_unexpected_empty_stream() {
let stream: BoxChunkStream = Box::pin(stream::empty());
let err = match probe_chunk_stream_before_commit(stream, 1).await {
Ok(_) => panic!("expected probe failure"),
Err(err) => err,
};
assert_eq!(err, rustfs_io_metrics::FallbackReason::ProbeFailed);
}
}
@@ -325,6 +325,16 @@ fn build_request<T>(input: T, method: Method) -> S3Request<T> {
}
}
async fn execute_get_object_with_fast_path_enabled(
usecase: &DefaultObjectUsecase,
req: S3Request<GetObjectInput>,
) -> S3Result<S3Response<GetObjectOutput>> {
temp_env::with_var(rustfs_config::ENV_OBJECT_GET_CHUNK_FAST_PATH_ENABLE, Some("true"), || async move {
usecase.execute_get_object(req).await
})
.await
}
#[tokio::test]
async fn execute_get_object_rejects_zero_part_number() {
let input = GetObjectInput::builder()
@@ -383,7 +393,7 @@ async fn prepare_get_object_chunk_read_marks_direct_path_for_single_disk_store()
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(payload.len() as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -476,7 +486,7 @@ async fn execute_get_object_range_marks_direct_path_for_single_disk_store() {
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(63 * 1024 + 1));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -552,7 +562,7 @@ async fn execute_get_object_range_marks_direct_path_for_multi_disk_store_without
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some((range_end - range_start + 1) as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -620,7 +630,7 @@ async fn execute_get_object_range_marks_reconstructed_path_for_multi_disk_store_
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some((range_end - range_start + 1) as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -669,7 +679,7 @@ async fn execute_get_object_part_number_marks_direct_path_for_single_disk_store(
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(parts[0].len() as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -717,7 +727,7 @@ async fn execute_get_object_whole_multipart_marks_direct_path_for_single_disk_st
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(parts.iter().map(|part| part.len() as i64).sum()));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -774,7 +784,7 @@ async fn execute_get_object_part_number_marks_reconstructed_path_for_multi_disk_
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(parts[0].len() as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -825,7 +835,7 @@ async fn execute_get_object_part_number_marks_reconstructed_path_for_second_mult
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(parts[1].len() as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -876,7 +886,7 @@ async fn execute_get_object_part_number_marks_reconstructed_path_for_final_multi
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(parts[2].len() as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -953,7 +963,7 @@ async fn execute_get_object_whole_multipart_marks_reconstructed_path_for_missing
);
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some(expected.len() as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();
@@ -1018,7 +1028,7 @@ async fn execute_get_object_multipart_range_marks_reconstructed_path_for_missing
}
let usecase = DefaultObjectUsecase::without_context();
let response = usecase.execute_get_object(req).await.unwrap();
let response = execute_get_object_with_fast_path_enabled(&usecase, req).await.unwrap();
assert_eq!(response.output.content_length, Some((range_end - range_start + 1) as i64));
let mut body = response.output.body.expect("expected body");
let mut collected = Vec::new();