Skip to main content

reddb_server/storage/
compact.rs

1//! Offline native rewrite. Source is read-locked against writers and never
2//! opened by a recovering writer. A new directory owns all output; the main
3//! filename is published only after sync and a complete record-by-record
4//! readback.
5use crate::storage::engine::{Page, PageType, Pager, PagerConfig, HEADER_SIZE, PAGE_SIZE};
6use reddb_engine::storage::{
7    btree::BTree,
8    pager_io::{DWB_ZONE_PAGES, DWB_ZONE_START_PAGE, FIRST_ALLOCATABLE_PAGE},
9};
10use reddb_file as f;
11use std::{
12    collections::BTreeMap,
13    fs::{File, OpenOptions},
14    io::{Read, Write},
15    path::Path,
16    sync::Arc,
17};
18mod assets;
19mod catalog;
20mod salvage;
21mod scrub;
22mod source;
23use catalog::Catalog;
24pub use salvage::{
25    salvage_paged_collections, salvage_paged_indexes, PagedCollectionLoss,
26    PagedCollectionSalvageReport, PagedIndexSalvageFinding, PagedIndexSalvageReport,
27};
28pub use scrub::{
29    scrub_paged, scrub_paged_trees, PagedScrubReport, PagedTreeFinding, PagedTreeScrubReport,
30};
31use source::Source;
32type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
33
34#[derive(Debug)]
35pub struct CompactionReport {
36    pub source_bytes: u64,
37    pub destination_bytes: u64,
38    pub source_allocated_bytes: Option<u64>,
39    pub destination_allocated_bytes: Option<u64>,
40    /// Includes exact persisted key and secondary-index records.
41    pub records: u64,
42    pub trees: usize,
43    /// Historical stale backlinks reconstructed from validated forward/owner order.
44    pub repaired_leaf_backlinks: u64,
45}
46
47impl CompactionReport {
48    pub fn machine_json(&self) -> String {
49        format!(
50            r#"{{"source_bytes":{},"destination_bytes":{},"records":{},"trees":{},"repaired_leaf_backlinks":{},"source_allocated_bytes":{},"destination_allocated_bytes":{}}}"#,
51            self.source_bytes,
52            self.destination_bytes,
53            self.records,
54            self.trees,
55            self.repaired_leaf_backlinks,
56            self.source_allocated_bytes
57                .map_or_else(|| "null".into(), |n| n.to_string()),
58            self.destination_allocated_bytes
59                .map_or_else(|| "null".into(), |n| n.to_string())
60        )
61    }
62}
63
64/// Rewrite a closed, checkpointed paged file into an exclusively-created output
65/// directory. On failure the source is unchanged and the incomplete output is
66/// retained for diagnosis. This does not switch a serving database or vacuum MVCC.
67pub fn compact_paged(source: &Path, destination: &Path) -> Result<CompactionReport> {
68    compact_paged_inner(source, destination, None, None, false)
69}
70
71/// Rewrite a closed store while explicitly retiring the obsolete physical
72/// `red_config` identity. The canonical `red.config` tree is preserved as-is;
73/// the source remains unchanged and ordinary compaction never enables this.
74pub fn compact_paged_canonical_system_config(
75    source: &Path,
76    destination: &Path,
77) -> Result<CompactionReport> {
78    compact_paged_inner(source, destination, None, None, true)
79}
80
81/// Replay a supported pending unified WAL only against an isolated copy, then
82/// publish a validated compacted database. The locked source is never opened
83/// by a writer. Unsupported WAL families and ambiguous recovery fail closed.
84pub fn recover_paged_wal(source: &Path, destination: &Path) -> Result<CompactionReport> {
85    use crate::storage::wal::{WalReader, WalRecord};
86    let source = source.canonicalize()?;
87    let parent = destination
88        .parent()
89        .filter(|p| !p.as_os_str().is_empty())
90        .unwrap_or_else(|| Path::new("."))
91        .canonicalize()?;
92    for owned in [
93        f::support_dir_for(&source),
94        source.with_file_name(format!("{}.ops", f::data_file_name(&source))),
95    ] {
96        if owned.exists() {
97            ensure(
98                !parent.starts_with(owned.canonicalize()?),
99                "destination is inside source storage",
100            )?;
101        }
102    }
103    let guard = Source::open_for_wal_recovery(&source)?;
104    guard.verify_superblocks()?;
105    salvage::verify_standalone_sidecars(&source)?;
106    let wal = f::unified_wal_path(&source);
107    let engine_wal = f::engine_wal_path(&source);
108    let pending_unified = wal_has_records(&wal)?;
109    let pending_engine = wal_has_records(&engine_wal)?;
110    ensure(
111        pending_unified
112            || pending_engine
113            || guard.pending_double_write
114            || guard.header.checkpoint_in_progress,
115        "no pending recovery",
116    )?;
117    ensure(
118        !guard.header.checkpoint_in_progress || pending_engine,
119        "interrupted page checkpoint requires its engine WAL",
120    )?;
121    let mut pending_ownership = false;
122    if pending_unified {
123        for entry in WalReader::open(&wal)?.iter() {
124            match entry?.1 {
125                WalRecord::PageWrite { data, .. } => verify_wal_recovery_action(&data)?,
126                WalRecord::TxCommitBatch { actions, .. } => {
127                    for action in actions {
128                        verify_wal_recovery_action(&action)?;
129                    }
130                }
131                WalRecord::VectorInsert { .. } | WalRecord::ProbabilisticDelta { .. } => {
132                    return Err(
133                        "WAL contains runtime-owned data requiring separate recovery".into(),
134                    );
135                }
136                WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
137                    pending_ownership = true
138                }
139                _ => {}
140            }
141        }
142    }
143    if !guard.pending_double_write
144        && !guard.header.checkpoint_in_progress
145        && !pending_ownership
146        && !pending_engine
147    {
148        salvage::verify_vault(&source, &guard)?;
149    }
150    let mut recovered_engine_xid = 0;
151    if pending_engine {
152        for entry in WalReader::open(&engine_wal)?.iter() {
153            match entry?.1 {
154                WalRecord::Begin { .. }
155                | WalRecord::Commit { .. }
156                | WalRecord::Rollback { .. }
157                | WalRecord::Checkpoint { .. } => {}
158                WalRecord::PageWrite { page_id, data, .. } => {
159                    ensure(
160                        page_id > 0
161                            && (page_id < DWB_ZONE_START_PAGE || page_id >= FIRST_ALLOCATABLE_PAGE)
162                            && u64::from(page_id) < guard.bytes.div_ceil(PAGE_SIZE as u64)
163                            && data.len() == PAGE_SIZE,
164                        "invalid engine WAL page",
165                    )?;
166                    let page = Page::from_slice(&data)?;
167                    ensure(
168                        page.page_id() == page_id,
169                        "engine WAL page identity mismatch",
170                    )?;
171                    page.page_type()?;
172                    // Engine transactions log page bytes before Pager::write_page
173                    // updates the page checksum. The WAL frame itself protects
174                    // the image; redo will seal the page before writing it.
175                }
176                _ => return Err("unsupported engine WAL record".into()),
177            }
178        }
179    }
180    std::fs::create_dir(destination)?;
181    #[cfg(unix)]
182    {
183        use std::os::unix::fs::PermissionsExt;
184        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
185    }
186    let stage = destination.join(".wal-replay");
187    std::fs::create_dir(&stage)?;
188    let name = source.file_name().ok_or("source filename is missing")?;
189    let staged_source = stage.join(name);
190    guard.copy_snapshot(&staged_source)?;
191    copy_sidecars(&source, &staged_source)?;
192    copy_physical_metadata_files(&source, &staged_source)?;
193    let config = crate::storage::unified::UnifiedStoreConfig {
194        resident_memory_bytes_max: (guard.header.version
195            >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
196            .then_some(64 * 1024 * 1024),
197        ..Default::default()
198    };
199    if pending_engine {
200        let pager = Pager::open(
201            &staged_source,
202            PagerConfig {
203                disk_residency: config.resident_memory_bytes_max.is_some(),
204                ..PagerConfig::default()
205            },
206        )?;
207        let wal = f::engine_wal_path(&staged_source);
208        recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
209        let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
210        writer.truncate()?;
211        writer.sync()?;
212    }
213    {
214        let store =
215            crate::storage::unified::UnifiedStore::open_with_config(&staged_source, config)?;
216        if pending_engine || guard.pending_double_write {
217            store.reconcile_physical_recovery()?;
218            // The same root can now contain newer entity headers than the
219            // old catalog summary. Preserve its previous floor, account for
220            // the physical WAL, and visit the recovered base before publishing.
221            let mut high = store
222                .startup_xid_high_water_mark()
223                .unwrap_or(0)
224                .max(recovered_engine_xid);
225            for name in store.list_collections() {
226                let manager = store
227                    .get_collection(&name)
228                    .ok_or("recovered collection disappeared")?;
229                let mut max_entity = 0u64;
230                let mut max_row = 0u64;
231                let mut max_sequence = 0u64;
232                let mut has_entity = false;
233                manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
234                    has_entity = true;
235                    high = high.max(entity.xmin).max(entity.xmax);
236                    max_entity = max_entity.max(entity.id.raw());
237                    max_sequence = max_sequence.max(entity.sequence_id);
238                    if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
239                        max_row = max_row.max(*row_id);
240                    }
241                    true
242                })?;
243                if !has_entity {
244                    continue;
245                }
246                let entity_next = max_entity
247                    .checked_add(1)
248                    .ok_or("recovered entity IDs are exhausted")?;
249                let row_next = max_row
250                    .checked_add(1)
251                    .ok_or("recovered row IDs are exhausted")?;
252                let sequence_next = max_sequence
253                    .checked_add(1)
254                    .ok_or("recovered sequence IDs are exhausted")?;
255                manager.restore_identity_counters(entity_next, row_next);
256                manager.restore_sequence_id_next(sequence_next);
257                store.register_entity_id(crate::storage::EntityId::new(max_entity));
258            }
259            ensure(
260                high < u64::MAX,
261                "recovered transaction high-water mark is exhausted",
262            )?;
263            store.remember_startup_xid_high_water_mark(high);
264        }
265        store.persist()?;
266    }
267    let validated_dir = stage.join("validated");
268    let report = compact_paged(&staged_source, &validated_dir)?;
269    let verified = scrub_paged(&validated_dir.join(name))?;
270    ensure(
271        verified.assets_verified
272            && verified.trees.findings.is_empty()
273            && verified.unclassified_pages == 0,
274        "replayed store failed scrub",
275    )?;
276    let final_path = destination.join(name);
277    copy_sidecars(&validated_dir.join(name), &final_path)?;
278    copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
279    write_new(
280        &destination.join("compaction.json"),
281        report.machine_json().as_bytes(),
282    )?;
283    sync_directory(destination)?;
284    std::fs::hard_link(validated_dir.join(name), &final_path)?;
285    sync_directory(destination)?;
286    // The published database must not retain a second pathname with a
287    // different WAL family: future opens through that name would be unsafe.
288    std::fs::remove_file(validated_dir.join(name))?;
289    sync_directory(&validated_dir)?;
290    Ok(report)
291}
292
293fn verify_wal_recovery_action(bytes: &[u8]) -> Result<()> {
294    f::decode_store_wal_action_frame(bytes)?;
295    Ok(())
296}
297
298fn wal_has_records(path: &Path) -> Result<bool> {
299    match std::fs::metadata(path) {
300        Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
301        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
302        Err(error) => Err(error.into()),
303    }
304}
305
306fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
307    use crate::storage::wal::{WalReader, WalRecord};
308    let mut transactions = std::collections::HashMap::new();
309    let mut last_lsn = 0;
310    let mut highest_tx = 0u64;
311    let header = pager.header()?;
312    let mut target_seen = !header.checkpoint_in_progress;
313    for item in WalReader::open(path)?.iter() {
314        let (lsn, record) = item?;
315        last_lsn = lsn;
316        target_seen |= lsn == header.checkpoint_target_lsn;
317        match record {
318            WalRecord::Begin { tx_id } => {
319                highest_tx = highest_tx.max(tx_id);
320                transactions.insert(tx_id, false);
321            }
322            WalRecord::Commit { tx_id } => {
323                highest_tx = highest_tx.max(tx_id);
324                transactions.insert(tx_id, true);
325            }
326            WalRecord::Rollback { tx_id } => {
327                highest_tx = highest_tx.max(tx_id);
328                transactions.remove(&tx_id);
329            }
330            WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
331            _ => {}
332        }
333    }
334    ensure(
335        target_seen,
336        "engine WAL does not contain the interrupted checkpoint target",
337    )?;
338    // Store only the LSN of each latest committed page, not every page image.
339    let mut latest = std::collections::HashMap::new();
340    for item in WalReader::open(path)?.iter() {
341        let (lsn, record) = item?;
342        if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
343            if transactions.get(&tx_id).copied().unwrap_or(false) {
344                latest.insert(page_id, lsn);
345            }
346        }
347    }
348    ensure(
349        !header.checkpoint_in_progress || !latest.is_empty(),
350        "interrupted checkpoint has no committed page images",
351    )?;
352    if !latest.is_empty() {
353        pager.set_checkpoint_in_progress(true, last_lsn)?;
354        for item in WalReader::open(path)?.iter() {
355            let (lsn, record) = item?;
356            if let WalRecord::PageWrite { page_id, data, .. } = record {
357                if latest.get(&page_id) == Some(&lsn) {
358                    pager.write_page(page_id, Page::from_slice(&data)?)?;
359                }
360            }
361        }
362        pager.sync()?;
363        pager.complete_checkpoint(last_lsn)?;
364    }
365    Ok(highest_tx)
366}
367
368fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
369    for (old, new) in [
370        (
371            f::physical_metadata_binary_path(source),
372            f::physical_metadata_binary_path(destination),
373        ),
374        (
375            f::physical_metadata_json_path(source),
376            f::physical_metadata_json_path(destination),
377        ),
378    ] {
379        match std::fs::symlink_metadata(&old) {
380            Ok(stat) => {
381                ensure(
382                    stat.is_file() && stat.len() <= 64 * 1024 * 1024,
383                    "invalid physical metadata sidecar",
384                )?;
385                let mut input = File::open(&old)?;
386                let mut output = create_new(&new)?;
387                std::io::copy(&mut input, &mut output)?;
388                output.sync_all()?;
389                drop(output);
390                verify_file_copy(&old, &new)?;
391            }
392            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
393            Err(error) => return Err(error.into()),
394        }
395    }
396    Ok(())
397}
398
399fn compact_paged_inner(
400    source: &Path,
401    destination: &Path,
402    salvage: Option<&PagedIndexSalvageReport>,
403    lost_collections: Option<&PagedCollectionSalvageReport>,
404    retain_canonical_system_config: bool,
405) -> Result<CompactionReport> {
406    let source_path = source.canonicalize()?;
407    let parent = destination
408        .parent()
409        .filter(|parent| !parent.as_os_str().is_empty())
410        .unwrap_or_else(|| Path::new("."))
411        .canonicalize()?;
412    for source_directory in [
413        f::support_dir_for(&source_path),
414        source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
415    ] {
416        if source_directory.exists() {
417            ensure(
418                !parent.starts_with(source_directory.canonicalize()?),
419                "destination is inside source storage",
420            )?;
421        }
422    }
423    let source = Arc::new(Source::open(&source_path)?);
424    let mut catalog = Catalog::read(&source)?;
425    if retain_canonical_system_config {
426        catalog.retain_canonical_system_config()?;
427    }
428    ensure(
429        source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
430            || catalog.resume.is_some(),
431        "prepared disk-resident file has no resume catalog",
432    )?;
433    if let Some(salvage) = salvage {
434        let resume = catalog
435            .resume
436            .as_mut()
437            .ok_or("index recovery requires a resume catalog")?;
438        for finding in &salvage.indexes {
439            let state = resume
440                .collections
441                .get_mut(&finding.collection)
442                .ok_or("index collection missing from resume")?;
443            let index = state
444                .secondary_indices
445                .remove(&finding.index)
446                .ok_or("index missing from resume")?;
447            ensure(
448                index.root_page == finding.root_page,
449                "index root changed during salvage",
450            )?;
451        }
452    }
453    if let Some(losses) = lost_collections {
454        ensure(
455            catalog.resume.is_none() && !catalog.has_cross_references(),
456            "collection recovery requires a reference-free legacy catalog",
457        )?;
458        for loss in &losses.collections {
459            let root = catalog
460                .roots
461                .iter_mut()
462                .find(|root| root.collection == loss.collection)
463                .ok_or("collection missing from catalog")?;
464            ensure(
465                root.root_page == loss.root_page,
466                "collection root changed during salvage",
467            )?;
468            if loss.skipped_pages == [loss.root_page] {
469                root.root_page = 0;
470            }
471        }
472    }
473    let name = source_path
474        .file_name()
475        .ok_or("source filename is missing")?;
476    // Exclusive creation prevents clobbering files, symlinks and other runs.
477    std::fs::create_dir(destination)?;
478    #[cfg(unix)]
479    {
480        use std::os::unix::fs::PermissionsExt;
481        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
482    }
483    let output = destination.join(name);
484    let temporary = destination.join(".compacting.rdb");
485    ensure(output != temporary, "reserved compaction source filename")?;
486    let target = Arc::new(Pager::open(
487        &temporary,
488        PagerConfig {
489            cache_size: 1024,
490            disk_residency: source.header.version
491                >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
492            ..PagerConfig::default()
493        },
494    )?);
495    let mut roots = BTreeMap::new();
496    let mut recovered_trees = BTreeMap::new();
497    let mut records = 0;
498    let mut repaired_leaf_backlinks = 0;
499    for collection in &catalog.roots {
500        let loss = lost_collections.and_then(|report| {
501            report
502                .collections
503                .iter()
504                .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
505        });
506        let count = if let Some(loss) = loss {
507            let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
508            recovered_trees.insert(loss.root_page, (count, hash));
509            count
510        } else {
511            rewrite_tree(
512                &source,
513                &target,
514                collection.root_page,
515                &mut roots,
516                &mut repaired_leaf_backlinks,
517            )?
518        };
519        if let Some(resume) = &catalog.resume {
520            ensure(
521                resume.collections[&collection.collection].entities == count,
522                "collection count disagrees with resume",
523            )?;
524        }
525        records += count;
526    }
527    if let Some(resume) = &catalog.resume {
528        for state in resume.collections.values() {
529            if let Some(root) = state.key_index_root {
530                records += rewrite_tree(
531                    &source,
532                    &target,
533                    root,
534                    &mut roots,
535                    &mut repaired_leaf_backlinks,
536                )?;
537            }
538            for index in state.secondary_indices.values() {
539                records += rewrite_tree(
540                    &source,
541                    &target,
542                    index.root_page,
543                    &mut roots,
544                    &mut repaired_leaf_backlinks,
545                )?;
546            }
547        }
548    }
549    let mut assets = assets::Assets::new(&source, &target);
550    assets.vault()?;
551    let physical = assets.physical()?;
552    assets.metadata(&source_path, Some(&output))?;
553    if salvage.is_some() || lost_collections.is_some() {
554        if let Some(salvage) = salvage {
555            for index in &salvage.indexes {
556                if index.root_page != 0 {
557                    match source.raw_page(index.root_page, true) {
558                        Ok(_) => {
559                            let tree = BTree::with_root(Arc::clone(&source), index.root_page);
560                            let skipped = tree.visit_recoverable_leaves(
561                                |id| {
562                                    source.claim(id).map_err(|error| {
563                                        reddb_engine::storage::btree::BTreeError::Corrupted(
564                                            error.to_string(),
565                                        )
566                                    })
567                                },
568                                |_, _| Ok(()),
569                            )?;
570                            ensure(
571                                !skipped.is_empty(),
572                                "index damage is not a verified leaf checksum failure",
573                            )?;
574                        }
575                        Err(error) => {
576                            ensure(
577                                matches!(
578                                    error.downcast_ref::<reddb_engine::storage::page::PageError>(),
579                                    Some(
580                                        reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
581                                    )
582                                ),
583                                "index root failure is not a checksum mismatch",
584                            )?;
585                            ensure(
586                                source.raw_page(index.root_page, false)?.page_type()?
587                                    == PageType::BTreeLeaf,
588                                "damaged interior index root cannot be recovered",
589                            )?;
590                            source.claim(index.root_page)?;
591                        }
592                    }
593                }
594            }
595        }
596        if let Some(losses) = lost_collections {
597            for collection in &losses.collections {
598                if collection.skipped_pages == [collection.root_page] {
599                    source.claim(collection.root_page)?;
600                }
601            }
602        }
603        ensure(
604            source.unclaimed_pages() == 0,
605            "unclassified source pages require separate recovery verification",
606        )?;
607    }
608    catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
609    target.update_physical_header(physical)?;
610    target.fence_file_format_version(source.header.version)?;
611    target.sync()?;
612    // Preserve source schema/LSN, but retain the newly allocated page boundary.
613    for _ in 0..2 {
614        target.commit_superblock(|slot| {
615            let mut header = f::decode_database_header(slot).map_err(|error| {
616                reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
617            })?;
618            header.schema_version = source.header.schema_version;
619            header.checkpoint_lsn = source.header.checkpoint_lsn;
620            f::encode_database_header(slot, &header).map_err(|error| {
621                reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
622            })
623        })?;
624    }
625    if lost_collections.is_some() {
626        // Both copies must be fenced before the recovered filename is published.
627        // Old engines reject this newer version; this engine admits it read-only.
628        for _ in 0..2 {
629            target.commit_superblock(|slot| {
630                f::set_database_header_version(
631                    slot,
632                    f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
633                )
634                .map_err(|error| {
635                    reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
636                })
637            })?;
638        }
639    }
640    let assets_verification = assets.into_verification();
641    drop(target);
642    let reopened = Arc::new(Source::open(&temporary)?);
643    if salvage.is_some() || lost_collections.is_some() {
644        salvage::verify_vault(&temporary, &reopened)?;
645    }
646    ensure(
647        reopened.header.physical == physical,
648        "rewritten physical header failed parity",
649    )?;
650    let rewritten_catalog = Catalog::read(&reopened)?;
651    ensure(
652        rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
653        "rewritten catalog differs from source",
654    )?;
655    for (&old, &new) in &roots {
656        if let Some((count, hash)) = recovered_trees.get(&old) {
657            verify_recovered_tree(&reopened, new, *count, *hash)?;
658        } else {
659            verify_tree(&source, &reopened, old, new)?;
660        }
661    }
662    for (id, expected, checksum) in assets_verification {
663        let page = reopened.raw_page(id, checksum)?;
664        if id != 2 {
665            reopened.claim(id)?;
666        }
667        ensure(
668            blake3::hash(page.content()) == expected,
669            "native asset failed readback parity",
670        )?;
671    }
672    let report = CompactionReport {
673        source_bytes: source.bytes,
674        destination_bytes: reopened.bytes,
675        source_allocated_bytes: allocated_bytes(&source_path)?,
676        destination_allocated_bytes: allocated_bytes(&temporary)?,
677        records,
678        trees: roots.len(),
679        repaired_leaf_backlinks,
680    };
681    drop(reopened);
682    copy_sidecars(&source_path, &output)?;
683    if retain_canonical_system_config {
684        let operational_path = output.with_file_name(format!("{}.ops", f::data_file_name(&output)));
685        if operational_path.exists() {
686            crate::storage::operational_manifest::OperationalManifest::for_db_path(&output)
687                .reconcile_collection_collisions(&[("red.config", "red_config", "red.config")])?;
688        }
689    }
690    write_new(
691        &destination.join("compaction.json"),
692        report.machine_json().as_bytes(),
693    )?;
694    if let Some(salvage) = salvage {
695        write_new(
696            &destination.join("salvage.json"),
697            salvage.machine_json().as_bytes(),
698        )?;
699    }
700    if let Some(losses) = lost_collections {
701        write_new(
702            &destination.join("salvage.json"),
703            losses.machine_json().as_bytes(),
704        )?;
705    }
706    sync_directory(destination)?;
707    // hard_link is create-new even if an unexpected file appears during work.
708    std::fs::hard_link(&temporary, &output)?;
709    sync_directory(destination)?;
710    std::fs::remove_file(&temporary)?;
711    sync_directory(destination)?;
712    if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
713        sync_directory(parent)?;
714    }
715    Ok(report)
716}
717fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
718    roots.values().map(|root| (*root, *root)).collect()
719}
720
721fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
722    hasher.update(&(key.len() as u64).to_le_bytes());
723    hasher.update(key);
724    hasher.update(&(value.len() as u64).to_le_bytes());
725    hasher.update(value);
726}
727
728fn rewrite_recoverable_tree(
729    source: &Arc<Source>,
730    target: &Arc<Pager>,
731    loss: &PagedCollectionLoss,
732    roots: &mut BTreeMap<u32, u32>,
733) -> Result<(u64, blake3::Hash)> {
734    ensure(
735        !roots.contains_key(&loss.root_page),
736        "duplicate live tree owner",
737    )?;
738    let original = BTree::with_root(Arc::clone(source), loss.root_page);
739    let destination = BTree::new(Arc::clone(target));
740    let mut hash = blake3::Hasher::new();
741    let mut count = 0u64;
742    let mut batch = Vec::new();
743    let mut size = 0usize;
744    let skipped = original.visit_recoverable_leaves(
745        |id| {
746            source.claim(id).map_err(|error| {
747                reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
748            })
749        },
750        |key, value| {
751            hash_record(&mut hash, &key, &value);
752            size += key.len() + value.len();
753            batch.push((key, value));
754            count += 1;
755            if size >= 1024 * 1024 || batch.len() >= 512 {
756                destination.bulk_insert_sorted(&batch)?;
757                batch.clear();
758                size = 0;
759            }
760            Ok(())
761        },
762    )?;
763    ensure(
764        skipped == loss.skipped_pages,
765        "damaged pages changed during recovery",
766    )?;
767    destination.bulk_insert_sorted(&batch)?;
768    roots.insert(loss.root_page, destination.root_page_id());
769    Ok((count, hash.finalize()))
770}
771
772fn verify_recovered_tree(
773    source: &Arc<Source>,
774    root: u32,
775    count: u64,
776    expected: blake3::Hash,
777) -> Result<()> {
778    let tree = BTree::with_root(Arc::clone(source), root);
779    let (owned, _) = inventory_tree(source, &tree, false)?;
780    ensure(owned == count, "recovered tree record count differs")?;
781    let mut cursor = tree.cursor_first()?;
782    let mut hash = blake3::Hasher::new();
783    let mut actual = 0u64;
784    while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
785        hash_record(&mut hash, &key, &value);
786        actual += 1;
787        ensure(actual <= count, "recovered tree has extra records")?;
788    }
789    ensure(
790        actual == count && hash.finalize() == expected,
791        "recovered tree failed record parity",
792    )
793}
794
795fn rewrite_tree(
796    source: &Arc<Source>,
797    target: &Arc<Pager>,
798    root: u32,
799    roots: &mut BTreeMap<u32, u32>,
800    repaired_leaf_backlinks: &mut u64,
801) -> Result<u64> {
802    if root == 0 {
803        return Ok(0);
804    }
805    ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
806    let tree = BTree::with_root(Arc::clone(source), root);
807    let (expected, repairs) = inventory_tree(source, &tree, true)?;
808    *repaired_leaf_backlinks += repairs;
809    let destination = BTree::new(Arc::clone(target));
810    let mut cursor = tree.cursor_first()?;
811    let mut count = 0;
812    let mut previous: Option<Vec<u8>> = None;
813    let mut batch = Vec::new();
814    let mut bytes = 0;
815    while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
816        ensure(
817            previous.as_ref().is_none_or(|old| old < &key),
818            "source tree keys are not strictly ordered",
819        )?;
820        previous = Some(key.clone());
821        bytes += key.len() + value.len();
822        batch.push((key, value));
823        count += 1;
824        ensure(
825            count <= expected,
826            "tree cursor exceeds structural inventory",
827        )?;
828        if bytes >= 1024 * 1024 || batch.len() >= 512 {
829            destination.bulk_insert_sorted(&batch)?;
830            batch.clear();
831            bytes = 0;
832        }
833    }
834    destination.bulk_insert_sorted(&batch)?;
835    ensure(count == expected, "tree cursor omits owned records")?;
836    roots.insert(root, destination.root_page_id());
837    Ok(count)
838}
839// Interior ownership and forward traversal must agree exactly. Old split code
840// omitted the successor backlink update; that redundant edge can be rebuilt
841// without guessing any ownership or key/value. Never repair forward omissions.
842fn inventory_tree(
843    source: &Source,
844    tree: &BTree<Source>,
845    allow_backlinks: bool,
846) -> Result<(u64, u64)> {
847    let pages = tree.retired_pages()?;
848    let mut leaves = Vec::new();
849    let mut expected = 0u64;
850    for id in pages {
851        source.claim(id)?;
852        let page = source.raw_page(id, true)?;
853        if page.page_type()? == PageType::BTreeLeaf {
854            leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
855            expected += u64::from(page.cell_count());
856        }
857    }
858    // Ownership traversal is right-to-left. A cursor follows the independent
859    // leaf chain; prove it visits exactly the structurally owned leaves.
860    leaves.reverse();
861    let mut repaired = 0;
862    for (index, &(id, previous, next)) in leaves.iter().enumerate() {
863        ensure(
864            next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
865            &format!(
866                "broken forward leaf chain at page {id}, root {}",
867                tree.root_page_id()
868            ),
869        )?;
870        if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
871            ensure(
872                allow_backlinks,
873                &format!("broken backward leaf chain at page {id}"),
874            )?;
875            repaired += 1;
876        }
877    }
878    Ok((expected, repaired))
879}
880fn verify_tree(source: &Arc<Source>, destination: &Arc<Source>, old: u32, new: u32) -> Result<()> {
881    let old = BTree::with_root(Arc::clone(source), old);
882    let new = BTree::with_root(Arc::clone(destination), new);
883    inventory_tree(destination, &new, false)?;
884    let mut left = old.cursor_first()?;
885    let mut right = new.cursor_first()?;
886    loop {
887        let expected = left.next_with_budget(32 * 1024 * 1024)?;
888        let actual = right.next_with_budget(32 * 1024 * 1024)?;
889        ensure(
890            expected == actual,
891            "compacted tree failed complete key/value parity",
892        )?;
893        if expected.is_none() {
894            return Ok(());
895        }
896    }
897}
898fn copy_sidecars(source: &Path, destination: &Path) -> Result<()> {
899    // These files contain logical records, not physical page addresses. Empty
900    // checkpointed WALs also preserve their version exactly.
901    for (old, new) in [
902        (
903            f::unified_wal_path(source),
904            f::unified_wal_path(destination),
905        ),
906        (f::engine_wal_path(source), f::engine_wal_path(destination)),
907        (
908            f::logical_wal_path(source),
909            f::logical_wal_path(destination),
910        ),
911        (
912            f::legacy_logical_slots_path(source),
913            f::legacy_logical_slots_path(destination),
914        ),
915        (
916            f::legacy_audit_log_path(source),
917            f::legacy_audit_log_path(destination),
918        ),
919    ] {
920        match File::open(&old) {
921            Ok(mut input) => {
922                let mut output = create_new(&new)?;
923                std::io::copy(&mut input, &mut output)?;
924                output.sync_all()?;
925                verify_file_copy(&old, &new)?;
926            }
927            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
928            Err(error) => return Err(error.into()),
929        }
930    }
931    let support = f::support_dir_for(source);
932    if support.exists() {
933        validate_empty_directory(&support, 0)?;
934        copy_directory(&support, &f::support_dir_for(destination), 0)?;
935    }
936    let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
937    let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
938    if old.exists() {
939        validate_operational(&old)?;
940        copy_directory(&old, &new, 0)?;
941    }
942    Ok(())
943}
944fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
945    ensure(depth < 32, "operational directory nesting exceeds limit")?;
946    ensure(
947        std::fs::symlink_metadata(source)?.is_dir(),
948        "operational directory must not be a symlink",
949    )?;
950    std::fs::create_dir(destination)?;
951    for entry in std::fs::read_dir(source)? {
952        let entry = entry?;
953        let kind = entry.file_type()?;
954        let output = destination.join(entry.file_name());
955        if kind.is_dir() {
956            copy_directory(&entry.path(), &output, depth + 1)?;
957        } else {
958            ensure(
959                kind.is_file(),
960                "operational directory contains a symlink or special file",
961            )?;
962            let mut input = File::open(entry.path())?;
963            let mut target = create_new(&output)?;
964            std::io::copy(&mut input, &mut target)?;
965            target.sync_all()?;
966            verify_file_copy(&entry.path(), &output)?;
967        }
968    }
969    sync_directory(destination)
970}
971fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
972    ensure(depth < 32, "support directory nesting exceeds limit")?;
973    ensure(
974        std::fs::symlink_metadata(path)?.is_dir(),
975        "support directory must not be a symlink",
976    )?;
977    for entry in std::fs::read_dir(path)? {
978        let entry = entry?;
979        ensure(
980            entry.file_type()?.is_dir(),
981            "non-empty support layout requires separate compaction qualification",
982        )?;
983        validate_empty_directory(&entry.path(), depth + 1)?;
984    }
985    Ok(())
986}
987fn validate_operational(root: &Path) -> Result<()> {
988    ensure(
989        !root.join("forks").exists(),
990        "store forks require separate compaction qualification",
991    )?;
992    let manifest = root.join("manifest.json");
993    ensure(
994        std::fs::metadata(&manifest)?.len() <= 64 * 1024 * 1024,
995        "operational manifest exceeds budget",
996    )?;
997    let document: crate::serde_json::Value =
998        crate::serde_json::from_str(&std::fs::read_to_string(manifest)?)?;
999    fn check(value: &crate::serde_json::Value, depth: usize) -> Result<()> {
1000        use crate::serde_json::Value;
1001        ensure(depth < 32, "operational manifest exceeds nesting limit")?;
1002        match value {
1003            Value::Object(fields) => {
1004                for (key, value) in fields {
1005                    if key == "source" || key == "fork_origin" {
1006                        ensure(
1007                            matches!(value, Value::Null),
1008                            "external operational owners require separate qualification",
1009                        )?;
1010                    }
1011                    if key == "path" {
1012                        let path = Path::new(value.as_str().ok_or("non-string operational path")?);
1013                        ensure(
1014                            !path.is_absolute()
1015                                && path
1016                                    .components()
1017                                    .all(|part| matches!(part, std::path::Component::Normal(_))),
1018                            "operational path escapes its directory",
1019                        )?;
1020                    }
1021                    check(value, depth + 1)?;
1022                }
1023            }
1024            Value::Array(values) => {
1025                for value in values {
1026                    check(value, depth + 1)?;
1027                }
1028            }
1029            _ => {}
1030        }
1031        Ok(())
1032    }
1033    check(&document, 0)
1034}
1035fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
1036    ensure(
1037        hash_reader(&mut File::open(source)?)? == hash_reader(&mut File::open(destination)?)?,
1038        "sidecar failed complete readback parity",
1039    )
1040}
1041fn hash_reader(input: &mut impl Read) -> Result<blake3::Hash> {
1042    let mut hash = blake3::Hasher::new();
1043    let mut buffer = [0; 64 * 1024];
1044    loop {
1045        let count = input.read(&mut buffer)?;
1046        if count == 0 {
1047            break;
1048        }
1049        hash.update(&buffer[..count]);
1050    }
1051    Ok(hash.finalize())
1052}
1053fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
1054    #[cfg(unix)]
1055    {
1056        use std::os::unix::fs::MetadataExt;
1057        Ok(Some(
1058            std::fs::metadata(path)?
1059                .blocks()
1060                .checked_mul(512)
1061                .ok_or("allocated byte count overflow")?,
1062        ))
1063    }
1064    #[cfg(not(unix))]
1065    {
1066        let _ = path;
1067        Ok(None)
1068    }
1069}
1070fn create_new(path: &Path) -> std::io::Result<File> {
1071    let mut options = OpenOptions::new();
1072    options.write(true).create_new(true);
1073    #[cfg(unix)]
1074    {
1075        use std::os::unix::fs::OpenOptionsExt;
1076        options.mode(0o600);
1077    }
1078    options.open(path)
1079}
1080fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1081    let mut file = create_new(path)?;
1082    file.write_all(bytes)?;
1083    file.sync_all()?;
1084    Ok(())
1085}
1086fn sync_directory(_path: &Path) -> Result<()> {
1087    #[cfg(unix)]
1088    File::open(_path)?.sync_all()?;
1089    Ok(())
1090}
1091fn ensure(condition: bool, message: &str) -> Result<()> {
1092    if condition {
1093        Ok(())
1094    } else {
1095        Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
1096    }
1097}
1098fn number(bytes: &[u8], offset: usize) -> Result<u32> {
1099    Ok(u32::from_le_bytes(
1100        bytes
1101            .get(offset..offset + 4)
1102            .ok_or("truncated native integer")?
1103            .try_into()?,
1104    ))
1105}