#![expect(
clippy::expect_used,
reason = "tests assert on known-present values; a panic is the failure signal"
)]
#![allow(
clippy::cast_possible_truncation,
reason = "in-file block offsets fit usize; only narrow on 32-bit targets"
)]
use super::*;
use crate::{
AbstractTree,
MAX_SEQNO,
SequenceNumberCounter,
runtime_config::EccScheme,
table::{block::Header, block_index::BlockIndex as _},
};
fn open_ecc_tree(dir: &std::path::Path) -> crate::Tree {
let crate::AnyTree::Standard(tree) = crate::Config::new(
dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.page_ecc(true)
.ecc_scheme(EccScheme::ReedSolomon {
data_shards: 8,
parity_shards: 2,
})
.open()
.expect("open ecc tree") else {
unreachable!("standard tree configured (no kv separation)");
};
tree
}
fn write_ecc_sst(dir: &std::path::Path) -> (std::path::PathBuf, crate::table::BlockHandle) {
let tree = open_ecc_tree(dir);
for i in 0u64..2_000 {
tree.insert(format!("key-{i:06}"), format!("v{i:06}"), i);
}
tree.flush_active_memtable(2_000).expect("flush");
let binding = tree.version_history.read().latest_version();
let table = binding
.version
.iter_tables()
.next()
.expect("flush produced one table");
let keyed = table
.block_index
.iter()
.next()
.expect("table has at least one data block")
.expect("block index entry decodes");
let handle = crate::table::BlockHandle::new(keyed.offset(), keyed.size());
((*table.path).clone(), handle)
}
#[test]
fn patrol_scrub_corrects_seeded_single_bit_fault_and_schedules_heal() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let (sst_path, block) = write_ecc_sst(dir.path());
let corrupt_pos = block.offset().0 as usize + Header::MIN_LEN + 3;
let mut bytes = std::fs::read(&sst_path)?;
let slot = bytes
.get_mut(corrupt_pos)
.expect("corrupt_pos in range for the SST bytes");
*slot ^= 0x80;
std::fs::write(&sst_path, &bytes)?;
let tree = open_ecc_tree(dir.path());
tree.update_runtime_config(|c| c.auto_heal = true)?;
assert!(tree.heal_hints().is_empty(), "fresh tree has no hints");
let report = patrol_scrub(&tree, &PatrolScrubOptions::default());
assert!(
report.corrections_applied >= 1,
"scrub must correct the seeded fault: {report:?}",
);
assert_eq!(
report.ssts_scheduled_for_rewrite, 1,
"the corrected SST is queued for healing exactly once: {report:?}",
);
assert_eq!(report.uncorrectable_blocks, 0, "{report:?}");
assert!(
report.is_ok(),
"a fully-correctable scrub is ok: {report:?}"
);
assert!(
!tree.heal_hints().is_empty(),
"the SST is recorded in the heal queue",
);
#[cfg(feature = "metrics")]
assert_eq!(
tree.metrics().ecc_auto_heal_scheduled_count(),
1,
"the scheduled SST is counted once in metrics",
);
Ok(())
}
#[test]
fn patrol_scrub_corrects_without_scheduling_when_auto_heal_off() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let (sst_path, block) = write_ecc_sst(dir.path());
let corrupt_pos = block.offset().0 as usize + Header::MIN_LEN + 3;
let mut bytes = std::fs::read(&sst_path)?;
let slot = bytes.get_mut(corrupt_pos).expect("corrupt_pos in range");
*slot ^= 0x80;
std::fs::write(&sst_path, &bytes)?;
let tree = open_ecc_tree(dir.path());
assert!(!tree.heal_hints().is_enabled(), "auto_heal defaults off");
let report = patrol_scrub(&tree, &PatrolScrubOptions::default());
assert!(
report.corrections_applied >= 1,
"correction-on-read still happens with auto_heal off: {report:?}",
);
assert_eq!(
report.ssts_scheduled_for_rewrite, 0,
"auto_heal off suppresses rewrite scheduling: {report:?}",
);
assert!(
tree.heal_hints().is_empty(),
"no SST queued when scheduling is off",
);
assert!(report.is_ok());
Ok(())
}
#[test]
fn patrol_scrub_reports_uncorrectable_block_not_silently_skipped() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let (sst_path, block) = write_ecc_sst(dir.path());
let payload_start = block.offset().0 as usize + Header::MIN_LEN;
let payload_end = block.offset().0 as usize + block.size() as usize;
let mut bytes = std::fs::read(&sst_path)?;
for slot in bytes
.get_mut(payload_start..payload_end)
.expect("block payload range in bounds")
{
*slot ^= 0xFF;
}
std::fs::write(&sst_path, &bytes)?;
let tree = open_ecc_tree(dir.path());
tree.update_runtime_config(|c| c.auto_heal = true)?;
let report = patrol_scrub(&tree, &PatrolScrubOptions::default());
assert!(
report.uncorrectable_blocks >= 1,
"an unrecoverable block must be reported, not skipped: {report:?}",
);
assert!(!report.is_ok(), "uncorrectable corruption fails the scrub");
assert!(
report
.errors
.iter()
.any(|e| matches!(e, ScrubError::UncorrectableBlock { .. })),
"the finding is an UncorrectableBlock: {report:?}",
);
Ok(())
}
#[test]
fn patrol_scrub_clean_ecc_tree_reports_no_corrections() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let _ = write_ecc_sst(dir.path());
let tree = open_ecc_tree(dir.path());
let report = patrol_scrub(&tree, &PatrolScrubOptions::default());
assert_eq!(report.sst_files_scanned, 1);
assert!(report.blocks_scanned >= 1);
assert_eq!(report.corrections_applied, 0, "no fault → no correction");
assert_eq!(report.uncorrectable_blocks, 0);
assert!(report.is_ok());
let got = tree.get(b"key-000000", MAX_SEQNO)?.expect("key present");
assert_eq!(&*got, b"v000000");
Ok(())
}