patch: add Jump handler (lead fast tier) + recovery-based progress %

Jump: on sustained batch failures skip ahead an escalating distance
(1 MiB doubling to 256 MiB) to find where readable data resumes, leaving
the skipped span for Bisect to pin — mirrors the Pass-1 damage-jump. It
leads the fast tier so a large dead run is skipped in seconds instead of
the linear sweeps grinding every dead batch (10 s each) first; on a
readable range it just streams it back. Recovers readable data buried
behind a big dead front (the 192 MB Dune range).

Progress %: report bytes RECOVERED (initial-bad minus still-pending)
instead of a per-range counter that only advanced on the final tier — so
the bar reflects the readable bulk recovered during tier 0 the instant it
lands, matching the 'MB remaining' number.
This commit is contained in:
Matthew Jackson
2026-06-30 20:40:22 -07:00
parent bc07011bcb
commit 8d775cd341
2 changed files with 97 additions and 2 deletions
+16 -2
View File
@@ -59,7 +59,7 @@ use crate::io::pipeline::{Flow, Sink};
use super::mapfile::{self, MapStats, Mapfile, SectorStatus};
use super::section_recover::{
Bisect, HandlerCtx, HandlerOutcome, Linear, RecoverySink, SectionHandler, run_handlers,
Bisect, HandlerCtx, HandlerOutcome, Jump, Linear, RecoverySink, SectionHandler, run_handlers,
};
/// Wall-clock budget one recovery handler gets on a section before the chain
@@ -834,6 +834,13 @@ impl PatchCtx<'_, '_> {
// 0 left. Adding a recovery idea is one more entry in the right tier (#55).
let mut handlers: Vec<Box<dyn SectionHandler>> = if tier == 0 {
vec![
// Jump LEADS the fast tier: it recovers readable data and skips
// ahead past dead runs, so a mostly-dead range is confirmed and
// left in seconds instead of the linear sweeps grinding every
// dead batch (10 s each) first. On a readable range it just
// streams it back like a linear read. The linear sweeps then
// mop up the spans Jump stepped over.
Box::new(Jump),
Box::new(Linear {
reverse: true,
fast: true,
@@ -974,9 +981,16 @@ impl Disc {
.map(|t| bytes_bad_in_title(t, &bad_ranges_now))
.unwrap_or(0);
let main_title = self.titles.first();
// Progress = bytes RECOVERED so far (initial bad still-pending), not a
// per-range counter. With breadth-first tiers the readable bulk comes
// back during tier 0 before any range is "finished", so a range-counter
// sits at 0% while hundreds of MB are actually recovered. Deriving it
// from the live pending count makes the bar (and the speed the client
// computes from its delta) reflect real recovery the instant it happens.
let recovered = state.work_total.saturating_sub(s.bytes_pending);
let pp = crate::progress::PassProgress {
kind,
work_done: state.work_done,
work_done: recovered,
work_total: state.work_total,
bytes_good_total: s.bytes_good,
bytes_unreadable_total: s.bytes_unreadable,
+81
View File
@@ -42,6 +42,14 @@ const SECTOR: u64 = 2048;
/// spans against granularity on dead ones.
const BATCH_SECTORS: u64 = 32;
/// `Jump` handler: after this many consecutive failed batches, skip ahead to
/// find where readable data resumes rather than reading every dead sector.
const JUMP_AFTER_FAILS: u32 = 2;
/// `Jump` initial skip distance; doubles after each jump, capped at
/// [`JUMP_CAP_BYTES`]. Mirrors the escalating Pass-1 damage-jump.
const JUMP_BASE_BYTES: u64 = 1 << 20; // 1 MiB
const JUMP_CAP_BYTES: u64 = 256 << 20; // 256 MiB
/// Where a handler left the section after its bounded attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum HandlerOutcome {
@@ -316,6 +324,79 @@ impl SectionHandler for Bisect {
}
}
/// Blow through a LARGE dead run fast. Reads forward in batches; after
/// [`JUMP_AFTER_FAILS`] consecutive failed batches it SKIPS AHEAD an escalating
/// distance (1 MiB → 2 → 4 … capped at [`JUMP_CAP_BYTES`]), leaving the skipped
/// span bad, to find where readable data RESUMES — mirroring the Pass-1
/// damage-jump. A later handler / `Bisect` pins the exact good/bad boundary the
/// jump stepped over. Uses fast reads (this is a scout, not a deep-recovery
/// pass). Without it a linear walk pays one up-to-10 s read per dead batch
/// across the whole run, so a deadline-bounded pass never reaches readable data
/// buried behind a big dead front (exactly the 192 MB range on Dune).
pub(super) struct Jump;
impl SectionHandler for Jump {
fn name(&self) -> &'static str {
"jump"
}
fn recover(
&mut self,
ctx: &mut HandlerCtx,
bad: &mut SubRanges,
deadline: Instant,
) -> HandlerOutcome {
let batch = BATCH_SECTORS * SECTOR;
let mut buf = vec![0u8; batch as usize];
let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec();
for (rp, rl) in snapshot {
let mut off = 0u64;
let mut consec_fail = 0u32;
let mut jump = JUMP_BASE_BYTES;
while off < rl {
if ctx.halted() {
return HandlerOutcome::Halted;
}
if ctx.past(deadline) {
return HandlerOutcome::Remaining;
}
let span = batch.min(rl - off);
let pos = rp + off;
let count = (span / SECTOR) as u16;
match read_span(ctx, &mut buf[..span as usize], pos, count, false) {
ReadHit::Good => {
bad.remove(pos, span);
consec_fail = 0;
jump = JUMP_BASE_BYTES;
off += span;
}
ReadHit::Bad => {
consec_fail += 1;
if consec_fail >= JUMP_AFTER_FAILS {
// Sustained dead run — skip ahead (sector-aligned so
// the walk stays batch-aligned) and escalate the next
// jump. The skipped span stays bad for Bisect / a
// later handler to pin the boundary.
let step = (jump / SECTOR).max(1) * SECTOR;
off = (off + step).min(rl);
jump = jump.saturating_mul(2).min(JUMP_CAP_BYTES);
consec_fail = 0;
} else {
off += span;
}
}
ReadHit::Transport => return HandlerOutcome::TransportFault,
}
}
}
if bad.is_empty() {
HandlerOutcome::Complete
} else {
HandlerOutcome::Remaining
}
}
}
/// Run the handler chain over one section's still-bad set. This is the
/// never-hang guarantee: each handler is bounded by the deadline
/// `section_deadline_for(bad)` returns, and the loop always drains to