perf(ecstore): data-shards-only lockstep GET reads with stripe-aligned deferred parity engagement (opt-in) (#4392)

* feat(ecstore): add stripe-advance handles for deferred bitrot readers

Give DeferredObjectReader a shared pending state and expose a
DeferredReaderStripeHandle that advances the still-unopened source by whole
bitrot blocks using the same bitrot_encoded_range geometry the reader was
created with (identity mapping when hash_size == 0). This lets the GET decode
path open a parity shard aligned to the stripe where a data shard failed
instead of reading every parity shard on every stripe (backlog#923).

An already-opened (or failed) reader rejects the advance so callers retire it
rather than engage it out of alignment; bitrot verification after an advance
checks the advanced stripe's block against that stripe's stored hash.

Co-Authored-By: heihutu <heihutu@gmail.com>

* perf(ecstore): read only data shards on healthy lockstep GET behind opt-in gate

PR #4289's lockstep fix made every reconstruction-verifying GET read all
data+parity shards per stripe; the parity blocks are read, bitrot-hashed and
then discarded, a deterministic 2x read-bytes/IOPS/hash-CPU amplification on
healthy 2+2 objects (backlog#923). With the new opt-in gate
RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE=true (default: false, behavior
identical to main):

- read_lockstep keeps only the data slots engaged while the object is
  healthy; parity slots stay unopened deferred readers.
- When a data shard is missing or dies at stripe k, parity readers are
  engaged mid-object by advancing their deferred stripe handle to stripe k,
  preserving the lockstep alignment invariant from backlog#832.
- Degraded stripes engage one parity beyond the decode quorum so
  reconstruction verification keeps an extra source to check against
  (erasure.rs only verifies when available > data shards); an engaged parity
  reader that errors is retired for the rest of the object like any other,
  and a parity reader that cannot be realigned is retired instead of being
  read out of position.
- fill_deferred_bitrot_readers records stripe handles for deferred slots and,
  gate-on only, swaps eagerly opened parity readers for unopened deferred
  ones so they remain engageable mid-object; ready/error bookkeeping used by
  quorum decisions is untouched.
- Both GET paths (legacy duplex via Erasure::decode_with_stripe_handles,
  codec streaming via ParallelReader::with_deferred_parity_handles) carry the
  handles from reader setup.

Short-read -> UnexpectedEof -> whole-object retirement and the
inconsistent-source rejection are unchanged in both gate modes; tests lock
the healthy-path data-shards-only call counts, the default read-all-shards
behavior, mid-object parity engagement for streaming and hash_size==0
formats, and mid-stream inconsistent-parity rejection.

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-08 07:15:49 +08:00
committed by GitHub
parent 65953bfdb3
commit bd5d3c5d92
4 changed files with 871 additions and 72 deletions
+69 -8
View File
@@ -912,13 +912,18 @@ impl SetDisks {
let decode_stage_start = Instant::now();
let unattempted_data_shards = !reader_setup.data_shards_attempted(erasure.data_shards);
let readers = reader_setup.readers;
let (written, err) = if let Some(read_costs) = read_costs {
erasure
.decode_with_read_costs(writer, readers, part_offset, part_length, part_size, read_costs)
.await
} else {
erasure.decode(writer, readers, part_offset, part_length, part_size).await
};
let deferred_stripe_handles = reader_setup.deferred_stripe_handles;
let (written, err) = erasure
.decode_with_stripe_handles(
writer,
readers,
part_offset,
part_length,
part_size,
read_costs,
deferred_stripe_handles,
)
.await;
let decode_elapsed = decode_stage_start.elapsed();
rustfs_io_metrics::record_get_object_decode_duration(decode_elapsed.as_secs_f64());
rustfs_io_metrics::record_get_object_stage_duration_by_size(
@@ -1271,6 +1276,7 @@ impl SetDisks {
}
let readers = reader_setup.readers;
let deferred_stripe_handles = reader_setup.deferred_stripe_handles;
let source = if let Some(read_costs) = read_costs {
coding::decode::ParallelReader::new_with_metrics_path_read_costs_and_reconstruction_verification(
readers,
@@ -1288,7 +1294,8 @@ impl SetDisks {
part_size,
Some(metrics_path),
)
};
}
.with_deferred_parity_handles(deferred_stripe_handles);
let engine = build_get_codec_streaming_decode_engine(erasure.clone())?;
let reader =
coding::decode_reader::ErasureDecodeReader::new_with_metrics_path(source, engine, part_length, metrics_path)?;
@@ -3205,6 +3212,60 @@ mod tests {
assert_eq!(&out[..n], [b"aaaa", b"bbbb", b"cccc", b"dddd"][fallback_index]);
}
/// backlog#923: with the data-shards-only lockstep gate on, every retained
/// parity reader must be an unopened deferred reader carrying a stripe
/// handle, so the decode path can realign it to a mid-object stripe. With
/// the gate off (default), eagerly opened parity readers are kept exactly
/// as before and carry no handles.
#[tokio::test]
#[serial_test::serial]
async fn bitrot_reader_setup_gates_parity_stripe_handle_conversion() {
for enabled in [None, Some("true")] {
// A missing data shard forces the VerifyReconstruction quorum to 3,
// so both parity slots complete eagerly (attempted + ready) before
// the deferred fill runs.
let mut setup = temp_env::async_with_vars(
[("RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE", enabled)],
setup_inline_bitrot_readers_with_preference(
vec![None, Some(b"bbbb"), Some(b"cccc"), Some(b"dddd")],
2,
2,
BitrotReaderSetupMode::VerifyReconstruction,
false,
),
)
.await;
assert_eq!(setup.available_shards(), 3);
for idx in 2..4 {
assert!(setup.attempted[idx] && setup.ready[idx], "parity slot {idx} should be eagerly ready");
assert!(setup.readers[idx].is_some(), "parity slot {idx} must keep a reader (enabled={enabled:?})");
assert_eq!(
setup.deferred_stripe_handles[idx].is_some(),
enabled.is_some(),
"parity slot {idx} stripe handle must match the gate (enabled={enabled:?})"
);
}
if enabled.is_some() {
// The converted parity reader is still unopened: its handle
// accepts a stripe advance, and reading it yields the shard
// bytes (block 0 here).
let handle = setup.deferred_stripe_handles[3].as_ref().expect("slot 3 handle");
assert!(handle.advance_stripes(1), "unopened converted parity reader must accept a stripe advance");
let mut reader = setup.readers[2].take().expect("slot 2 reader");
let mut out = [0u8; 4];
let n = reader
.read(&mut out)
.await
.expect("converted parity reader should open on read");
assert_eq!(n, 4);
assert_eq!(&out[..n], b"cccc");
}
}
}
#[tokio::test]
async fn bitrot_reader_setup_data_blocks_first_keeps_deferred_fallback_readers() {
let mut setup = setup_inline_bitrot_readers_with_env(