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.requires_disk_residency() || catalog.resume.is_some(),
95        "prepared disk-resident file has no resume catalog",
96    )?;
97    let mut report = PagedTreeScrubReport::default();
98    for collection in &catalog.roots {
99        let state = catalog
100            .resume
101            .as_ref()
102            .and_then(|r| r.collections.get(&collection.collection));
103        if catalog.resume.is_some() {
104            ensure(state.is_some(), "collection missing from resume catalog")?;
105        }
106        if let Some(state) = state {
107            ensure(
108                state.root_page == collection.root_page,
109                "collection root differs from resume catalog",
110            )?;
111        }
112        inspect_tree(
113            source,
114            &mut report,
115            &collection.collection,
116            "collection",
117            collection.root_page,
118            state.map(|s| s.entities),
119        );
120    }
121    if let Some(resume) = &catalog.resume {
122        ensure(
123            resume.collections.len() == catalog.roots.len(),
124            "resume catalog has unknown collections",
125        )?;
126        for (name, state) in &resume.collections {
127            if let Some(root) = state.key_index_root {
128                inspect_tree(source, &mut report, name, "key index", root, None);
129            }
130            for (index, state) in &state.secondary_indices {
131                inspect_tree(
132                    source,
133                    &mut report,
134                    name,
135                    &format!("secondary index {index}"),
136                    state.root_page,
137                    None,
138                );
139            }
140        }
141    }
142    Ok((report, catalog))
143}
144
145fn inspect_tree(
146    source: &Arc<Source>,
147    report: &mut PagedTreeScrubReport,
148    collection: &str,
149    name: &str,
150    root: u32,
151    declared_count: Option<u64>,
152) {
153    let result = (|| -> Result<u64> {
154        if root == 0 {
155            ensure(
156                declared_count.unwrap_or(0) == 0,
157                "empty tree has nonzero declared count",
158            )?;
159            return Ok(0);
160        }
161        let tree = source.tree(root)?;
162        let (expected, _) = inventory_tree(source, &tree, true)?;
163        let mut cursor = tree.cursor_first()?;
164        let mut previous: Option<Vec<u8>> = None;
165        let mut count = 0u64;
166        while let Some((key, _value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
167            ensure(
168                previous.as_ref().is_none_or(|old| old < &key),
169                "tree keys are not strictly ordered",
170            )?;
171            previous = Some(key);
172            count += 1;
173            ensure(
174                count <= expected,
175                "tree cursor exceeds structural inventory",
176            )?;
177        }
178        ensure(count == expected, "tree cursor omits owned records")?;
179        if let Some(declared) = declared_count {
180            ensure(
181                count == declared,
182                "collection count disagrees with resume catalog",
183            )?;
184        }
185        Ok(count)
186    })();
187    match result {
188        Ok(count) => {
189            report.trees_verified += 1;
190            report.records_verified += count;
191        }
192        Err(error) => report.findings.push(PagedTreeFinding {
193            collection: collection.to_owned(),
194            tree: name.to_owned(),
195            root_page: root,
196            reason: error.to_string(),
197        }),
198    }
199}