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:
Matthew Jackson
2026-06-30 20:25:48 -07:00
parent d65b776a8e
commit bc07011bcb
+107 -84
View File
@@ -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)?;