disc/patch: drop cache-priming, bisect batch failures instead of count=1

Two patch-recovery speed fixes, grounded in live rip1 telemetry on the
Dune UHD bad regions.

1. Pull prime_cache. In the reverse walk it issued throwaway reads of
   lba-3..lba-1 — i.e. straight into the not-yet-visited dead L-EC core —
   grinding each ~7-9s at the fast timeout, then discarding the result.
   The live trace showed a good target read (read_duration_ms=1) taking
   9s wall-clock purely from the prime grind ahead of it. The dead-zone
   skip (Tier 1) + scatter's recalibration read (read_good_sectors)
   cover the cache-warm intent without grinding the dead zone, so prime
   is redundant harm — removed (function + CACHE_PRIME_SECTORS + call).

2. Bisect on batch failure. handle_read_failure used to collapse a
   failed count>1 batch straight to count=1 and single-walk the entire
   batch. Now it halves (count/2) and retries the same start: a good
   half recovers in BULK, the bad sector is isolated in O(log n) reads
   instead of O(n). Invariant preserved — only a count==1 failure marks
   NonTrimmed, so no good sector is lumped into a bad mark.

These are Tier 2 (bisect) of the progressive recovery model
(fast-mass-skip -> bisect -> scatter); composable so order can be tuned.
25 lib + 8 AB fixture tests green; precommit clean on Rust 1.86.
This commit is contained in:
Matthew Jackson
2026-06-30 11:20:44 -07:00
parent 5b39a0af2b
commit 9cc422bd00
+15 -37
View File
@@ -351,9 +351,6 @@ const PASSN_DAMAGE_THRESHOLD_PCT: usize = crate::disc::read_error::PATCH_DAMAGE_
const PASSN_SKIP_SECTORS_BASE: u64 = 32;
const PASSN_SKIP_SECTORS_CAP: u64 = 4096;
const PASSN_ESCALATION_RESET_GOOD: u32 = 4;
// Cache-prime: number of single-sector throwaway reads issued at LBAs
// immediately preceding the target before a count==1 recovery read.
const CACHE_PRIME_SECTORS: u32 = 3;
/// Probe-offset escalation: returns a per-probe skip distance in
/// sectors of `PASSN_SKIP_SECTORS_BASE << (3 × idx)` (i.e. multiplies
@@ -436,30 +433,6 @@ pub(super) fn compute_initial_state(
))
}
/// Cache priming: before the recovery read, issue `CACHE_PRIME_SECTORS`
/// throwaway single-sector reads at the LBAs immediately preceding
/// `lba`. The drive's read-ahead cache prefetches forward on
/// sequential reads — by the time the caller asks for `lba` it may
/// already be cached, even if a cold read of the same LBA would fail.
/// Proven 2026-05-07 with dd-as-oracle: 8/8 sectors recoverable when
/// primed vs 6/8 cold. Failures are best-effort: we already have these
/// bytes Finished from a prior pass, so prime failures don't update
/// mapfile state. Only runs on count==1 reads (the genuine recovery
/// path) and when `lba >= CACHE_PRIME_SECTORS` so the subtraction
/// doesn't underflow.
pub(super) fn prime_cache<R: SectorSource + ?Sized>(reader: &mut R, lba: u32, count: u16) {
if !(lba >= CACHE_PRIME_SECTORS && count == 1) {
return;
}
let mut prime_buf = [0u8; 2048];
for i in 0..CACHE_PRIME_SECTORS {
let prime_lba = lba - CACHE_PRIME_SECTORS + i;
// Best-effort; ignore errors. Recovery=false is intentional:
// a fast 1.5s timeout is fine because we don't need the data.
let _ = reader.read_sectors(prime_lba, 1, &mut prime_buf[..], false);
}
}
/// One recovery read of `[lba, lba+count)` into `buf[..count*2048]`.
///
/// On an AACS disc a mid-unit window (start or length not unit-aligned)
@@ -1179,23 +1152,30 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
// Adaptive batching split decision: a batch-read failure
// (count > 1) is NOT a recorded failure. We don't yet know which
// sector in the batch was actually bad — could be one, could be
// many. Drop to count=1 and retry the SAME starting position so
// every sector gets individually probed. Cursor stays put; loop
// continues. Invariants: no good sector ever gets lumped into a
// NonTrimmed mark, no spurious consecutive_failures (which drives
// wedge detection), no damage_window pollution from batch-level
// signals.
// many. BISECT: halve the batch and retry the SAME starting position.
// If the bad sector is in the upper half, the lower half now SUCCEEDS
// and recovers in BULK (one read, not N single reads); if it's in the
// lower half, we halve again. Either way the bad sector is isolated in
// O(log n) reads instead of single-stepping all n. Collapsing straight
// to count=1 (the pre-bisect behavior) single-walked the entire failed
// batch — on a mostly-good NonTrimmed range that is the dominant cost.
// Cursor stays put; loop continues. Invariants preserved: only a
// count==1 failure ever marks NonTrimmed, so no good sector is lumped
// into a bad mark; a batch failure still doesn't touch
// consecutive_failures / the damage window.
if count > 1 {
let halved = (count / 2).max(1);
tracing::info!(
target: "freemkv::disc",
phase = "patch.batch.split",
lba,
count,
from_batch = state.current_batch,
to_batch = halved,
err_code = err.code(),
"adaptive batching: batch read failed, dropping to count=1 to probe individually"
"adaptive batching: batch read failed, bisecting (halving) to isolate the bad sector"
);
state.current_batch = 1;
state.current_batch = halved;
state.consecutive_singles_ok = 0;
return Ok(FailureAction::ContinueInner);
}
@@ -2040,8 +2020,6 @@ impl<R: SectorSource + ?Sized> PatchCtx<'_, '_, R> {
"starting sector read"
);
prime_cache(self.reader, lba, count);
// Single-shot read (no inline retry — see the historical note
// in handle_read_failure). `recovery_read` widens a mid-unit
// AACS window to the aligned unit; otherwise it's a plain read.