Skip to main content

reddb_server/storage/compact/
salvage.rs

1use super::*;
2
3#[derive(Debug)]
4pub struct PagedIndexSalvageFinding {
5    pub collection: String,
6    pub index: String,
7    pub root_page: u32,
8    pub reason: String,
9}
10
11#[derive(Debug)]
12pub struct PagedIndexSalvageReport {
13    pub indexes: Vec<PagedIndexSalvageFinding>,
14}
15
16impl PagedIndexSalvageReport {
17    pub fn machine_json(&self) -> String {
18        let indexes: Vec<_> = self
19            .indexes
20            .iter()
21            .map(|finding| {
22                crate::serde_json::json!({
23                    "collection": finding.collection.clone(),
24                    "index": finding.index.clone(),
25                    "root_page": finding.root_page,
26                    "reason": finding.reason.clone(),
27                })
28            })
29            .collect();
30        crate::serde_json::json!({
31            "scope": "secondary_indexes_only",
32            "action": "discarded_for_rebuild",
33            "page_generation_freshness_verified": false,
34            "indexes": indexes,
35        })
36        .to_string_compact()
37    }
38}
39
40/// Recover a closed, checkpointed store when only derived secondary index
41/// trees are damaged. Data and primary-key trees must verify. The output
42/// retains index definitions but omits damaged physical roots so the runtime
43/// can rebuild them from the surviving rows. Other corruption fails closed.
44pub fn salvage_paged_indexes(source: &Path, destination: &Path) -> Result<PagedIndexSalvageReport> {
45    let canonical_path = source.canonicalize()?;
46    let source = canonical_path.as_path();
47    // Hold the source lock across both verification and publication.
48    let guard = Source::open(source)?;
49    verify_supported_source(source, &guard)?;
50    let scrub = scrub_paged_trees(source)?;
51    let mut indexes = Vec::new();
52    for finding in scrub.findings {
53        let index = finding
54            .tree
55            .strip_prefix("secondary index ")
56            .ok_or_else(|| {
57                format!(
58                    "cannot safely recover {} tree of {}: {}",
59                    finding.tree, finding.collection, finding.reason
60                )
61            })?;
62        indexes.push(PagedIndexSalvageFinding {
63            collection: finding.collection,
64            index: index.to_owned(),
65            root_page: finding.root_page,
66            reason: finding.reason,
67        });
68    }
69    ensure(!indexes.is_empty(), "no damaged secondary index to recover")?;
70    let report = PagedIndexSalvageReport { indexes };
71    compact_paged_inner(source, destination, Some(&report), None, false)?;
72    Ok(report)
73}
74
75#[derive(Debug)]
76pub struct PagedCollectionLoss {
77    pub collection: String,
78    pub root_page: u32,
79    pub skipped_pages: Vec<u32>,
80}
81
82#[derive(Debug)]
83pub struct PagedCollectionSalvageReport {
84    pub collections: Vec<PagedCollectionLoss>,
85}
86
87impl PagedCollectionSalvageReport {
88    pub fn machine_json(&self) -> String {
89        let collections: Vec<_> = self
90            .collections
91            .iter()
92            .map(|loss| {
93                crate::serde_json::json!({
94                    "collection": loss.collection.clone(), "root_page": loss.root_page,
95                    "action": if loss.skipped_pages == [loss.root_page] { "collection_emptied" } else { "damaged_leaves_skipped" },
96                    "reason": "page_checksum_mismatch", "skipped_pages": loss.skipped_pages.clone(),
97                    "skipped_entities": crate::serde_json::Value::Null,
98                })
99            })
100            .collect();
101        crate::serde_json::json!({"scope": "legacy_collection_trees", "page_generation_freshness_verified": false, "identity_high_water_verified": false, "safe_for_writes": false, "collections": collections}).to_string_compact()
102    }
103}
104
105/// Recover intact collections and verified leaves in a reference-free legacy
106/// store. Lost entity counts and identity high-water marks remain unknown.
107pub fn salvage_paged_collections(
108    source: &Path,
109    destination: &Path,
110) -> Result<PagedCollectionSalvageReport> {
111    let canonical_path = source.canonicalize()?;
112    let source = canonical_path.as_path();
113    let guard = Arc::new(Source::open(source)?);
114    verify_supported_source(source, &guard)?;
115    ensure(
116        guard.header.physical == f::PhysicalFileHeader::default(),
117        "physical metadata requires separate data recovery verification",
118    )?;
119    ensure(
120        !f::physical_metadata_binary_path(source).exists()
121            && !f::physical_metadata_json_path(source).exists(),
122        "metadata sidecar requires separate data recovery verification",
123    )?;
124    let catalog = Catalog::read(&guard)?;
125    ensure(
126        catalog.resume.is_none() && !catalog.has_cross_references(),
127        "collection recovery requires a reference-free legacy catalog",
128    )?;
129    let scrub = scrub_paged_trees(source)?;
130    let mut collections = Vec::new();
131    for finding in scrub.findings {
132        ensure(
133            finding.tree == "collection",
134            "only collection root damage is supported",
135        )?;
136        let canonical =
137            reddb_engine::storage::system_collection_names::canonical_or_self(&finding.collection);
138        ensure(
139            !canonical.starts_with("red.") && !canonical.starts_with('_'),
140            "system collections cannot be dropped",
141        )?;
142        let skipped_pages = match guard.raw_page(finding.root_page, true) {
143            Ok(_) => {
144                let tree = BTree::with_root(Arc::clone(&guard), finding.root_page);
145                tree.visit_recoverable_leaves(
146                    |id| {
147                        guard.claim(id).map_err(|error| {
148                            reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
149                        })
150                    },
151                    |_, _| Ok(()),
152                )?
153            }
154            Err(error) => {
155                ensure(
156                    matches!(
157                        error.downcast_ref::<reddb_engine::storage::page::PageError>(),
158                        Some(reddb_engine::storage::page::PageError::ChecksumMismatch { .. })
159                    ),
160                    "collection root failure is not a checksum mismatch",
161                )?;
162                ensure(
163                    guard.raw_page(finding.root_page, false)?.page_type()? == PageType::BTreeLeaf,
164                    "only leaf-root collection damage is supported",
165                )?;
166                vec![finding.root_page]
167            }
168        };
169        ensure(!skipped_pages.is_empty(), "no checksum-damaged leaf found")?;
170        collections.push(PagedCollectionLoss {
171            collection: finding.collection,
172            root_page: finding.root_page,
173            skipped_pages,
174        });
175    }
176    ensure(
177        !collections.is_empty(),
178        "no damaged collection root to recover",
179    )?;
180    let report = PagedCollectionSalvageReport { collections };
181    compact_paged_inner(source, destination, None, Some(&report), false)?;
182    Ok(report)
183}
184
185pub(super) fn verify_supported_source(source: &Path, guard: &Source) -> Result<()> {
186    verify_supported_source_inner(source, guard, false)
187}
188
189pub(super) fn verify_supported_source_with_audit(source: &Path, guard: &Source) -> Result<()> {
190    verify_supported_source_inner(source, guard, true)
191}
192
193fn verify_supported_source_inner(
194    source: &Path,
195    guard: &Source,
196    preserve_audit: bool,
197) -> Result<()> {
198    guard.verify_superblocks()?;
199    verify_standalone_sidecars_inner(source, preserve_audit)?;
200    let physical = guard.header.physical;
201    for (page_id, expected) in [
202        (physical.manifest_page, physical.manifest_checksum),
203        (
204            physical.collection_roots_page,
205            physical.collection_roots_checksum,
206        ),
207        (physical.registry_page, physical.registry_checksum),
208        (physical.recovery_page, physical.recovery_checksum),
209        (physical.catalog_page, physical.catalog_checksum),
210        (
211            physical.metadata_state_page,
212            physical.metadata_state_checksum,
213        ),
214        (
215            physical.vector_artifact_page,
216            physical.vector_artifact_checksum,
217        ),
218    ] {
219        if page_id == 0 {
220            continue;
221        }
222        let page = guard.raw_page(page_id, true)?;
223        ensure(
224            page.page_type()? == PageType::NativeMeta,
225            "invalid physical metadata page",
226        )?;
227        // The published checksum covers the encoded payload, not the zero
228        // padding to the page boundary. Its length is not stored separately.
229        // Accept only a checksum matching a prefix with exclusively zero
230        // bytes after it; the compactor also decodes each payload below.
231        let content = page.content();
232        let minimum = content
233            .iter()
234            .rposition(|byte| *byte != 0)
235            .map_or(0, |position| position + 1);
236        let mut checksum = crc32fast::Hasher::new();
237        checksum.update(&content[..minimum]);
238        let mut matches = u64::from(checksum.clone().finalize()) == expected;
239        for byte in &content[minimum..] {
240            checksum.update(std::slice::from_ref(byte));
241            matches |= u64::from(checksum.clone().finalize()) == expected;
242        }
243        ensure(
244            matches,
245            "physical metadata differs from its published checksum",
246        )?;
247    }
248    verify_vault(source, guard)?;
249    Ok(())
250}
251
252pub(super) fn verify_wal_recovery_sidecars(source: &Path) -> Result<()> {
253    verify_standalone_sidecars_inner(source, true)
254}
255
256fn verify_standalone_sidecars_inner(source: &Path, preserve_audit: bool) -> Result<()> {
257    let operational = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
258    if operational.exists() {
259        f::OperationalManifest::for_db_path(source).verify_owned_for_copy()?;
260        f::OperationalManifest::for_db_path(source).verify_marker_only_for_recovery()?;
261    }
262    for path in [
263        f::logical_wal_path(source),
264        f::legacy_logical_slots_path(source),
265    ] {
266        match std::fs::symlink_metadata(path) {
267            Ok(metadata) => ensure(
268                metadata.is_file() && metadata.len() == 0,
269                "logical WAL, slot or audit data requires separate recovery verification",
270            )?,
271            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
272            Err(error) => return Err(error.into()),
273        }
274    }
275    let audit = f::legacy_audit_log_path(source);
276    match std::fs::symlink_metadata(&audit) {
277        Ok(metadata) if preserve_audit => {
278            ensure(metadata.is_file(), "audit log must be a regular file")?;
279            super::verify_audit_log_family(source)?;
280        }
281        Ok(metadata) => ensure(
282            metadata.is_file() && metadata.len() == 0,
283            "logical WAL, slot or audit data requires separate recovery verification",
284        )?,
285        Err(error) if error.kind() == std::io::ErrorKind::NotFound && preserve_audit => {
286            super::verify_audit_log_family(source)?;
287        }
288        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
289        Err(error) => return Err(error.into()),
290    }
291    Ok(())
292}
293
294pub(super) fn verify_vault(source: &Path, guard: &Source) -> Result<()> {
295    let vault = guard.raw_page(2, false)?;
296    if !vault.as_bytes().iter().all(|byte| *byte == 0) {
297        ensure(vault.page_type()? == PageType::Vault, "invalid vault page")?;
298        if vault.content().iter().all(|byte| *byte == 0) {
299            vault.verify_checksum()?;
300            return Ok(());
301        }
302        let content = vault.content();
303        ensure(
304            &content[..4] == b"RDVT" && matches!(content[4], 2 | 3),
305            "unsupported vault header",
306        )?;
307        let payload = u32::from_le_bytes(content[21..25].try_into()?);
308        ensure(
309            payload <= 64 * 1024 * 1024,
310            "vault exceeds verification budget",
311        )?;
312        let pager = Pager::open(
313            source,
314            PagerConfig {
315                read_only: true,
316                create: false,
317                ..PagerConfig::default()
318            },
319        )?;
320        let vault =
321            crate::auth::vault::Vault::open(&pager).map_err(|_| "vault authentication failed")?;
322        ensure(
323            matches!(vault.load(&pager), Ok(Some(_))),
324            "vault authentication failed",
325        )?;
326    }
327    Ok(())
328}