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)
69}
70
71/// Replay a supported pending unified WAL only against an isolated copy, then
72/// publish a validated compacted database. The locked source is never opened
73/// by a writer. Interrupted checkpoints, DWB and non-unified WALs are refused.
74pub fn recover_paged_wal(source: &Path, destination: &Path) -> Result<CompactionReport> {
75    use crate::storage::wal::{WalReader, WalRecord};
76    let source = source.canonicalize()?;
77    let parent = destination
78        .parent()
79        .filter(|p| !p.as_os_str().is_empty())
80        .unwrap_or_else(|| Path::new("."))
81        .canonicalize()?;
82    for owned in [
83        f::support_dir_for(&source),
84        source.with_file_name(format!("{}.ops", f::data_file_name(&source))),
85    ] {
86        if owned.exists() {
87            ensure(
88                !parent.starts_with(owned.canonicalize()?),
89                "destination is inside source storage",
90            )?;
91        }
92    }
93    let guard = Source::open_for_wal_recovery(&source)?;
94    salvage::verify_supported_source(&source, &guard)?;
95    let mut assets = assets::Assets::for_verification(&guard);
96    assets.vault()?;
97    assets.physical()?;
98    assets.metadata(&source, None)?;
99    let wal = f::unified_wal_path(&source);
100    let length = std::fs::metadata(&wal)?.len();
101    ensure(
102        length > f::WAL_FILE_HEADER_BYTES as u64,
103        "no pending unified WAL",
104    )?;
105    ensure(
106        length <= 256 * 1024 * 1024,
107        "WAL exceeds recovery verification budget",
108    )?;
109    for entry in WalReader::open(&wal)?.iter() {
110        match entry?.1 {
111            WalRecord::PageWrite { data, .. } => verify_wal_recovery_action(&data)?,
112            WalRecord::TxCommitBatch { actions, .. } => {
113                for action in actions {
114                    verify_wal_recovery_action(&action)?;
115                }
116            }
117            WalRecord::VectorInsert { .. } | WalRecord::ProbabilisticDelta { .. } => {
118                return Err("WAL contains runtime-owned data requiring separate recovery".into());
119            }
120            _ => {}
121        }
122    }
123    std::fs::create_dir(destination)?;
124    #[cfg(unix)]
125    {
126        use std::os::unix::fs::PermissionsExt;
127        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
128    }
129    let stage = destination.join(".wal-replay");
130    std::fs::create_dir(&stage)?;
131    let name = source.file_name().ok_or("source filename is missing")?;
132    let staged_source = stage.join(name);
133    let mut input = File::open(&source)?;
134    let mut output = create_new(&staged_source)?;
135    std::io::copy(&mut input, &mut output)?;
136    output.sync_all()?;
137    drop(output);
138    verify_file_copy(&source, &staged_source)?;
139    copy_sidecars(&source, &staged_source)?;
140    copy_physical_metadata_files(&source, &staged_source)?;
141    let config = crate::storage::unified::UnifiedStoreConfig {
142        resident_memory_bytes_max: (guard.header.version
143            >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
144            .then_some(64 * 1024 * 1024),
145        ..Default::default()
146    };
147    {
148        let store =
149            crate::storage::unified::UnifiedStore::open_with_config(&staged_source, config)?;
150        store.persist()?;
151    }
152    let validated_dir = stage.join("validated");
153    let report = compact_paged(&staged_source, &validated_dir)?;
154    let verified = scrub_paged(&validated_dir.join(name))?;
155    ensure(
156        verified.assets_verified
157            && verified.trees.findings.is_empty()
158            && verified.unclassified_pages == 0,
159        "replayed store failed scrub",
160    )?;
161    let final_path = destination.join(name);
162    copy_sidecars(&validated_dir.join(name), &final_path)?;
163    copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
164    write_new(
165        &destination.join("compaction.json"),
166        report.machine_json().as_bytes(),
167    )?;
168    sync_directory(destination)?;
169    std::fs::hard_link(validated_dir.join(name), &final_path)?;
170    sync_directory(destination)?;
171    // The published database must not retain a second pathname with a
172    // different WAL family: future opens through that name would be unsafe.
173    std::fs::remove_file(validated_dir.join(name))?;
174    sync_directory(&validated_dir)?;
175    Ok(report)
176}
177
178fn verify_wal_recovery_action(bytes: &[u8]) -> Result<()> {
179    match f::decode_store_wal_action_frame(bytes)? {
180        f::StoreWalActionFrame::UpsertEntityRecord { .. }
181        | f::StoreWalActionFrame::DeleteEntityRecord { .. }
182        | f::StoreWalActionFrame::BulkUpsertEntityRecords { .. } => Ok(()),
183        _ => Err("WAL schema or metadata changes require separate recovery qualification".into()),
184    }
185}
186
187fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
188    for (old, new) in [
189        (
190            f::physical_metadata_binary_path(source),
191            f::physical_metadata_binary_path(destination),
192        ),
193        (
194            f::physical_metadata_json_path(source),
195            f::physical_metadata_json_path(destination),
196        ),
197    ] {
198        match std::fs::symlink_metadata(&old) {
199            Ok(stat) => {
200                ensure(
201                    stat.is_file() && stat.len() <= 64 * 1024 * 1024,
202                    "invalid physical metadata sidecar",
203                )?;
204                let mut input = File::open(&old)?;
205                let mut output = create_new(&new)?;
206                std::io::copy(&mut input, &mut output)?;
207                output.sync_all()?;
208                drop(output);
209                verify_file_copy(&old, &new)?;
210            }
211            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
212            Err(error) => return Err(error.into()),
213        }
214    }
215    Ok(())
216}
217
218fn compact_paged_inner(
219    source: &Path,
220    destination: &Path,
221    salvage: Option<&PagedIndexSalvageReport>,
222    lost_collections: Option<&PagedCollectionSalvageReport>,
223) -> Result<CompactionReport> {
224    let source_path = source.canonicalize()?;
225    let parent = destination
226        .parent()
227        .filter(|parent| !parent.as_os_str().is_empty())
228        .unwrap_or_else(|| Path::new("."))
229        .canonicalize()?;
230    for source_directory in [
231        f::support_dir_for(&source_path),
232        source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
233    ] {
234        if source_directory.exists() {
235            ensure(
236                !parent.starts_with(source_directory.canonicalize()?),
237                "destination is inside source storage",
238            )?;
239        }
240    }
241    let source = Arc::new(Source::open(&source_path)?);
242    let mut catalog = Catalog::read(&source)?;
243    ensure(
244        source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
245            || catalog.resume.is_some(),
246        "prepared disk-resident file has no resume catalog",
247    )?;
248    if let Some(salvage) = salvage {
249        let resume = catalog
250            .resume
251            .as_mut()
252            .ok_or("index recovery requires a resume catalog")?;
253        for finding in &salvage.indexes {
254            let state = resume
255                .collections
256                .get_mut(&finding.collection)
257                .ok_or("index collection missing from resume")?;
258            let index = state
259                .secondary_indices
260                .remove(&finding.index)
261                .ok_or("index missing from resume")?;
262            ensure(
263                index.root_page == finding.root_page,
264                "index root changed during salvage",
265            )?;
266        }
267    }
268    if let Some(losses) = lost_collections {
269        ensure(
270            catalog.resume.is_none() && !catalog.has_cross_references(),
271            "collection recovery requires a reference-free legacy catalog",
272        )?;
273        for loss in &losses.collections {
274            let root = catalog
275                .roots
276                .iter_mut()
277                .find(|root| root.collection == loss.collection)
278                .ok_or("collection missing from catalog")?;
279            ensure(
280                root.root_page == loss.root_page,
281                "collection root changed during salvage",
282            )?;
283            if loss.skipped_pages == [loss.root_page] {
284                root.root_page = 0;
285            }
286        }
287    }
288    let name = source_path
289        .file_name()
290        .ok_or("source filename is missing")?;
291    // Exclusive creation prevents clobbering files, symlinks and other runs.
292    std::fs::create_dir(destination)?;
293    #[cfg(unix)]
294    {
295        use std::os::unix::fs::PermissionsExt;
296        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
297    }
298    let output = destination.join(name);
299    let temporary = destination.join(".compacting.rdb");
300    ensure(output != temporary, "reserved compaction source filename")?;
301    let target = Arc::new(Pager::open(
302        &temporary,
303        PagerConfig {
304            cache_size: 1024,
305            disk_residency: source.header.version
306                >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
307            ..PagerConfig::default()
308        },
309    )?);
310    let mut roots = BTreeMap::new();
311    let mut recovered_trees = BTreeMap::new();
312    let mut records = 0;
313    let mut repaired_leaf_backlinks = 0;
314    for collection in &catalog.roots {
315        let loss = lost_collections.and_then(|report| {
316            report
317                .collections
318                .iter()
319                .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
320        });
321        let count = if let Some(loss) = loss {
322            let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
323            recovered_trees.insert(loss.root_page, (count, hash));
324            count
325        } else {
326            rewrite_tree(
327                &source,
328                &target,
329                collection.root_page,
330                &mut roots,
331                &mut repaired_leaf_backlinks,
332            )?
333        };
334        if let Some(resume) = &catalog.resume {
335            ensure(
336                resume.collections[&collection.collection].entities == count,
337                "collection count disagrees with resume",
338            )?;
339        }
340        records += count;
341    }
342    if let Some(resume) = &catalog.resume {
343        for state in resume.collections.values() {
344            if let Some(root) = state.key_index_root {
345                records += rewrite_tree(
346                    &source,
347                    &target,
348                    root,
349                    &mut roots,
350                    &mut repaired_leaf_backlinks,
351                )?;
352            }
353            for index in state.secondary_indices.values() {
354                records += rewrite_tree(
355                    &source,
356                    &target,
357                    index.root_page,
358                    &mut roots,
359                    &mut repaired_leaf_backlinks,
360                )?;
361            }
362        }
363    }
364    let mut assets = assets::Assets::new(&source, &target);
365    assets.vault()?;
366    let physical = assets.physical()?;
367    assets.metadata(&source_path, Some(&output))?;
368    if salvage.is_some() || lost_collections.is_some() {
369        if let Some(salvage) = salvage {
370            for index in &salvage.indexes {
371                if index.root_page != 0 {
372                    match source.raw_page(index.root_page, true) {
373                        Ok(_) => {
374                            let tree = BTree::with_root(Arc::clone(&source), index.root_page);
375                            let skipped = tree.visit_recoverable_leaves(
376                                |id| {
377                                    source.claim(id).map_err(|error| {
378                                        reddb_engine::storage::btree::BTreeError::Corrupted(
379                                            error.to_string(),
380                                        )
381                                    })
382                                },
383                                |_, _| Ok(()),
384                            )?;
385                            ensure(
386                                !skipped.is_empty(),
387                                "index damage is not a verified leaf checksum failure",
388                            )?;
389                        }
390                        Err(error) => {
391                            ensure(
392                                matches!(
393                                    error.downcast_ref::<reddb_engine::storage::page::PageError>(),
394                                    Some(
395                                        reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
396                                    )
397                                ),
398                                "index root failure is not a checksum mismatch",
399                            )?;
400                            ensure(
401                                source.raw_page(index.root_page, false)?.page_type()?
402                                    == PageType::BTreeLeaf,
403                                "damaged interior index root cannot be recovered",
404                            )?;
405                            source.claim(index.root_page)?;
406                        }
407                    }
408                }
409            }
410        }
411        if let Some(losses) = lost_collections {
412            for collection in &losses.collections {
413                if collection.skipped_pages == [collection.root_page] {
414                    source.claim(collection.root_page)?;
415                }
416            }
417        }
418        ensure(
419            source.unclaimed_pages() == 0,
420            "unclassified source pages require separate recovery verification",
421        )?;
422    }
423    catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
424    target.update_physical_header(physical)?;
425    target.fence_file_format_version(source.header.version)?;
426    target.sync()?;
427    // Preserve source schema/LSN, but retain the newly allocated page boundary.
428    for _ in 0..2 {
429        target.commit_superblock(|slot| {
430            let mut header = f::decode_database_header(slot).map_err(|error| {
431                reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
432            })?;
433            header.schema_version = source.header.schema_version;
434            header.checkpoint_lsn = source.header.checkpoint_lsn;
435            f::encode_database_header(slot, &header).map_err(|error| {
436                reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
437            })
438        })?;
439    }
440    if lost_collections.is_some() {
441        // Both copies must be fenced before the recovered filename is published.
442        // Old engines reject this newer version; this engine admits it read-only.
443        for _ in 0..2 {
444            target.commit_superblock(|slot| {
445                f::set_database_header_version(
446                    slot,
447                    f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
448                )
449                .map_err(|error| {
450                    reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
451                })
452            })?;
453        }
454    }
455    let assets_verification = assets.into_verification();
456    drop(target);
457    let reopened = Arc::new(Source::open(&temporary)?);
458    if salvage.is_some() || lost_collections.is_some() {
459        salvage::verify_vault(&temporary, &reopened)?;
460    }
461    ensure(
462        reopened.header.physical == physical,
463        "rewritten physical header failed parity",
464    )?;
465    let rewritten_catalog = Catalog::read(&reopened)?;
466    ensure(
467        rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
468        "rewritten catalog differs from source",
469    )?;
470    for (&old, &new) in &roots {
471        if let Some((count, hash)) = recovered_trees.get(&old) {
472            verify_recovered_tree(&reopened, new, *count, *hash)?;
473        } else {
474            verify_tree(&source, &reopened, old, new)?;
475        }
476    }
477    for (id, expected, checksum) in assets_verification {
478        let page = reopened.raw_page(id, checksum)?;
479        if id != 2 {
480            reopened.claim(id)?;
481        }
482        ensure(
483            blake3::hash(page.content()) == expected,
484            "native asset failed readback parity",
485        )?;
486    }
487    let report = CompactionReport {
488        source_bytes: source.bytes,
489        destination_bytes: reopened.bytes,
490        source_allocated_bytes: allocated_bytes(&source_path)?,
491        destination_allocated_bytes: allocated_bytes(&temporary)?,
492        records,
493        trees: roots.len(),
494        repaired_leaf_backlinks,
495    };
496    drop(reopened);
497    copy_sidecars(&source_path, &output)?;
498    write_new(
499        &destination.join("compaction.json"),
500        report.machine_json().as_bytes(),
501    )?;
502    if let Some(salvage) = salvage {
503        write_new(
504            &destination.join("salvage.json"),
505            salvage.machine_json().as_bytes(),
506        )?;
507    }
508    if let Some(losses) = lost_collections {
509        write_new(
510            &destination.join("salvage.json"),
511            losses.machine_json().as_bytes(),
512        )?;
513    }
514    sync_directory(destination)?;
515    // hard_link is create-new even if an unexpected file appears during work.
516    std::fs::hard_link(&temporary, &output)?;
517    sync_directory(destination)?;
518    std::fs::remove_file(&temporary)?;
519    sync_directory(destination)?;
520    if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
521        sync_directory(parent)?;
522    }
523    Ok(report)
524}
525fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
526    roots.values().map(|root| (*root, *root)).collect()
527}
528
529fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
530    hasher.update(&(key.len() as u64).to_le_bytes());
531    hasher.update(key);
532    hasher.update(&(value.len() as u64).to_le_bytes());
533    hasher.update(value);
534}
535
536fn rewrite_recoverable_tree(
537    source: &Arc<Source>,
538    target: &Arc<Pager>,
539    loss: &PagedCollectionLoss,
540    roots: &mut BTreeMap<u32, u32>,
541) -> Result<(u64, blake3::Hash)> {
542    ensure(
543        !roots.contains_key(&loss.root_page),
544        "duplicate live tree owner",
545    )?;
546    let original = BTree::with_root(Arc::clone(source), loss.root_page);
547    let destination = BTree::new(Arc::clone(target));
548    let mut hash = blake3::Hasher::new();
549    let mut count = 0u64;
550    let mut batch = Vec::new();
551    let mut size = 0usize;
552    let skipped = original.visit_recoverable_leaves(
553        |id| {
554            source.claim(id).map_err(|error| {
555                reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
556            })
557        },
558        |key, value| {
559            hash_record(&mut hash, &key, &value);
560            size += key.len() + value.len();
561            batch.push((key, value));
562            count += 1;
563            if size >= 1024 * 1024 || batch.len() >= 512 {
564                destination.bulk_insert_sorted(&batch)?;
565                batch.clear();
566                size = 0;
567            }
568            Ok(())
569        },
570    )?;
571    ensure(
572        skipped == loss.skipped_pages,
573        "damaged pages changed during recovery",
574    )?;
575    destination.bulk_insert_sorted(&batch)?;
576    roots.insert(loss.root_page, destination.root_page_id());
577    Ok((count, hash.finalize()))
578}
579
580fn verify_recovered_tree(
581    source: &Arc<Source>,
582    root: u32,
583    count: u64,
584    expected: blake3::Hash,
585) -> Result<()> {
586    let tree = BTree::with_root(Arc::clone(source), root);
587    let (owned, _) = inventory_tree(source, &tree, false)?;
588    ensure(owned == count, "recovered tree record count differs")?;
589    let mut cursor = tree.cursor_first()?;
590    let mut hash = blake3::Hasher::new();
591    let mut actual = 0u64;
592    while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
593        hash_record(&mut hash, &key, &value);
594        actual += 1;
595        ensure(actual <= count, "recovered tree has extra records")?;
596    }
597    ensure(
598        actual == count && hash.finalize() == expected,
599        "recovered tree failed record parity",
600    )
601}
602
603fn rewrite_tree(
604    source: &Arc<Source>,
605    target: &Arc<Pager>,
606    root: u32,
607    roots: &mut BTreeMap<u32, u32>,
608    repaired_leaf_backlinks: &mut u64,
609) -> Result<u64> {
610    if root == 0 {
611        return Ok(0);
612    }
613    ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
614    let tree = BTree::with_root(Arc::clone(source), root);
615    let (expected, repairs) = inventory_tree(source, &tree, true)?;
616    *repaired_leaf_backlinks += repairs;
617    let destination = BTree::new(Arc::clone(target));
618    let mut cursor = tree.cursor_first()?;
619    let mut count = 0;
620    let mut previous: Option<Vec<u8>> = None;
621    let mut batch = Vec::new();
622    let mut bytes = 0;
623    while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
624        ensure(
625            previous.as_ref().is_none_or(|old| old < &key),
626            "source tree keys are not strictly ordered",
627        )?;
628        previous = Some(key.clone());
629        bytes += key.len() + value.len();
630        batch.push((key, value));
631        count += 1;
632        ensure(
633            count <= expected,
634            "tree cursor exceeds structural inventory",
635        )?;
636        if bytes >= 1024 * 1024 || batch.len() >= 512 {
637            destination.bulk_insert_sorted(&batch)?;
638            batch.clear();
639            bytes = 0;
640        }
641    }
642    destination.bulk_insert_sorted(&batch)?;
643    ensure(count == expected, "tree cursor omits owned records")?;
644    roots.insert(root, destination.root_page_id());
645    Ok(count)
646}
647// Interior ownership and forward traversal must agree exactly. Old split code
648// omitted the successor backlink update; that redundant edge can be rebuilt
649// without guessing any ownership or key/value. Never repair forward omissions.
650fn inventory_tree(
651    source: &Source,
652    tree: &BTree<Source>,
653    allow_backlinks: bool,
654) -> Result<(u64, u64)> {
655    let pages = tree.retired_pages()?;
656    let mut leaves = Vec::new();
657    let mut expected = 0u64;
658    for id in pages {
659        source.claim(id)?;
660        let page = source.raw_page(id, true)?;
661        if page.page_type()? == PageType::BTreeLeaf {
662            leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
663            expected += u64::from(page.cell_count());
664        }
665    }
666    // Ownership traversal is right-to-left. A cursor follows the independent
667    // leaf chain; prove it visits exactly the structurally owned leaves.
668    leaves.reverse();
669    let mut repaired = 0;
670    for (index, &(id, previous, next)) in leaves.iter().enumerate() {
671        ensure(
672            next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
673            &format!(
674                "broken forward leaf chain at page {id}, root {}",
675                tree.root_page_id()
676            ),
677        )?;
678        if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
679            ensure(
680                allow_backlinks,
681                &format!("broken backward leaf chain at page {id}"),
682            )?;
683            repaired += 1;
684        }
685    }
686    Ok((expected, repaired))
687}
688fn verify_tree(source: &Arc<Source>, destination: &Arc<Source>, old: u32, new: u32) -> Result<()> {
689    let old = BTree::with_root(Arc::clone(source), old);
690    let new = BTree::with_root(Arc::clone(destination), new);
691    inventory_tree(destination, &new, false)?;
692    let mut left = old.cursor_first()?;
693    let mut right = new.cursor_first()?;
694    loop {
695        let expected = left.next_with_budget(32 * 1024 * 1024)?;
696        let actual = right.next_with_budget(32 * 1024 * 1024)?;
697        ensure(
698            expected == actual,
699            "compacted tree failed complete key/value parity",
700        )?;
701        if expected.is_none() {
702            return Ok(());
703        }
704    }
705}
706fn copy_sidecars(source: &Path, destination: &Path) -> Result<()> {
707    // These files contain logical records, not physical page addresses. Empty
708    // checkpointed WALs also preserve their version exactly.
709    for (old, new) in [
710        (
711            f::unified_wal_path(source),
712            f::unified_wal_path(destination),
713        ),
714        (f::engine_wal_path(source), f::engine_wal_path(destination)),
715        (
716            f::logical_wal_path(source),
717            f::logical_wal_path(destination),
718        ),
719        (
720            f::legacy_logical_slots_path(source),
721            f::legacy_logical_slots_path(destination),
722        ),
723        (
724            f::legacy_audit_log_path(source),
725            f::legacy_audit_log_path(destination),
726        ),
727    ] {
728        match File::open(&old) {
729            Ok(mut input) => {
730                let mut output = create_new(&new)?;
731                std::io::copy(&mut input, &mut output)?;
732                output.sync_all()?;
733                verify_file_copy(&old, &new)?;
734            }
735            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
736            Err(error) => return Err(error.into()),
737        }
738    }
739    let support = f::support_dir_for(source);
740    if support.exists() {
741        validate_empty_directory(&support, 0)?;
742        copy_directory(&support, &f::support_dir_for(destination), 0)?;
743    }
744    let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
745    let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
746    if old.exists() {
747        validate_operational(&old)?;
748        copy_directory(&old, &new, 0)?;
749    }
750    Ok(())
751}
752fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
753    ensure(depth < 32, "operational directory nesting exceeds limit")?;
754    ensure(
755        std::fs::symlink_metadata(source)?.is_dir(),
756        "operational directory must not be a symlink",
757    )?;
758    std::fs::create_dir(destination)?;
759    for entry in std::fs::read_dir(source)? {
760        let entry = entry?;
761        let kind = entry.file_type()?;
762        let output = destination.join(entry.file_name());
763        if kind.is_dir() {
764            copy_directory(&entry.path(), &output, depth + 1)?;
765        } else {
766            ensure(
767                kind.is_file(),
768                "operational directory contains a symlink or special file",
769            )?;
770            let mut input = File::open(entry.path())?;
771            let mut target = create_new(&output)?;
772            std::io::copy(&mut input, &mut target)?;
773            target.sync_all()?;
774            verify_file_copy(&entry.path(), &output)?;
775        }
776    }
777    sync_directory(destination)
778}
779fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
780    ensure(depth < 32, "support directory nesting exceeds limit")?;
781    ensure(
782        std::fs::symlink_metadata(path)?.is_dir(),
783        "support directory must not be a symlink",
784    )?;
785    for entry in std::fs::read_dir(path)? {
786        let entry = entry?;
787        ensure(
788            entry.file_type()?.is_dir(),
789            "non-empty support layout requires separate compaction qualification",
790        )?;
791        validate_empty_directory(&entry.path(), depth + 1)?;
792    }
793    Ok(())
794}
795fn validate_operational(root: &Path) -> Result<()> {
796    ensure(
797        !root.join("forks").exists(),
798        "store forks require separate compaction qualification",
799    )?;
800    let manifest = root.join("manifest.json");
801    ensure(
802        std::fs::metadata(&manifest)?.len() <= 64 * 1024 * 1024,
803        "operational manifest exceeds budget",
804    )?;
805    let document: crate::serde_json::Value =
806        crate::serde_json::from_str(&std::fs::read_to_string(manifest)?)?;
807    fn check(value: &crate::serde_json::Value, depth: usize) -> Result<()> {
808        use crate::serde_json::Value;
809        ensure(depth < 32, "operational manifest exceeds nesting limit")?;
810        match value {
811            Value::Object(fields) => {
812                for (key, value) in fields {
813                    if key == "source" || key == "fork_origin" {
814                        ensure(
815                            matches!(value, Value::Null),
816                            "external operational owners require separate qualification",
817                        )?;
818                    }
819                    if key == "path" {
820                        let path = Path::new(value.as_str().ok_or("non-string operational path")?);
821                        ensure(
822                            !path.is_absolute()
823                                && path
824                                    .components()
825                                    .all(|part| matches!(part, std::path::Component::Normal(_))),
826                            "operational path escapes its directory",
827                        )?;
828                    }
829                    check(value, depth + 1)?;
830                }
831            }
832            Value::Array(values) => {
833                for value in values {
834                    check(value, depth + 1)?;
835                }
836            }
837            _ => {}
838        }
839        Ok(())
840    }
841    check(&document, 0)
842}
843fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
844    fn hash(path: &Path) -> Result<blake3::Hash> {
845        let mut file = File::open(path)?;
846        let mut hash = blake3::Hasher::new();
847        let mut buffer = [0; 64 * 1024];
848        loop {
849            let count = file.read(&mut buffer)?;
850            if count == 0 {
851                break;
852            }
853            hash.update(&buffer[..count]);
854        }
855        Ok(hash.finalize())
856    }
857    ensure(
858        hash(source)? == hash(destination)?,
859        "sidecar failed complete readback parity",
860    )
861}
862fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
863    #[cfg(unix)]
864    {
865        use std::os::unix::fs::MetadataExt;
866        Ok(Some(
867            std::fs::metadata(path)?
868                .blocks()
869                .checked_mul(512)
870                .ok_or("allocated byte count overflow")?,
871        ))
872    }
873    #[cfg(not(unix))]
874    {
875        let _ = path;
876        Ok(None)
877    }
878}
879fn create_new(path: &Path) -> std::io::Result<File> {
880    let mut options = OpenOptions::new();
881    options.write(true).create_new(true);
882    #[cfg(unix)]
883    {
884        use std::os::unix::fs::OpenOptionsExt;
885        options.mode(0o600);
886    }
887    options.open(path)
888}
889fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
890    let mut file = create_new(path)?;
891    file.write_all(bytes)?;
892    file.sync_all()?;
893    Ok(())
894}
895fn sync_directory(_path: &Path) -> Result<()> {
896    #[cfg(unix)]
897    File::open(_path)?.sync_all()?;
898    Ok(())
899}
900fn ensure(condition: bool, message: &str) -> Result<()> {
901    if condition {
902        Ok(())
903    } else {
904        Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
905    }
906}
907fn number(bytes: &[u8], offset: usize) -> Result<u32> {
908    Ok(u32::from_le_bytes(
909        bytes
910            .get(offset..offset + 4)
911            .ok_or("truncated native integer")?
912            .try_into()?,
913    ))
914}