Skip to main content

reddb_server/storage/compact/
scrub.rs

1use super::*;
2
3/// A failed tree is unverified; the number of affected records is unknown.
4#[derive(Debug)]
5pub struct PagedTreeFinding {
6    pub collection: String,
7    pub tree: String,
8    pub root_page: u32,
9    pub reason: String,
10}
11
12/// This is a tree-only diagnostic, not a complete store integrity check.
13#[derive(Debug, Default)]
14pub struct PagedTreeScrubReport {
15    pub trees_verified: u64,
16    pub records_verified: u64,
17    pub findings: Vec<PagedTreeFinding>,
18}
19
20/// A physical scrub of a supported, closed and checkpointed standalone store.
21/// If a tree fails, auxiliary checks are not considered complete.
22#[derive(Debug)]
23pub struct PagedScrubReport {
24    pub trees: PagedTreeScrubReport,
25    pub assets_verified: bool,
26    /// Allocated pages not identified as free or owned by a verified structure.
27    pub unclassified_pages: u64,
28}
29
30/// Verify the supported offline layout without writing to the source. Pending
31/// recovery, vaults without valid credentials and unsupported sidecars fail closed.
32pub fn scrub_paged(path: &Path) -> Result<PagedScrubReport> {
33    let canonical_path = path.canonicalize()?;
34    let path = canonical_path.as_path();
35    let source = Arc::new(Source::open(path)?);
36    super::salvage::verify_supported_source_with_audit(path, &source)?;
37    let (trees, catalog) = inspect_all_trees(&source)?;
38    if !trees.findings.is_empty() {
39        return Ok(PagedScrubReport {
40            trees,
41            assets_verified: false,
42            unclassified_pages: source.unclaimed_pages(),
43        });
44    }
45    let internal = super::internal_metadata::read(&source, &catalog)?;
46    let mut assets = super::assets::Assets::for_verification(&source);
47    assets.vault()?;
48    assets.physical()?;
49    assets.metadata(
50        path,
51        None,
52        internal.as_ref().map(|record| record.document.as_str()),
53    )?;
54    catalog.claim_retired(&source)?;
55    Ok(PagedScrubReport {
56        trees,
57        assets_verified: true,
58        unclassified_pages: source.unclaimed_pages(),
59    })
60}
61
62pub(super) fn verify_recovery_vector_pages(path: &Path) -> Result<()> {
63    let source = Arc::new(Source::open(path)?);
64    super::salvage::verify_supported_source(path, &source)?;
65    let (trees, catalog) = inspect_all_trees(&source)?;
66    ensure(
67        trees.findings.is_empty(),
68        "recovered tree verification failed",
69    )?;
70    let internal = super::internal_metadata::read(&source, &catalog)?;
71    let mut assets = super::assets::Assets::for_verification(&source);
72    assets.vault()?;
73    assets.physical()?;
74    assets.metadata(
75        path,
76        None,
77        internal.as_ref().map(|record| record.document.as_str()),
78    )?;
79    catalog.claim_retired(&source)?;
80    source.verify_unclaimed_vector_pages()
81}
82
83/// Inspect trees in a closed, checkpointed paged store without writing to it.
84/// Vault, physical assets, logical index correctness and replica state are not
85/// verified. The same layout and WAL restrictions as offline compaction apply.
86pub fn scrub_paged_trees(path: &Path) -> Result<PagedTreeScrubReport> {
87    let source = Arc::new(Source::open(path)?);
88    inspect_all_trees(&source).map(|(report, _)| report)
89}
90
91fn inspect_all_trees(source: &Arc<Source>) -> Result<(PagedTreeScrubReport, Catalog)> {
92    let catalog = Catalog::read(source)?;
93    ensure(
94        source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
95            || catalog.resume.is_some(),
96        "prepared disk-resident file has no resume catalog",
97    )?;
98    let mut report = PagedTreeScrubReport::default();
99    for collection in &catalog.roots {
100        let state = catalog
101            .resume
102            .as_ref()
103            .and_then(|r| r.collections.get(&collection.collection));
104        if catalog.resume.is_some() {
105            ensure(state.is_some(), "collection missing from resume catalog")?;
106        }
107        if let Some(state) = state {
108            ensure(
109                state.root_page == collection.root_page,
110                "collection root differs from resume catalog",
111            )?;
112        }
113        inspect_tree(
114            source,
115            &mut report,
116            &collection.collection,
117            "collection",
118            collection.root_page,
119            state.map(|s| s.entities),
120        );
121    }
122    if let Some(resume) = &catalog.resume {
123        ensure(
124            resume.collections.len() == catalog.roots.len(),
125            "resume catalog has unknown collections",
126        )?;
127        for (name, state) in &resume.collections {
128            if let Some(root) = state.key_index_root {
129                inspect_tree(source, &mut report, name, "key index", root, None);
130            }
131            for (index, state) in &state.secondary_indices {
132                inspect_tree(
133                    source,
134                    &mut report,
135                    name,
136                    &format!("secondary index {index}"),
137                    state.root_page,
138                    None,
139                );
140            }
141        }
142    }
143    Ok((report, catalog))
144}
145
146fn inspect_tree(
147    source: &Arc<Source>,
148    report: &mut PagedTreeScrubReport,
149    collection: &str,
150    name: &str,
151    root: u32,
152    declared_count: Option<u64>,
153) {
154    let result = (|| -> Result<u64> {
155        if root == 0 {
156            ensure(
157                declared_count.unwrap_or(0) == 0,
158                "empty tree has nonzero declared count",
159            )?;
160            return Ok(0);
161        }
162        let tree = BTree::with_root(Arc::clone(source), root);
163        let (expected, _) = inventory_tree(source, &tree, true)?;
164        let mut cursor = tree.cursor_first()?;
165        let mut previous: Option<Vec<u8>> = None;
166        let mut count = 0u64;
167        while let Some((key, _value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
168            ensure(
169                previous.as_ref().is_none_or(|old| old < &key),
170                "tree keys are not strictly ordered",
171            )?;
172            previous = Some(key);
173            count += 1;
174            ensure(
175                count <= expected,
176                "tree cursor exceeds structural inventory",
177            )?;
178        }
179        ensure(count == expected, "tree cursor omits owned records")?;
180        if let Some(declared) = declared_count {
181            ensure(
182                count == declared,
183                "collection count disagrees with resume catalog",
184            )?;
185        }
186        Ok(count)
187    })();
188    match result {
189        Ok(count) => {
190            report.trees_verified += 1;
191            report.records_verified += count;
192        }
193        Err(error) => report.findings.push(PagedTreeFinding {
194            collection: collection.to_owned(),
195            tree: name.to_owned(),
196            root_page: root,
197            reason: error.to_string(),
198        }),
199    }
200}