mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 11:32:19 +00:00
perf(ecstore): in-place per-part legacy degradation for lazy multipart codec reader (backlog#879) (#4560)
perf(ecstore): in-place per-part legacy degradation for lazy multipart codec reader Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
@@ -38,7 +38,8 @@ use crate::diagnostics::get::{
|
|||||||
};
|
};
|
||||||
use crate::erasure::coding::BitrotReader;
|
use crate::erasure::coding::BitrotReader;
|
||||||
use crate::io_support::bitrot::{
|
use crate::io_support::bitrot::{
|
||||||
BitrotReaderStageMetrics, create_bitrot_reader_with_stage_metrics, create_deferred_bitrot_reader, object_mmap_read_enabled,
|
BitrotReaderStageMetrics, DeferredReaderStripeHandle, create_bitrot_reader_with_stage_metrics, create_deferred_bitrot_reader,
|
||||||
|
object_mmap_read_enabled,
|
||||||
};
|
};
|
||||||
use crate::set_disk::shard_source::ShardReadCost;
|
use crate::set_disk::shard_source::ShardReadCost;
|
||||||
use futures::stream::{FuturesUnordered, StreamExt};
|
use futures::stream::{FuturesUnordered, StreamExt};
|
||||||
@@ -1098,6 +1099,10 @@ impl SetDisks {
|
|||||||
metrics_object_class,
|
metrics_object_class,
|
||||||
metrics_size_bucket,
|
metrics_size_bucket,
|
||||||
prefer_data_blocks_first_reader_setup,
|
prefer_data_blocks_first_reader_setup,
|
||||||
|
// Single-part objects keep the whole-request fallback: a degraded
|
||||||
|
// sole part is detected before any byte streams, so the caller can
|
||||||
|
// still hand the request to the legacy duplex path unchanged.
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1146,6 +1151,10 @@ impl SetDisks {
|
|||||||
metrics_object_class,
|
metrics_object_class,
|
||||||
metrics_size_bucket,
|
metrics_size_bucket,
|
||||||
false,
|
false,
|
||||||
|
// The first part stays eager and keeps the whole-request fallback:
|
||||||
|
// if part 1 is already degraded, the entire GET drops to the legacy
|
||||||
|
// duplex path before a single byte is streamed (semantics unchanged).
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
@@ -1187,6 +1196,11 @@ impl SetDisks {
|
|||||||
ctx.metrics_object_class,
|
ctx.metrics_object_class,
|
||||||
ctx.metrics_size_bucket,
|
ctx.metrics_size_bucket,
|
||||||
false,
|
false,
|
||||||
|
// backlog#879: later parts have already streamed earlier bytes,
|
||||||
|
// so a whole-request fallback is impossible here. Degrade this
|
||||||
|
// part in place to a legacy per-part decode reader instead of
|
||||||
|
// failing the stream mid-flight.
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -1214,6 +1228,14 @@ impl SetDisks {
|
|||||||
metrics_object_class: &'static str,
|
metrics_object_class: &'static str,
|
||||||
metrics_size_bucket: &'static str,
|
metrics_size_bucket: &'static str,
|
||||||
prefer_data_blocks_first_reader_setup: bool,
|
prefer_data_blocks_first_reader_setup: bool,
|
||||||
|
// backlog#879: when the codec streaming fast path cannot serve this part
|
||||||
|
// (a shard is missing and reconstruction is required), `false` preserves
|
||||||
|
// the historical whole-request fallback by returning `Fallback`, while
|
||||||
|
// `true` degrades in place — building a legacy per-part decode reader that
|
||||||
|
// reuses the shard readers already opened here. Only lazily-built later
|
||||||
|
// parts pass `true`, so the eager first-part fallback semantics are
|
||||||
|
// untouched and the common read path is never affected.
|
||||||
|
allow_inplace_legacy_fallback: bool,
|
||||||
) -> Result<GetCodecStreamingReaderBuildOutcome> {
|
) -> Result<GetCodecStreamingReaderBuildOutcome> {
|
||||||
if part_length > part_size {
|
if part_length > part_size {
|
||||||
return Err(Error::other("codec streaming reader part length exceeds part size"));
|
return Err(Error::other("codec streaming reader part length exceeds part size"));
|
||||||
@@ -1278,6 +1300,33 @@ impl SetDisks {
|
|||||||
|
|
||||||
let missing_shards = reader_setup.completed_failed_shards();
|
let missing_shards = reader_setup.completed_failed_shards();
|
||||||
if let Some(reason) = codec_streaming_reader_setup_fallback_reason(missing_shards) {
|
if let Some(reason) = codec_streaming_reader_setup_fallback_reason(missing_shards) {
|
||||||
|
if allow_inplace_legacy_fallback {
|
||||||
|
// backlog#879: read quorum still holds (checked above), so the
|
||||||
|
// shard readers already opened here can reconstruct this part via
|
||||||
|
// the legacy per-part decode path. Bridge that decode into an
|
||||||
|
// AsyncRead and keep streaming instead of failing mid-flight. This
|
||||||
|
// is a successful degradation, not a pipeline failure: record it on
|
||||||
|
// the existing codec-streaming fallback counter and log at debug.
|
||||||
|
rustfs_io_metrics::record_get_object_codec_streaming_fallback(reason.as_str());
|
||||||
|
debug!(
|
||||||
|
metrics_path,
|
||||||
|
part_number,
|
||||||
|
missing_shards,
|
||||||
|
fallback_reason = ?reason,
|
||||||
|
state = "codec_streaming_mid_stream_legacy_fallback",
|
||||||
|
"Codec streaming later part degraded in place to legacy per-part decode"
|
||||||
|
);
|
||||||
|
let reader = build_legacy_per_part_fallback_reader(
|
||||||
|
erasure.clone(),
|
||||||
|
reader_setup.readers,
|
||||||
|
reader_setup.deferred_stripe_handles,
|
||||||
|
read_costs,
|
||||||
|
part_offset,
|
||||||
|
part_length,
|
||||||
|
part_size,
|
||||||
|
);
|
||||||
|
return Ok(GetCodecStreamingReaderBuildOutcome::Reader(reader));
|
||||||
|
}
|
||||||
return Ok(GetCodecStreamingReaderBuildOutcome::Fallback(reason));
|
return Ok(GetCodecStreamingReaderBuildOutcome::Fallback(reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,10 +1478,11 @@ type LazyPartBuilder = Box<dyn FnMut(usize) -> LazyPartBuildHandle + Send + Sync
|
|||||||
/// The first part reader is built eagerly by the caller so the dominant
|
/// The first part reader is built eagerly by the caller so the dominant
|
||||||
/// fallback conditions are detected before any byte is streamed; every
|
/// fallback conditions are detected before any byte is streamed; every
|
||||||
/// subsequent part is only built once the previous part reaches EOF. If a
|
/// subsequent part is only built once the previous part reaches EOF. If a
|
||||||
/// later part hits a fallback condition mid-stream, the reader surfaces a read
|
/// later part hits a fallback condition mid-stream, its builder degrades in
|
||||||
/// error instead: data has already been streamed, so switching the whole
|
/// place to a legacy per-part decode reader (backlog#879) and streaming
|
||||||
/// request to the legacy path is no longer possible. The next request detects
|
/// continues, so a degraded later part is reconstructed instead of failing the
|
||||||
/// the condition on its eager first-part setup and falls back cleanly.
|
/// request. The builder therefore never yields `Fallback` for a lazily-built
|
||||||
|
/// part in production; the `Fallback` arm below stays only as a defensive guard.
|
||||||
struct LazyMultipartCodecStreamingReader {
|
struct LazyMultipartCodecStreamingReader {
|
||||||
current: Option<Box<dyn AsyncRead + Unpin + Send + Sync>>,
|
current: Option<Box<dyn AsyncRead + Unpin + Send + Sync>>,
|
||||||
pending: Option<LazyPartBuildHandle>,
|
pending: Option<LazyPartBuildHandle>,
|
||||||
@@ -1491,19 +1541,15 @@ impl AsyncRead for LazyMultipartCodecStreamingReader {
|
|||||||
this.current = Some(reader);
|
this.current = Some(reader);
|
||||||
}
|
}
|
||||||
Ok(Ok(GetCodecStreamingReaderBuildOutcome::Fallback(reason))) => {
|
Ok(Ok(GetCodecStreamingReaderBuildOutcome::Fallback(reason))) => {
|
||||||
// KNOWN LIMITATION (backlog#871 follow-up): once
|
// Defensive guard: since backlog#879 the lazy part
|
||||||
// earlier parts have streamed we can no longer
|
// builder degrades a missing-shard part in place to
|
||||||
// hand the whole request back to the legacy
|
// a legacy per-part decode reader and returns
|
||||||
// duplex path, so a degraded later part surfaces
|
// `Reader`, so this arm is not reached on the
|
||||||
// as a read error even though legacy per-part
|
// production path. If a builder ever does surface a
|
||||||
// decode could still reconstruct it. This only
|
// `Fallback` mid-stream, earlier bytes have already
|
||||||
// affects the OPT-IN multipart codec streaming
|
// shipped and the whole request can no longer be
|
||||||
// path (RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE,
|
// handed to the legacy duplex path, so we surface a
|
||||||
// default off): the first part stays eager so the
|
// read error rather than truncate silently.
|
||||||
// common case where part 1 is already degraded
|
|
||||||
// still falls back cleanly before any byte ships.
|
|
||||||
// A proper fix (in-place per-part legacy
|
|
||||||
// degradation) is tracked as a follow-up.
|
|
||||||
record_get_object_pipeline_failure_for_path(
|
record_get_object_pipeline_failure_for_path(
|
||||||
this.metrics_path,
|
this.metrics_path,
|
||||||
GET_STAGE_READER_SETUP,
|
GET_STAGE_READER_SETUP,
|
||||||
@@ -1549,6 +1595,104 @@ impl Drop for LazyMultipartCodecStreamingReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge a degraded later part onto the legacy per-part erasure decode
|
||||||
|
/// (backlog#879).
|
||||||
|
///
|
||||||
|
/// When the codec streaming fast path cannot serve a part because a shard is
|
||||||
|
/// missing, read quorum still holds, so the shard readers already opened for
|
||||||
|
/// this part reconstruct it through the same `decode_with_stripe_handles` path
|
||||||
|
/// the legacy duplex fallback uses. A bounded duplex pipe turns that
|
||||||
|
/// push-based decode into the `AsyncRead` the lazy multipart reader expects: a
|
||||||
|
/// background task drives the decode into the write half while the returned
|
||||||
|
/// reader drains the read half. No extra file descriptors are opened — the
|
||||||
|
/// readers are moved in from the setup that just ran.
|
||||||
|
fn build_legacy_per_part_fallback_reader(
|
||||||
|
erasure: coding::Erasure,
|
||||||
|
readers: Vec<Option<ObjectBitrotReader>>,
|
||||||
|
deferred_stripe_handles: Vec<Option<DeferredReaderStripeHandle>>,
|
||||||
|
read_costs: Option<Vec<ShardReadCost>>,
|
||||||
|
part_offset: usize,
|
||||||
|
part_length: usize,
|
||||||
|
part_size: usize,
|
||||||
|
) -> Box<dyn AsyncRead + Unpin + Send + Sync> {
|
||||||
|
let buffer = adaptive_duplex_buffer_size(part_size as i64);
|
||||||
|
let (read_half, mut write_half) = tokio::io::duplex(buffer);
|
||||||
|
let decode = tokio::spawn(async move {
|
||||||
|
let (_written, err) = erasure
|
||||||
|
.decode_with_stripe_handles(
|
||||||
|
&mut write_half,
|
||||||
|
readers,
|
||||||
|
part_offset,
|
||||||
|
part_length,
|
||||||
|
part_size,
|
||||||
|
read_costs,
|
||||||
|
deferred_stripe_handles,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Dropping `write_half` on return signals EOF to the reader half.
|
||||||
|
err
|
||||||
|
});
|
||||||
|
Box::new(LegacyPerPartDecodeReader {
|
||||||
|
inner: read_half,
|
||||||
|
decode: Some(decode),
|
||||||
|
finished: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AsyncRead` over a legacy per-part decode running on a background task
|
||||||
|
/// (backlog#879). Bytes flow through a duplex pipe; once the pipe reaches EOF
|
||||||
|
/// the decode task result is joined so a reconstruction failure surfaces as a
|
||||||
|
/// read error instead of a silently truncated stream.
|
||||||
|
struct LegacyPerPartDecodeReader {
|
||||||
|
inner: tokio::io::DuplexStream,
|
||||||
|
decode: Option<tokio::task::JoinHandle<Option<std::io::Error>>>,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for LegacyPerPartDecodeReader {
|
||||||
|
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
let this = self.get_mut();
|
||||||
|
if this.finished {
|
||||||
|
return Poll::Ready(Ok(()));
|
||||||
|
}
|
||||||
|
let filled_before = buf.filled().len();
|
||||||
|
match Pin::new(&mut this.inner).poll_read(cx, buf) {
|
||||||
|
Poll::Ready(Ok(())) if buf.filled().len() == filled_before => {
|
||||||
|
// Pipe EOF: the write half was dropped, so the decode task has
|
||||||
|
// finished. Join it to surface any reconstruction error rather
|
||||||
|
// than reporting a clean EOF over a truncated stream.
|
||||||
|
let Some(handle) = this.decode.as_mut() else {
|
||||||
|
this.finished = true;
|
||||||
|
return Poll::Ready(Ok(()));
|
||||||
|
};
|
||||||
|
match Pin::new(handle).poll(cx) {
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
Poll::Ready(join_result) => {
|
||||||
|
this.decode = None;
|
||||||
|
this.finished = true;
|
||||||
|
match join_result {
|
||||||
|
Ok(Some(err)) => Poll::Ready(Err(err)),
|
||||||
|
Ok(None) => Poll::Ready(Ok(())),
|
||||||
|
Err(join_err) => Poll::Ready(Err(std::io::Error::other(join_err))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LegacyPerPartDecodeReader {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Abort the decode task if the client disconnects before the part is
|
||||||
|
// drained so background reconstruction IO stops.
|
||||||
|
if let Some(handle) = self.decode.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_object_metadata_cache_request_bypass_reason(bucket: &str, opts: &ObjectOptions, read_data: bool) -> Option<&'static str> {
|
fn get_object_metadata_cache_request_bypass_reason(bucket: &str, opts: &ObjectOptions, read_data: bool) -> Option<&'static str> {
|
||||||
if !read_data {
|
if !read_data {
|
||||||
return Some(GET_METADATA_CACHE_REASON_NOT_READ_DATA);
|
return Some(GET_METADATA_CACHE_REASON_NOT_READ_DATA);
|
||||||
@@ -4133,6 +4277,133 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("inconsistent read source shards"));
|
assert!(err.to_string().contains("inconsistent read source shards"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backlog#879: a later multipart part that loses a shard (read quorum still
|
||||||
|
// holds) must be reconstructed in place through the legacy per-part decode
|
||||||
|
// bridge instead of failing the stream. This exercises the exact reader the
|
||||||
|
// lazy multipart builder now returns from its degraded-part branch.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_per_part_fallback_reader_reconstructs_missing_shard() {
|
||||||
|
let erasure = coding::Erasure::new(2, 2, 64);
|
||||||
|
let data = (0..64u8).map(|value| value.wrapping_add(7)).collect::<Vec<_>>();
|
||||||
|
// Drop data shard 0: reconstruction is required, so the codec streaming
|
||||||
|
// fast path would fall back, but the parity shards keep read quorum.
|
||||||
|
let setup =
|
||||||
|
setup_codec_data_blocks_first_encoded_bitrot_readers(&erasure, &data, &[0], &[], &[], HashAlgorithm::HighwayHash256)
|
||||||
|
.await;
|
||||||
|
assert_eq!(setup.completed_failed_shards(), 1, "one shard must be missing to force the fallback");
|
||||||
|
assert!(setup.available_shards() >= 2, "read quorum must still hold");
|
||||||
|
|
||||||
|
let mut reader = build_legacy_per_part_fallback_reader(
|
||||||
|
erasure,
|
||||||
|
setup.readers,
|
||||||
|
setup.deferred_stripe_handles,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
data.len(),
|
||||||
|
data.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut decoded = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut decoded)
|
||||||
|
.await
|
||||||
|
.expect("legacy per-part fallback reader should reconstruct the degraded part");
|
||||||
|
assert_eq!(decoded, data, "reconstructed bytes must match the original part payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// backlog#879: the whole multipart object must stream to completion when a
|
||||||
|
// later part degrades. Part 1 streams from a healthy reader, part 2 is built
|
||||||
|
// through the legacy per-part fallback reader (missing shard, quorum intact),
|
||||||
|
// and the concatenation must equal part1 || part2 with no mid-stream error.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lazy_multipart_continues_when_later_part_degrades_to_legacy() {
|
||||||
|
let erasure = coding::Erasure::new(2, 2, 64);
|
||||||
|
let part1: Vec<u8> = (0..64u8).collect();
|
||||||
|
let part2: Vec<u8> = (0..64u8).rev().collect();
|
||||||
|
|
||||||
|
// Build the degraded part-2 setup up front (missing data shard 0).
|
||||||
|
let part2_for_setup = part2.clone();
|
||||||
|
let setup = setup_codec_data_blocks_first_encoded_bitrot_readers(
|
||||||
|
&erasure,
|
||||||
|
&part2_for_setup,
|
||||||
|
&[0],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
HashAlgorithm::HighwayHash256,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(setup.completed_failed_shards(), 1);
|
||||||
|
|
||||||
|
let erasure_for_builder = erasure.clone();
|
||||||
|
let part2_len = part2.len();
|
||||||
|
let setup_slot = std::sync::Mutex::new(Some((setup, erasure_for_builder)));
|
||||||
|
let builds = Arc::new(AtomicUsize::new(0));
|
||||||
|
let builds_clone = Arc::clone(&builds);
|
||||||
|
let builder: Box<dyn FnMut(usize) -> LazyPartBuildHandle + Send + Sync> = Box::new(move |_remaining_index| {
|
||||||
|
builds_clone.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let (setup, erasure) = setup_slot.lock().expect("setup slot lock").take().expect("part 2 built once");
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
let reader = build_legacy_per_part_fallback_reader(
|
||||||
|
erasure,
|
||||||
|
setup.readers,
|
||||||
|
setup.deferred_stripe_handles,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
part2_len,
|
||||||
|
part2_len,
|
||||||
|
);
|
||||||
|
Ok(GetCodecStreamingReaderBuildOutcome::Reader(reader))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut reader =
|
||||||
|
LazyMultipartCodecStreamingReader::new(Box::new(Cursor::new(part1.clone())), 2, builder, "codec_streaming");
|
||||||
|
let mut output = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut output)
|
||||||
|
.await
|
||||||
|
.expect("degraded later part must be reconstructed instead of failing the stream");
|
||||||
|
|
||||||
|
let mut expected = part1;
|
||||||
|
expected.extend_from_slice(&part2);
|
||||||
|
assert_eq!(output, expected, "full object bytes must match part1 || reconstructed part2");
|
||||||
|
assert_eq!(builds.load(Ordering::SeqCst), 1, "part 2 must be built exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
// backlog#879: if reconstruction itself fails (inconsistent deferred source),
|
||||||
|
// the bridge must surface a read error at EOF rather than silently truncate
|
||||||
|
// the stream. This guards the join-handle error propagation.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_per_part_fallback_reader_surfaces_reconstruction_error() {
|
||||||
|
let erasure = coding::Erasure::new(2, 2, 64);
|
||||||
|
let data = (0..64u8).map(|value| value.wrapping_mul(3)).collect::<Vec<_>>();
|
||||||
|
// Corrupt shard 0 and feed an inconsistent deferred reconstruction source
|
||||||
|
// (shard 2): reconstruction verification must fail during decode.
|
||||||
|
let setup =
|
||||||
|
setup_codec_data_blocks_first_encoded_bitrot_readers(&erasure, &data, &[], &[0], &[2], HashAlgorithm::HighwayHash256)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut reader = build_legacy_per_part_fallback_reader(
|
||||||
|
erasure,
|
||||||
|
setup.readers,
|
||||||
|
setup.deferred_stripe_handles,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
data.len(),
|
||||||
|
data.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut decoded = Vec::new();
|
||||||
|
let err = reader
|
||||||
|
.read_to_end(&mut decoded)
|
||||||
|
.await
|
||||||
|
.expect_err("failed reconstruction must surface as a read error, not a truncated stream");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("inconsistent read source shards"),
|
||||||
|
"error should describe the reconstruction failure: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codec_streaming_reader_gate_is_conservative() {
|
fn codec_streaming_reader_gate_is_conservative() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
|
|||||||
Reference in New Issue
Block a user