patch: breadth-first two-tier recovery, largest ranges first
The per-range walk was depth-first: each bad range ran the full handler chain (fast + slow deep-recovery + bisect) before the next range was touched. So a handful of tiny dead fragments at one end of the disc burned the whole pass and the big NonTrimmed ranges elsewhere — usually sweep-jump over-marks that read straight back — were never attempted. Now recovery runs in two breadth-first tiers over ALL sections: - Tier 0 gives every section one fast full-batch attempt (fast reads only), largest ranges first, so the recoverable bulk of the disc comes back in the first minutes. - Tier 1 deep-recovers only the residue tier 0 could not pull. Per-section still-bad sets persist across tiers. Largest-first ordering means a big readable region is reached before time is spent on tiny dead fragments. Linear no longer collapses a failed batch to count=1 single-sector reads (live probing: a marginal sector recovers in a large read, not a lone one) — a failed 32-batch stays 32 and is re-attempted at full size by the next handler/pass; Bisect salvages readable islands. Adds a handler-start trace line so the debug log shows which handler is running and the hand-off to the next.
This commit is contained in:
+107
-84
@@ -342,10 +342,9 @@ use super::{Disc, DiscTitle, PatchOptions, PatchOutcome, bytes_bad_in_title};
|
|||||||
use crate::io::pipeline::Pipeline;
|
use crate::io::pipeline::Pipeline;
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
/// Cooldown between patch ranges that actually grinded (dropped to the slow
|
/// Breadth-first recovery tiers. Tier 0 fast-sweeps every bad range; tier 1
|
||||||
/// recovery speed). Lets the drive settle before the next range re-enters at max
|
/// deep-recovers the residual. See `PatchCtx::run`.
|
||||||
/// speed. Gated on "grinded" so a many-small-range pass doesn't stall on it.
|
const PATCH_TIERS: usize = 2;
|
||||||
const INTER_RANGE_COOLDOWN_SECS: u64 = 10;
|
|
||||||
|
|
||||||
/// Send a `PatchItem` and translate a `SendError` (consumer thread died
|
/// Send a `PatchItem` and translate a `SendError` (consumer thread died
|
||||||
/// / panicked) into a library error so the caller propagates cleanly.
|
/// / panicked) into a library error so the caller propagates cleanly.
|
||||||
@@ -651,8 +650,6 @@ pub(super) struct PatchLoopState {
|
|||||||
pub blocks_read_failed: u64,
|
pub blocks_read_failed: u64,
|
||||||
pub unreadable_count: u64,
|
pub unreadable_count: u64,
|
||||||
pub work_done: u64,
|
pub work_done: u64,
|
||||||
// Progress baseline for the region-exit "bytes recovered" log.
|
|
||||||
pub bytes_good_last: u64,
|
|
||||||
// Clock seam: the handler chain reads wall time through this rather than
|
// Clock seam: the handler chain reads wall time through this rather than
|
||||||
// calling `Instant::now()` inline, so the per-handler deadline is driven by
|
// calling `Instant::now()` inline, so the per-handler deadline is driven by
|
||||||
// an injectable clock and deterministic tests can wind it forward.
|
// an injectable clock and deterministic tests can wind it forward.
|
||||||
@@ -700,7 +697,6 @@ impl PatchLoopState {
|
|||||||
blocks_read_failed: 0,
|
blocks_read_failed: 0,
|
||||||
unreadable_count: 0,
|
unreadable_count: 0,
|
||||||
work_done: 0,
|
work_done: 0,
|
||||||
bytes_good_last: bytes_good_before,
|
|
||||||
now,
|
now,
|
||||||
bytes_good_before,
|
bytes_good_before,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
@@ -744,10 +740,6 @@ struct PatchCtx<'a, 'o> {
|
|||||||
opts: &'a PatchOptions<'o>,
|
opts: &'a PatchOptions<'o>,
|
||||||
total_bytes: u64,
|
total_bytes: u64,
|
||||||
decrypt_is_aacs: bool,
|
decrypt_is_aacs: bool,
|
||||||
/// Armed when a range grinded (dropped to slow speed); consumed as an
|
|
||||||
/// inter-range cooldown before the NEXT range enters at max speed.
|
|
||||||
/// Gated on "grinded" so a many-small-range pass doesn't stall on it.
|
|
||||||
cooldown_pending: bool,
|
|
||||||
state: PatchLoopState,
|
state: PatchLoopState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,58 +750,78 @@ impl PatchCtx<'_, '_> {
|
|||||||
/// halt / wedge / transport-fault.
|
/// halt / wedge / transport-fault.
|
||||||
fn run(&mut self, bad_ranges: &[(u64, u64)]) -> Result<()> {
|
fn run(&mut self, bad_ranges: &[(u64, u64)]) -> Result<()> {
|
||||||
let num_ranges = bad_ranges.len();
|
let num_ranges = bad_ranges.len();
|
||||||
for (range_idx, &(range_pos, range_size)) in bad_ranges.iter().enumerate() {
|
// Attack the LARGEST ranges first. The big NonTrimmed regions are usually
|
||||||
if self.cooldown_pending {
|
// sweep-jump over-marks that read straight back, so ordering them ahead of
|
||||||
tracing::info!(
|
// the many tiny dead fragments lets tier 0 recover the bulk of the disc in
|
||||||
target: "freemkv::disc",
|
// its first minutes instead of grinding fragments first (ties: low LBA
|
||||||
phase = "patch.region.cooldown",
|
// first for a predictable, mostly-sequential walk).
|
||||||
secs = INTER_RANGE_COOLDOWN_SECS,
|
let mut ordered: Vec<(u64, u64)> = bad_ranges.to_vec();
|
||||||
"inter-range cooldown (previous range grinded at slow speed)"
|
ordered.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
);
|
// Per-range still-bad sets, persisted ACROSS the breadth-first tiers so
|
||||||
super::sleep_secs_or_halt(INTER_RANGE_COOLDOWN_SECS, self.opts.halt.as_ref());
|
// tier N+1 works on exactly what tier N left behind.
|
||||||
self.cooldown_pending = false;
|
let mut sections: Vec<SubRanges> = ordered
|
||||||
}
|
.iter()
|
||||||
let outcome = self.patch_region(range_idx, num_ranges, range_pos, range_size)?;
|
.map(|&(p, l)| SubRanges::from_section(p, l))
|
||||||
tracing::info!(
|
.collect();
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "patch.region.exit",
|
// BREADTH-FIRST recovery. Tier 0 fast-sweeps EVERY range first — grabbing
|
||||||
range_index = range_idx,
|
// the easily-readable bulk across the whole disc (sweep-jump over-marks a
|
||||||
range_lba = range_pos / 2048,
|
// big region NonTrimmed without testing each sector, so most of it reads
|
||||||
outcome = ?outcome,
|
// back in seconds) — BEFORE any range's slow per-sector grind. Tier 1
|
||||||
blocks_read_ok = self.state.blocks_read_ok,
|
// then deep-recovers only the residual. This fixes the depth-first
|
||||||
blocks_read_failed = self.state.blocks_read_failed,
|
// starvation bug: the full chain used to run per range, so a small dead
|
||||||
bytes_recovered =
|
// cluster at the front burned ~5 min/range and the big
|
||||||
self.state.bytes_good_last.saturating_sub(self.state.bytes_good_before),
|
// mostly-recoverable ranges were never reached.
|
||||||
"region finished"
|
for tier in 0..PATCH_TIERS {
|
||||||
);
|
let final_tier = tier + 1 == PATCH_TIERS;
|
||||||
match outcome {
|
for (range_idx, &(range_pos, range_size)) in ordered.iter().enumerate() {
|
||||||
RegionOutcome::Completed => {}
|
if sections[range_idx].is_empty() {
|
||||||
RegionOutcome::Halted | RegionOutcome::TransportFault => {
|
continue; // already fully recovered by an earlier tier
|
||||||
break;
|
}
|
||||||
|
let outcome = self.recover_section(
|
||||||
|
tier,
|
||||||
|
range_idx,
|
||||||
|
num_ranges,
|
||||||
|
range_pos,
|
||||||
|
range_size,
|
||||||
|
&mut sections[range_idx],
|
||||||
|
final_tier,
|
||||||
|
)?;
|
||||||
|
match outcome {
|
||||||
|
RegionOutcome::Completed => {}
|
||||||
|
RegionOutcome::Halted | RegionOutcome::TransportFault => return Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recover ONE bad range, end to start (reverse) or start to end.
|
/// Run ONE breadth-first tier of the handler chain over one range's still-bad
|
||||||
/// Owns the per-iteration read → success/failure → damage-skip →
|
/// set `bad`. Tier 0 = the fast breadth handlers (grab the readable bulk,
|
||||||
/// watchdog cycle and nothing else; cross-range concerns live in
|
/// fast-fail the rest); tier 1 = deep recovery (slow reads) + bisect on the
|
||||||
/// [`PatchCtx::run`]. Returns why it stopped (see [`RegionOutcome`]).
|
/// residual. `final_tier` records the surviving residue as NonTrimmed and
|
||||||
fn patch_region(
|
/// accounts the range toward progress exactly once. Cross-range scheduling
|
||||||
|
/// lives in [`PatchCtx::run`]; this owns one (tier, range) unit of work.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn recover_section(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
tier: usize,
|
||||||
range_idx: usize,
|
range_idx: usize,
|
||||||
num_ranges: usize,
|
num_ranges: usize,
|
||||||
range_pos: u64,
|
range_pos: u64,
|
||||||
range_size: u64,
|
range_size: u64,
|
||||||
|
bad: &mut SubRanges,
|
||||||
|
final_tier: bool,
|
||||||
) -> Result<RegionOutcome> {
|
) -> Result<RegionOutcome> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch.region.enter",
|
phase = "patch.region.enter",
|
||||||
|
tier,
|
||||||
range_index = range_idx,
|
range_index = range_idx,
|
||||||
num_total_ranges = num_ranges,
|
num_total_ranges = num_ranges,
|
||||||
range_lba = range_pos / 2048,
|
range_lba = range_pos / 2048,
|
||||||
range_size_mb = range_size as f64 / 1_048_576.0,
|
range_size_mb = range_size as f64 / 1_048_576.0,
|
||||||
|
bad_bytes = bad.total_len(),
|
||||||
"entering patch range"
|
"entering patch range"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -817,35 +829,33 @@ impl PatchCtx<'_, '_> {
|
|||||||
// itself via its `fast` flag.
|
// itself via its `fast` flag.
|
||||||
self.reader.set_speed(0xFFFF);
|
self.reader.set_speed(0xFFFF);
|
||||||
|
|
||||||
// The section's still-bad set. Handlers shrink it via `SubRanges::remove`
|
// Tier 0: the fast handlers only — sweep the readable bulk of EVERY range
|
||||||
// as they recover spans; whatever survives the chain is this pass's
|
// before any slow grind. Tier 1: slow deep-recovery + bisect on what tier
|
||||||
// residue.
|
// 0 left. Adding a recovery idea is one more entry in the right tier (#55).
|
||||||
let mut bad = SubRanges::from_section(range_pos, range_size);
|
let mut handlers: Vec<Box<dyn SectionHandler>> = if tier == 0 {
|
||||||
|
vec![
|
||||||
// The recovery-idea chain, cheapest first: fast reverse, fast forward,
|
Box::new(Linear {
|
||||||
// slow reverse, slow forward, then bisect for readable islands inside a
|
reverse: true,
|
||||||
// mostly-dead range. Each is deadline-bounded; when one can't shrink the
|
fast: true,
|
||||||
// set the next tries a different idea. Adding an idea is one more entry
|
}),
|
||||||
// here (#55).
|
Box::new(Linear {
|
||||||
let mut handlers: Vec<Box<dyn SectionHandler>> = vec![
|
reverse: false,
|
||||||
Box::new(Linear {
|
fast: true,
|
||||||
reverse: true,
|
}),
|
||||||
fast: true,
|
]
|
||||||
}),
|
} else {
|
||||||
Box::new(Linear {
|
vec![
|
||||||
reverse: false,
|
Box::new(Linear {
|
||||||
fast: true,
|
reverse: true,
|
||||||
}),
|
fast: false,
|
||||||
Box::new(Linear {
|
}),
|
||||||
reverse: true,
|
Box::new(Linear {
|
||||||
fast: false,
|
reverse: false,
|
||||||
}),
|
fast: false,
|
||||||
Box::new(Linear {
|
}),
|
||||||
reverse: false,
|
Box::new(Bisect),
|
||||||
fast: false,
|
]
|
||||||
}),
|
};
|
||||||
Box::new(Bisect),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Clock seam: handlers read wall time through this so tests can wind a
|
// Clock seam: handlers read wall time through this so tests can wind a
|
||||||
// fake clock (the same seam the pass uses for its own timing).
|
// fake clock (the same seam the pass uses for its own timing).
|
||||||
@@ -857,6 +867,7 @@ impl PatchCtx<'_, '_> {
|
|||||||
err: None,
|
err: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let bad_before = bad.total_len();
|
||||||
let outcome = {
|
let outcome = {
|
||||||
let mut ctx = HandlerCtx {
|
let mut ctx = HandlerCtx {
|
||||||
reader: &mut *self.reader,
|
reader: &mut *self.reader,
|
||||||
@@ -865,28 +876,41 @@ impl PatchCtx<'_, '_> {
|
|||||||
halt: self.opts.halt.as_deref(),
|
halt: self.opts.halt.as_deref(),
|
||||||
decrypt_is_aacs: self.decrypt_is_aacs,
|
decrypt_is_aacs: self.decrypt_is_aacs,
|
||||||
};
|
};
|
||||||
run_handlers(&mut ctx, &mut handlers, &mut bad, |_bad| {
|
run_handlers(&mut ctx, &mut handlers, bad, |_bad| {
|
||||||
now_ptr() + std::time::Duration::from_secs(PER_HANDLER_BUDGET_SECS)
|
now_ptr() + std::time::Duration::from_secs(PER_HANDLER_BUDGET_SECS)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "patch.region.exit",
|
||||||
|
tier,
|
||||||
|
range_index = range_idx,
|
||||||
|
range_lba = range_pos / 2048,
|
||||||
|
outcome = ?outcome,
|
||||||
|
bad_bytes_before = bad_before,
|
||||||
|
bad_bytes_after = bad.total_len(),
|
||||||
|
recovered = bad_before.saturating_sub(bad.total_len()),
|
||||||
|
"region tier finished"
|
||||||
|
);
|
||||||
|
|
||||||
// A pipe-closed / halt error captured while emitting recovered spans is
|
// A pipe-closed / halt error captured while emitting recovered spans is
|
||||||
// fatal to the pass.
|
// fatal to the pass.
|
||||||
if let Some(e) = sink.err.take() {
|
if let Some(e) = sink.err.take() {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything still bad is this pass's residue: record NonTrimmed and MOVE
|
// On the FINAL tier, whatever is still bad is this pass's residue: record
|
||||||
// ON to the next range. A later pass — or a future handler — gets another
|
// NonTrimmed and account the range toward progress (once). A later pass —
|
||||||
// shot; the orchestrator promotes still-NonTrimmed to Unreadable only
|
// or a future handler — gets another shot; the orchestrator promotes
|
||||||
// after the final pass completes.
|
// still-NonTrimmed to Unreadable only after the final pass completes.
|
||||||
for &(pos, len) in bad.ranges() {
|
if final_tier {
|
||||||
send_or_abort(self.pipe, PatchItem::NonTrimmed { pos, len })?;
|
for &(pos, len) in bad.ranges() {
|
||||||
|
send_or_abort(self.pipe, PatchItem::NonTrimmed { pos, len })?;
|
||||||
|
}
|
||||||
|
self.state.work_done = self.state.work_done.saturating_add(range_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole section is now processed (recovered or left as residue);
|
|
||||||
// account it for progress/ETA and report once.
|
|
||||||
self.state.work_done = self.state.work_done.saturating_add(range_size);
|
|
||||||
if self
|
if self
|
||||||
.disc
|
.disc
|
||||||
.report_patch_progress(&self.state, self.opts, self.total_bytes, self.shared)
|
.report_patch_progress(&self.state, self.opts, self.total_bytes, self.shared)
|
||||||
@@ -1164,7 +1188,6 @@ impl Disc {
|
|||||||
opts,
|
opts,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
decrypt_is_aacs,
|
decrypt_is_aacs,
|
||||||
cooldown_pending: false,
|
|
||||||
state: PatchLoopState::new(bytes_good_before, total_bytes, initial_batch, work_total),
|
state: PatchLoopState::new(bytes_good_before, total_bytes, initial_batch, work_total),
|
||||||
};
|
};
|
||||||
ctx.run(&bad_ranges)?;
|
ctx.run(&bad_ranges)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user