mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 15:46:53 +00:00
fix(ecstore): hedge slow shard reads in lockstep GET to cut the large-object first-byte tail (#4799)
This commit is contained in:
@@ -1091,6 +1091,7 @@ where
|
|||||||
let mut completed = 0usize;
|
let mut completed = 0usize;
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
let mut first_shard_recorded = false;
|
let mut first_shard_recorded = false;
|
||||||
|
let mut hedged = false;
|
||||||
// Scope the reader borrow (held by `reader_iter`/`sets`) so it is released
|
// Scope the reader borrow (held by `reader_iter`/`sets`) so it is released
|
||||||
// before the retirement pass mutates `self.readers` below.
|
// before the retirement pass mutates `self.readers` below.
|
||||||
{
|
{
|
||||||
@@ -1115,7 +1116,53 @@ where
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some((i, _read_cost, result, _should_retire)) = sets.next().await {
|
// Hedge the straggler. Once every healthy shard has had `hedge_delay`
|
||||||
|
// to return, stop waiting out the full per-shard `read_timeout` for a
|
||||||
|
// slow shard: cancel the still-pending reads and let the stripe-aligned
|
||||||
|
// parity substitution below cover their slots — but only when enough
|
||||||
|
// unengaged parity remains to reconstruct them, otherwise keep waiting.
|
||||||
|
// A slow shard here otherwise stalls the whole stripe (and the GET's
|
||||||
|
// first byte) until the 10s read timeout fires: the large-object GET
|
||||||
|
// first-byte tail seen in backlog#1156.
|
||||||
|
let hedge_delay = shard_read_hedge_delay(read_timeout);
|
||||||
|
let hedge_sleep = hedge_delay.map(tokio::time::sleep);
|
||||||
|
tokio::pin!(hedge_sleep);
|
||||||
|
loop {
|
||||||
|
let item = match hedge_sleep.as_mut().as_pin_mut() {
|
||||||
|
Some(sleep) if !hedged => {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
item = sets.next() => item,
|
||||||
|
_ = sleep => {
|
||||||
|
hedged = true;
|
||||||
|
// Stop waiting for the straggler(s) only once the stripe
|
||||||
|
// can be finished without them: either every data shard
|
||||||
|
// is already in (no reconstruction needed), or there are
|
||||||
|
// at least `data_shards + 1` shards — decode quorum plus
|
||||||
|
// a reconstruction-verification source, so a missing data
|
||||||
|
// shard is reconstructed *and* verified, never settled at
|
||||||
|
// exactly `data_shards` (which would silently skip
|
||||||
|
// verification, erasure.rs). In the read-all default every
|
||||||
|
// slot is engaged, so `sets` already holds data + parity
|
||||||
|
// and this stops one slow shard from stalling the stripe
|
||||||
|
// (and the GET's first byte) for the full read timeout
|
||||||
|
// (backlog#1156). Otherwise keep waiting — the straggler
|
||||||
|
// may be the verification source, or the only route to
|
||||||
|
// quorum on a degraded stripe.
|
||||||
|
let succeeded = shards.iter().filter(|shard| shard.is_some()).count();
|
||||||
|
let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none());
|
||||||
|
if !data_missing || succeeded > data_shards {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => sets.next().await,
|
||||||
|
};
|
||||||
|
let Some((i, _read_cost, result, _should_retire)) = item else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
completed += 1;
|
completed += 1;
|
||||||
if !first_shard_recorded {
|
if !first_shard_recorded {
|
||||||
if let Some(path) = metrics_path {
|
if let Some(path) = metrics_path {
|
||||||
@@ -1134,14 +1181,46 @@ where
|
|||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Once the hedge has fired, stop as soon as the stripe can finish
|
||||||
|
// (all data present, or the decode+verification quorum) rather than
|
||||||
|
// draining every straggler — a shard that resolves just after the
|
||||||
|
// hedge must not pull the first byte back out to the read timeout.
|
||||||
|
// Gated on `hedged` so a healthy stripe still reads every shard
|
||||||
|
// (the read-all default invariant, backlog#923 gate off).
|
||||||
|
if hedged {
|
||||||
|
let succeeded = shards.iter().filter(|shard| shard.is_some()).count();
|
||||||
|
let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none());
|
||||||
|
if !data_missing || succeeded > data_shards {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A data shard may have died during this stripe's reads. The unengaged
|
// Retire any shard the hedge left in flight: dropping `sets` above
|
||||||
|
// cancelled its read, so the reader is now at an indeterminate stream
|
||||||
|
// position and can never be block-aligned again — the same reason a
|
||||||
|
// mid-block read error retires a reader. Its slot stays missing and is
|
||||||
|
// covered by the stripe-aligned parity substitution below.
|
||||||
|
if hedged {
|
||||||
|
for i in 0..num_readers {
|
||||||
|
if participating[i] && shards[i].is_none() && errs[i].is_none() {
|
||||||
|
errs[i] = Some(Error::from(io::Error::new(ErrorKind::TimedOut, "shard read hedged after a slow shard")));
|
||||||
|
retire_readers.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A data shard may have died or been hedged this stripe. The unengaged
|
||||||
// parity readers are still unopened, so they can be aligned to *this*
|
// parity readers are still unopened, so they can be aligned to *this*
|
||||||
// stripe and read now: engage them one at a time until the stripe has
|
// stripe and read now: engage them one at a time until the stripe has
|
||||||
// `data_shards + 1` successful shards (decode quorum plus one
|
// `data_shards + 1` successful shards (decode quorum plus one
|
||||||
// reconstruction-verification source) or parity runs out.
|
// reconstruction-verification source) or parity runs out. In the read-all
|
||||||
|
// default (`RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE` off) every parity
|
||||||
|
// slot is already engaged, so `!self.engaged[idx]` finds nothing and this
|
||||||
|
// loop is a no-op — the hedged data shard is reconstructed from the parity
|
||||||
|
// already read above. It only substitutes deferred parity in the
|
||||||
|
// data-shards-only path (backlog#923).
|
||||||
loop {
|
loop {
|
||||||
let success_now = shards.iter().filter(|shard| shard.is_some()).count();
|
let success_now = shards.iter().filter(|shard| shard.is_some()).count();
|
||||||
let data_shard_missing = shards.iter().take(data_shards).any(|shard| shard.is_none());
|
let data_shard_missing = shards.iter().take(data_shards).any(|shard| shard.is_none());
|
||||||
@@ -3632,6 +3711,152 @@ mod tests {
|
|||||||
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lockstep GET regression for the large-object first-byte tail (backlog#1156).
|
||||||
|
/// A single slow data shard must not stall the stripe for the full per-shard
|
||||||
|
/// `read_timeout`: once the hedge delay elapses and the stripe already holds a
|
||||||
|
/// decode-plus-verification quorum, the lockstep read retires the straggler and
|
||||||
|
/// returns in ~hedge_delay even though `read_timeout` is 60s. Before the hedge
|
||||||
|
/// the lockstep path waited for every launched read, so this completed in
|
||||||
|
/// ~read_timeout (the 10s stall observed on large-object GETs) instead of ~100ms.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_lockstep_hedges_slow_shard_instead_of_waiting_read_timeout() {
|
||||||
|
const NUM_SHARDS: usize = 1;
|
||||||
|
const BLOCK_SIZE: usize = 64;
|
||||||
|
const DATA_SHARDS: usize = 2;
|
||||||
|
// Two parity so data_shards + 1 shards are available without the slow one.
|
||||||
|
const PARITY_SHARDS: usize = 2;
|
||||||
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
||||||
|
|
||||||
|
let hash_algo = HashAlgorithm::None;
|
||||||
|
let readers = vec![
|
||||||
|
// Data shard 0 never resolves; without the hedge the stripe waits out
|
||||||
|
// the full 60s read timeout for it.
|
||||||
|
Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])),
|
||||||
|
SHARD_SIZE,
|
||||||
|
hash_algo.clone(),
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])),
|
||||||
|
SHARD_SIZE,
|
||||||
|
hash_algo.clone(),
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
TestShardReader::Ready(Cursor::new(vec![3_u8; SHARD_SIZE * NUM_SHARDS])),
|
||||||
|
SHARD_SIZE,
|
||||||
|
hash_algo,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
||||||
|
// Reconstruction verification selects the lockstep read path.
|
||||||
|
let mut parallel_reader = ParallelReader::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
||||||
|
readers,
|
||||||
|
erasure,
|
||||||
|
0,
|
||||||
|
NUM_SHARDS * BLOCK_SIZE,
|
||||||
|
None,
|
||||||
|
vec![ShardReadCost::Unknown; 4],
|
||||||
|
Duration::from_secs(60),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(parallel_reader.verify_reconstruction, "test must exercise the lockstep read path");
|
||||||
|
|
||||||
|
let (bufs, errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read())
|
||||||
|
.await
|
||||||
|
.expect("lockstep read must hedge the slow shard instead of waiting out the 60s read timeout");
|
||||||
|
|
||||||
|
// The slow data shard was hedged: retired with a TimedOut error; the stripe
|
||||||
|
// reached data_shards + 1 shards from the healthy data + parity.
|
||||||
|
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
|
||||||
|
assert!(parallel_reader.readers[0].is_none());
|
||||||
|
assert!(bufs[0].is_none());
|
||||||
|
assert!(bufs[1].is_some());
|
||||||
|
assert!(bufs[2].is_some());
|
||||||
|
assert!(bufs[3].is_some());
|
||||||
|
assert_eq!(DATA_SHARDS + 1, bufs.iter().filter(|buf| buf.is_some()).count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lockstep verification-quorum regression (backlog#1156). When a data shard is
|
||||||
|
/// missing, the hedge must settle only at `data_shards + 1` (decode quorum plus
|
||||||
|
/// a reconstruction-verification source), never at exactly `data_shards` — that
|
||||||
|
/// would silently skip reconstruction verification (erasure.rs verifies only
|
||||||
|
/// when `available_shards > data_shards`) and could stream a corrupt
|
||||||
|
/// reconstruction. Here `data1` and `parity2` are instant, `data0` is dead-slow,
|
||||||
|
/// and the verification-source `parity3` returns *after* the hedge delay. The
|
||||||
|
/// read must wait for `parity3` (reaching `data_shards + 1`) rather than settle
|
||||||
|
/// at `data_shards` and abandon it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_lockstep_hedge_waits_for_verification_quorum() {
|
||||||
|
const NUM_SHARDS: usize = 1;
|
||||||
|
const BLOCK_SIZE: usize = 64;
|
||||||
|
const DATA_SHARDS: usize = 2;
|
||||||
|
const PARITY_SHARDS: usize = 2;
|
||||||
|
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
|
||||||
|
|
||||||
|
let hash_algo = HashAlgorithm::None;
|
||||||
|
// parity3 resolves 200ms in — after the 100ms hedge, but well under the
|
||||||
|
// 500ms test bound and the 60s read timeout.
|
||||||
|
let parity3_ready_at = TokioInstant::now() + Duration::from_millis(200);
|
||||||
|
let readers = vec![
|
||||||
|
Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])),
|
||||||
|
SHARD_SIZE,
|
||||||
|
hash_algo.clone(),
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])),
|
||||||
|
SHARD_SIZE,
|
||||||
|
hash_algo.clone(),
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
Some(BitrotReader::new(
|
||||||
|
TestShardReader::ReadyAt {
|
||||||
|
cursor: Cursor::new(vec![3_u8; SHARD_SIZE * NUM_SHARDS]),
|
||||||
|
ready_at: parity3_ready_at,
|
||||||
|
sleep: None,
|
||||||
|
},
|
||||||
|
SHARD_SIZE,
|
||||||
|
hash_algo,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
||||||
|
let mut parallel_reader = ParallelReader::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification(
|
||||||
|
readers,
|
||||||
|
erasure,
|
||||||
|
0,
|
||||||
|
NUM_SHARDS * BLOCK_SIZE,
|
||||||
|
None,
|
||||||
|
vec![ShardReadCost::Unknown; 4],
|
||||||
|
Duration::from_secs(60),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(parallel_reader.verify_reconstruction, "test must exercise the lockstep read path");
|
||||||
|
|
||||||
|
let (bufs, errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read())
|
||||||
|
.await
|
||||||
|
.expect("hedge must reach data_shards + 1, not stall to the 60s read timeout");
|
||||||
|
|
||||||
|
// data0 stays missing/retired; the verification-source parity3 was NOT
|
||||||
|
// abandoned at the hedge — the read waited for it to reach data_shards + 1.
|
||||||
|
assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut));
|
||||||
|
assert!(parallel_reader.readers[0].is_none());
|
||||||
|
assert!(bufs[0].is_none());
|
||||||
|
assert!(bufs[1].is_some());
|
||||||
|
assert!(bufs[2].is_some());
|
||||||
|
assert!(bufs[3].is_some(), "verification-source parity must be used, not settled away");
|
||||||
|
assert!(parallel_reader.readers[3].is_some(), "verification-source parity must not be retired");
|
||||||
|
assert_eq!(DATA_SHARDS + 1, bufs.iter().filter(|buf| buf.is_some()).count());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parallel_reader_drains_completed_shards_after_quorum() {
|
async fn test_parallel_reader_drains_completed_shards_after_quorum() {
|
||||||
const NUM_SHARDS: usize = 1;
|
const NUM_SHARDS: usize = 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user