use crate::AbstractTree;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) mod heal_attest;
#[derive(Debug)]
#[non_exhaustive]
pub enum ScrubError {
UncorrectableBlock {
table_id: crate::table::TableId,
path: PathBuf,
block_offset: u64,
reason: String,
},
BlockIndexUnreadable {
table_id: crate::table::TableId,
path: PathBuf,
reason: String,
},
ChecksumRefreshFailed {
table_id: crate::table::TableId,
path: PathBuf,
reason: String,
},
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct PatrolScrubReport {
pub sst_files_scanned: usize,
pub blocks_scanned: usize,
pub corrections_applied: usize,
pub ssts_scheduled_for_rewrite: usize,
pub blocks_healed_in_place: usize,
pub uncorrectable_blocks: usize,
pub errors: Vec<ScrubError>,
}
impl PatrolScrubReport {
#[must_use]
pub fn is_ok(&self) -> bool {
self.uncorrectable_blocks == 0 && self.errors.is_empty()
}
pub(crate) fn merge(&mut self, other: Self) {
self.sst_files_scanned += other.sst_files_scanned;
self.blocks_scanned += other.blocks_scanned;
self.corrections_applied += other.corrections_applied;
self.ssts_scheduled_for_rewrite += other.ssts_scheduled_for_rewrite;
self.blocks_healed_in_place += other.blocks_healed_in_place;
self.uncorrectable_blocks += other.uncorrectable_blocks;
self.errors.extend(other.errors);
}
}
#[derive(Clone, Debug)]
pub struct PatrolScrubOptions {
pub parallelism: usize,
pub throttle: Option<std::time::Duration>,
pub heal_in_place: bool,
pub progress: Option<std::sync::Arc<crate::RecoveryProgress>>,
}
impl Default for PatrolScrubOptions {
fn default() -> Self {
Self {
parallelism: 1,
throttle: None,
heal_in_place: false,
progress: None,
}
}
}
impl PatrolScrubOptions {
#[must_use]
pub const fn parallelism(mut self, workers: usize) -> Self {
self.parallelism = workers;
self
}
#[must_use]
pub const fn throttle(mut self, delay: std::time::Duration) -> Self {
self.throttle = Some(delay);
self
}
#[must_use]
pub const fn heal_in_place(mut self, enable: bool) -> Self {
self.heal_in_place = enable;
self
}
#[must_use]
pub fn progress(mut self, progress: std::sync::Arc<crate::RecoveryProgress>) -> Self {
self.progress = Some(progress);
self
}
}
#[must_use]
pub fn patrol_scrub(
tree: &(impl AbstractTree + Sync),
options: &PatrolScrubOptions,
) -> PatrolScrubReport {
let version = tree.current_version();
let tables: Vec<crate::table::Table> = version.iter_tables().cloned().collect();
let physical_size = |table: &crate::table::Table| {
table
.fs
.metadata(&table.path)
.map_or(table.metadata.file_size, |m| m.len)
};
if let Some(p) = &options.progress {
p.set_phase(crate::RecoveryPhase::Scrubbing);
p.set_bytes_total(
tables
.iter()
.fold(0u64, |acc, t| acc.saturating_add(physical_size(t))),
);
}
let publish = |table: &crate::table::Table, partial: &PatrolScrubReport| {
if let Some(p) = &options.progress {
p.add_bytes_processed(physical_size(table));
p.add_blocks(
partial.blocks_scanned as u64,
partial.corrections_applied as u64,
0,
partial.corrections_applied as u64,
);
}
};
let workers = options.parallelism.max(1).min(tables.len().max(1));
if workers <= 1 {
let mut report = PatrolScrubReport::default();
for (idx, table) in tables.iter().enumerate() {
let partial = scan_and_reconcile(tree, table, options);
publish(table, &partial);
report.merge(partial);
if idx + 1 < tables.len()
&& let Some(delay) = options.throttle
{
std::thread::sleep(delay);
}
}
if let Some(p) = &options.progress {
p.set_phase(crate::RecoveryPhase::Done);
}
return report;
}
let cursor = AtomicUsize::new(0);
let partials = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
let mut local = PatrolScrubReport::default();
let mut idx = cursor.fetch_add(1, Ordering::Relaxed);
while let Some(table) = tables.get(idx) {
let partial = scan_and_reconcile(tree, table, options);
publish(table, &partial);
local.merge(partial);
idx = cursor.fetch_add(1, Ordering::Relaxed);
if tables.get(idx).is_some()
&& let Some(delay) = options.throttle
{
std::thread::sleep(delay);
}
}
local
})
})
.collect();
handles
.into_iter()
.map(|handle| match handle.join() {
Ok(local) => local,
Err(payload) => std::panic::resume_unwind(payload),
})
.collect::<Vec<_>>()
});
let mut report = PatrolScrubReport::default();
for partial in partials {
report.merge(partial);
}
if let Some(p) = &options.progress {
p.set_phase(crate::RecoveryPhase::Done);
}
report
}
fn scan_and_reconcile(
tree: &impl AbstractTree,
table: &crate::table::Table,
options: &PatrolScrubOptions,
) -> PatrolScrubReport {
let heals =
cfg!(feature = "page_ecc") && options.heal_in_place && table.metadata.ecc_params.is_some();
#[cfg(feature = "page_ecc")]
let heal_lock = heals.then(|| table.heal_lock_arc());
#[cfg(feature = "page_ecc")]
let _heal_exclusive = heal_lock.as_ref().map(|l| l.lock());
let _mutation_window = heals
.then(|| {
table
.deletion_pause
.get()
.map(|p| p.enter_mutation_window())
})
.flatten();
let current = tree
.current_version()
.iter_tables()
.find(|t| t.id() == table.id())
.cloned();
let scan_table: &crate::table::Table = match ¤t {
Some(cur) if cur.restrict_lower_bound() != table.restrict_lower_bound() => cur,
_ => table,
};
let manifest_checksum = current
.as_ref()
.map_or_else(|| table.checksum(), crate::table::Table::checksum);
let (mut partial, heal_attributable) =
scan_one(scan_table, options, tree.sync_mode(), manifest_checksum);
if heals
&& wants_checksum_refresh(&partial)
&& let Some(finding) = refresh_healed_checksum(tree, scan_table, heal_attributable)
{
partial.errors.push(finding);
}
partial
}
#[cfg(feature = "page_ecc")]
pub(crate) fn reconcile_pending_heals(tree: &impl AbstractTree) -> crate::Result<()> {
let options = PatrolScrubOptions::default().heal_in_place(true);
let version = tree.current_version();
let mut pending: Vec<crate::table::Table> = Vec::new();
for table in version.iter_tables() {
if table.metadata.ecc_params.is_some()
&& heal_attest::exists(&*table.fs, &table.path).map_err(crate::Error::Io)?
{
pending.push(table.clone());
}
}
if pending.is_empty() {
return Ok(());
}
let mut report = PatrolScrubReport::default();
for table in &pending {
report.merge(scan_and_reconcile(tree, table, &options));
}
if report.is_ok() {
Ok(())
} else {
Err(crate::Error::from(std::io::Error::other(alloc::format!(
"checkpoint aborted: {} pending heal attestation(s) could not be reconciled \
({} uncorrectable block(s), {} finding(s)); run a scrub and retry",
pending.len(),
report.uncorrectable_blocks,
report.errors.len(),
))))
}
}
#[cfg(feature = "std")]
pub(crate) fn abort_checkpoint_if_pending_heals(
tree: &impl AbstractTree,
reason: &str,
) -> crate::Result<()> {
let version = tree.current_version();
for table in version.iter_tables() {
if heal_attest::exists(&*table.fs, &table.path).map_err(crate::Error::Io)? {
if let Ok(fresh) = table.live_region_checksum()
&& fresh == table.checksum()
{
heal_attest::remove(&*table.fs, &table.path);
continue;
}
return Err(crate::Error::from(std::io::Error::other(alloc::format!(
"checkpoint aborted: table #{} has a pending heal attestation that cannot be \
reconciled here ({reason}); retry the checkpoint",
table.id(),
))));
}
}
Ok(())
}
fn wants_checksum_refresh(partial: &PatrolScrubReport) -> bool {
partial.uncorrectable_blocks == 0 && partial.errors.is_empty()
}
fn refresh_healed_checksum(
tree: &impl AbstractTree,
table: &crate::table::Table,
heal_attributable: bool,
) -> Option<ScrubError> {
let finding = |reason: String| {
log::warn!(
"failed to persist refreshed checksum for healed table #{}: {reason}",
table.id(),
);
Some(ScrubError::ChecksumRefreshFailed {
table_id: table.id(),
path: (*table.path).clone(),
reason,
})
};
let fresh = match table.live_region_checksum() {
Ok(ck) => ck,
Err(e) => return finding(e.to_string()),
};
let binding = tree.current_version();
let Some(current_view) = binding.iter_tables().find(|t| t.id() == table.id()) else {
return None;
};
if current_view.restrict_lower_bound() != table.restrict_lower_bound() {
return None;
}
let current = current_view.checksum();
if fresh == current {
heal_attest::remove(&*table.fs, &table.path);
return None;
}
let attest_result = heal_attest::attests(
&*table.fs,
&table.path,
table.encryption.as_deref(),
table.id(),
fresh,
current,
);
let attributable =
heal_attributable || matches!(attest_result, heal_attest::AttestResult::Attests);
let sidecar_inconclusive = matches!(attest_result, heal_attest::AttestResult::Inconclusive);
if heal_attributable
&& let Err(e) = heal_attest::write(
&*table.fs,
&table.path,
table.encryption.as_deref(),
table.id(),
current,
fresh,
)
{
return finding(alloc::format!(
"could not persist the reconcile attestation ({e}); the manifest digest \
was not refreshed"
));
}
let refuse = |reason: String, remove_marker: bool| -> Option<ScrubError> {
if remove_marker {
heal_attest::remove(&*table.fs, &table.path);
}
finding(reason)
};
let definitive = |e: &crate::Error| !matches!(e, crate::Error::Io(_));
if !attributable {
let remove_marker = !sidecar_inconclusive;
match table.has_deletion_metadata() {
Ok(true) => {
return refuse(
"digest mismatch not attributable to this pass's heal on a \
table carrying deletion metadata (range tombstones / delete \
bitmap), which no cross-check can authenticate; the manifest \
digest was not refreshed"
.into(),
remove_marker,
);
}
Ok(false) => {}
Err(e) => return refuse(e.to_string(), remove_marker),
}
return refuse(
"digest mismatch not attributable to this pass's heal; the file's \
non-derivable content (meta scalars such as created_at and the \
per-KV footer descriptor, plus any footer-less value bytes) has no \
cross-check to authenticate it and the recovery-time copy may \
itself be a pre-open restamp; the manifest digest was not refreshed"
.into(),
remove_marker,
);
}
let data_start = match table.restrict_lower_bound() {
Some(bound) => match table.punch_offset_for(bound) {
Ok(offset) => offset,
Err(e) => {
return refuse(
alloc::format!(
"restricted-view punch-offset lookup failed ({e}); the manifest \
digest was not refreshed"
),
false,
);
}
},
None => 0,
};
let walk = crate::verify::verify_sst_file_with_context(
&table.fs,
&table.path,
table.encryption.as_ref(),
Some(table.id()),
data_start,
);
if !walk.errors.is_empty() || !walk.warnings.is_empty() {
let walk_definitive = walk.errors.iter().any(|e| {
use crate::verify::BlockVerifyError as E;
!matches!(e, E::SstFileUnreadable { .. } | E::DataReadError { .. })
});
return refuse(
"digest mismatch with corruption outside the scanned data blocks; \
the manifest digest was not refreshed"
.into(),
walk_definitive,
);
}
if let Err(e) = table.verify_blob_links() {
return refuse(
alloc::format!(
"digest mismatch with a blob-link cross-check failure ({e}); \
the manifest digest was not refreshed"
),
definitive(&e),
);
}
if let Err(e) = table.verify_tli_mirrors() {
return refuse(
alloc::format!(
"digest mismatch with a TLI mirror comparison failure ({e}); \
the manifest digest was not refreshed"
),
definitive(&e),
);
}
if let Err(e) = table.verify_block_layout() {
return refuse(
alloc::format!(
"digest mismatch with a block-layout cross-check failure ({e}); \
the manifest digest was not refreshed"
),
definitive(&e),
);
}
if let Err((gate, e)) = table.verify_reconcile_gates(tree.prefix_extractor().as_ref(), true) {
use crate::table::ReconcileGate as G;
let what = match gate {
G::Separators => "an index separator cross-check failure",
G::KvChecksums => "a per-KV verification failure",
G::SeqnoBounds => "a seqno-bounds cross-check failure",
G::BlockEntryCounts => "a block entry-count mismatch",
G::ZoneMap => "a zone-map cross-check failure",
G::Locator => "a locator cross-check failure",
G::Filter => "a filter cross-check failure",
G::PointReadReachability => "a point-read reachability failure",
G::MetadataBounds => "a metadata-bounds cross-check failure",
G::BlockLayout => "a block-layout cross-check failure",
};
return refuse(
alloc::format!(
"digest mismatch with {what} ({e}); the manifest digest was \
not refreshed"
),
definitive(&e),
);
}
if let Err(e) = (|| -> crate::Result<()> {
let file = table.fs.open(
&table.path,
&crate::fs::FsOpenOptions::new().read(true).write(true),
)?;
crate::fs::FsFile::sync_data_with(&*file, crate::fs::SyncMode::Full)?;
Ok(())
})() {
return refuse(
alloc::format!(
"digest mismatch whose healed bytes could not be synced ({e}); \
the manifest digest was not refreshed"
),
false,
);
}
use crate::abstract_tree::ChecksumRefreshOutcome;
match tree.refresh_table_checksum(table.id(), fresh, table.restrict_lower_bound()) {
Ok(ChecksumRefreshOutcome::Refreshed) => {
heal_attest::remove(&*table.fs, &table.path);
None
}
Ok(ChecksumRefreshOutcome::Stale) => None,
Ok(ChecksumRefreshOutcome::Contended) => finding(
"the manifest install lock was held by a concurrent compaction; the \
healed bytes are durable but the manifest digest is stale until a \
later patrol reconciles the kept attestation"
.to_string(),
),
Err(e) => finding(e.to_string()),
}
}
fn scan_one(
table: &crate::table::Table,
options: &PatrolScrubOptions,
sync_mode: crate::fs::SyncMode,
manifest_checksum: crate::Checksum,
) -> (PatrolScrubReport, bool) {
#[cfg(feature = "page_ecc")]
if options.heal_in_place && table.metadata.ecc_params.is_some() {
return table.heal_data_blocks_in_place(sync_mode, manifest_checksum);
}
let _ = (options, sync_mode, manifest_checksum);
(table.scrub_data_blocks(), false)
}
#[cfg(test)]
mod tests;
#[cfg(all(test, feature = "page_ecc"))]
mod ecc_tests;